Compare commits

..
Author SHA1 Message Date
总工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
总管andmultica-agent be149cd6f7 HEL-879 verify endpoint grants and correct final window evidence
Co-authored-by: multica-agent <github@multica.ai>
2026-10-28 00:11:44 +08:00
opencodeandmultica-agent 376653135b HEL-879 R5 real-machine window executed (2026-10-27 15:26:54-15:58:10 UTC, 31min16s, within 2h/3-call/40req/500k-token/200-TTS-segment/0-embed authorization): real chain verified on .12 (isolated 8360/8361 dual-entry + 8363 WSS bridge + dedicated worker 8082 + shared TTS 9880 recovered for the window only) -- 3 conversational calls incl. 3 continuous rounds with receipts acked (4 sharing_attempt ack_at/judged), 3 real barge-in interrupts (turn_cancel interrupted, old turn fail-closed), hangup deletes room with ~20ms residual, abrupt-drop rooms TTL-compensated clean after API restart epoch 4->5, voice/text mutual cancel via same chat lock observed (voice-cancelled-text direction); usage ledger 14/40 requests, 42717/500000 tokens, 12/200 TTS segments, 0 embeddings, 0 new spend, budget sealed (real usage preserved) + consistency snapshot archived; two real-machine fixes with evidence: livekit_wire server token now carries SDK-parity video grants (room_admin/room_create; grantless bearer rejected 401 by real server) and Twirp calls switched to JSON encoding (protobuf body unparseable), offline fakes updated, 352/352 pytest + ruff(src/tests/scripts) clean; self-signed cert reissued critical CA:FALSE SAN=IP after rustls CaUsedAsEndEntity rejection; D1 audition README corrected to 'non-custom-function entry' per user feedback (comment 01a17de1); precise ~1s interrupt stop timing and text-cancels-voice direction honestly listed as not-obtained (follow-up window); all window-only processes torn down, protected services untouched, ComfyUI stays paused
Co-authored-by: multica-agent <github@multica.ai>
2026-10-28 00:00:41 +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
zonggongandmultica-agent 8dc84f88e9 HEL-879 final closure: integrate preserved HEL-898 rework (b0dcb17->18aef2a ff, zero backend diff), one-pass review of the three fixes passes offline (agent-left teardown via existing fail path incl. DELETE debt; closed set per-call unbounded, seq gate unchanged; speaking-state sentence-granularity display with in-place grow), web 118/118 + typecheck/build/lint(0err/1known) green, req checks 3/3; deliver D1 zero-synthesis audition package (11 reference recordings fetched read-only, sha256 matched per file, offline index.html + manifest; repo keeps manifest/provenance only) and update r5-test-package section 4/8 into the delivered-audition + consolidated request form; zero real calls/downloads/installs/deploy/merge, caps stay 0
Co-authored-by: multica-agent <github@multica.ai>
2026-10-27 07:24:27 +08:00
68 changed files with 8209 additions and 71 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 · 主动联系范围
File diff suppressed because one or more lines are too long
+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 通过",
"前端与浏览器未在本机复跑(环境缺依赖且本卡禁下载安装),以作者日志+逐行静态复核+保全哈希复核作证据,如实标注未验证范围;真机项全部列入补测包"
]
}
+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"
}
]
+47
View File
@@ -0,0 +1,47 @@
# HEL-879 · D1 参考试听包(零合成,已交付)
> 交付时间 2026-10-27。性质:把服务器既有 11 支音色的 zero-shot 参考录音整理成
> 可离线播放的 HTML 试听页 + 音频包,作为主卡 D1 拍板材料。**零 TTS 合成、零
> 克隆、零共享音色切换、零下载安装**;对 .12 只做 SSH 只读复制,未改服务器任何
> 文件与状态。
>
> **2026-10-27 勘误(主卡授权轮)**:白栖知反馈「让我解压附件包试听」的设计
> 不合理(主卡评论 `01a17de1-2892-78be-a422-eb6a11ffebfd`)——他要的是可
> 自定义音色,而不是从预设音色里挑一个。本包正名为「**非定制功能入口**」:
> 它只是既有 11 支登记音色的参考原声对照材料,**不是**用户音色定制/上传/录制
> 的功能入口;正式的自定义音色(上传、录制、试听、应用)由独立施工分支接续,
> 未完成前不宣称可用。本轮 R5 有限测试由总管选定临时测试音色 `female_soft`
> (已核服务器登记;临时测试值,不是用户最终音色),不要求用户解压本包选 ID。
## 包内容(随本卡评论附件交付,不入仓库大媒体)
`HEL-879-D1-参考试听包.zip`(4.4MB,sha256
`e20764f9f98cbfac5ef966227540a75c5061cf16d151e5d0c9d86b620e3972f9`):
- `index.html` —— 离线试听页(自包含,无外部依赖;解压后直接用浏览器打开),
逐支列出音色 ID、时长/采样率、参考录音原文、播放器、文件哈希前缀;
页首明确注明「参考录音 ≠ 实际合成效果」。
- `manifest.json` —— 机器可读清单:11 支音色的 id / 文件 / prompt_text /
服务器源路径 / sha256 / 字节数 / 秒数 / 采样率 / 声道(本目录留有同内容副本)。
- `voice_samples/*.wav` —— 11 段参考录音原文件。
## 来源与取回方式(可复核)
- 音色登记:`192.168.200.12:/opt/voice-agent/cosyvoice/voices.json`(实读,
11 个 ID:bailu、bajie、default、female_soft、haimianbaobao、jiaojiao、
male_warm、nezha、rain、sunwukong、xiaoyun;每项 `prompt_wav` + `prompt_text`)。
- 音频文件:`prompt_wav` 所指 wav(`voice_samples/<id>.wav` 10 支 +
default 用 `CosyVoice/asset/zero_shot_prompt.wav`),`scp` 只读取回。
- 完整性:每文件本地 sha256 与服务器 `sha256sum` 输出逐一相符(见
manifest.json;default 为 IEEE float32/24kHz/3.5s,bailu 为 44.1kHz 立体声
10s,其余 16k/32k 单声道 2.4–16.8s)。
## 边界(如实)
- 参考录音是各音色**来源说话人的真实录音**(zero-shot 克隆依据),不是
CosyVoice 实际合成产物;实际合成效果与参考录音不同,需在获批测试窗口内
真实合成复核(计入 TTS 预算)。
- 参考录音原文为各来源录音本身的内容,按原样保留,仅作试听对照;其中数支
为影视/角色向来源,是否适合「她」由白栖知试听后判断,工程不代选、不预筛。
- 通用演示当前 `voice_state.json` = rain 是共享演示状态,不是本用户的选择依据。
- 克隆新音色需写共享 `voices.json` 并触发共享 TTS 热加载,**需单独授权**,本包未做。
@@ -0,0 +1,135 @@
{
"package": "HEL-879 D1 参考试听包(零合成)",
"generated": "2026-10-27",
"source_server": "192.168.200.12 (SSH 只读取回,未改服务器任何文件)",
"source_registry": "/opt/voice-agent/cosyvoice/voices.json",
"count": 11,
"notes": [
"参考录音 = 该音色 zero-shot 克隆的来源录音(音色来源说话人的真实录音),不是 CosyVoice 实际合成产物;实际合成效果与参考录音不同,需在获批测试窗口内真实合成复核(计入 TTS 预算)。",
"通用演示当前使用 rain,是共享演示状态,不是本用户的选择依据。",
"本包只读取得,未切换/修改任何共享音色状态;克隆需另行单独授权。"
],
"voices": [
{
"id": "default",
"file": "voice_samples/zero_shot_prompt.wav",
"prompt_text": "希望你以后能够做的比我还好呦。",
"server_prompt_wav": "/opt/voice-agent/cosyvoice/CosyVoice/asset/zero_shot_prompt.wav",
"sha256": "c7b31d6dbe7cc6a716dded00550db5b50940bf209e424e4ad207b12e657c8ff6",
"bytes": 334138,
"seconds": 3.5,
"sample_rate": 24000,
"channels": 1
},
{
"id": "sunwukong",
"file": "voice_samples/sunwukong.wav",
"prompt_text": "俺老孙来也,俺乃500年前大闹天宫的齐天大圣孙悟空,这里山势险峻,只怕藏有妖精妖怪,哪里逃吃?俺老孙吧。",
"server_prompt_wav": "/opt/voice-agent/cosyvoice/voice_samples/sunwukong.wav",
"sha256": "a0cf44a8ab659ebddb8bc4faffe88f717d14d2c66c09bc5ea657212826806e68",
"bytes": 429746,
"seconds": 13.4,
"sample_rate": 16000,
"channels": 1
},
{
"id": "nezha",
"file": "voice_samples/nezha.wav",
"prompt_text": "天雷滚滚,我好怕怕劈的我浑身掉渣渣,突破天劫,我笑,哈哈逆天改命。我吹喇叭滴答滴答,滴滴答。",
"server_prompt_wav": "/opt/voice-agent/cosyvoice/voice_samples/nezha.wav",
"sha256": "9911a73e3f54c235dedb05c18f29356f41ba3b1e1b58811c4f766897f0fe1d10",
"bytes": 305196,
"seconds": 9.5,
"sample_rate": 16000,
"channels": 1
},
{
"id": "bajie",
"file": "voice_samples/bajie.wav",
"prompt_text": "师傅,哎,这取经路比高,老庄种地还苦,俺老猪饿的能吞下石龙素包子,猴哥,你筋斗云块,要不先去画个圆。",
"server_prompt_wav": "/opt/voice-agent/cosyvoice/voice_samples/bajie.wav",
"sha256": "1f9c84ce8c10145ace5f615a428a08e2482f322363f037245878068aa95ed98a",
"bytes": 424320,
"seconds": 13.3,
"sample_rate": 16000,
"channels": 1
},
{
"id": "haimianbaobao",
"file": "voice_samples/haimianbaobao.wav",
"prompt_text": "没错,我就是个笨蛋派大星,我们一起去抓水母吧,我们一起度过快乐的一天。",
"server_prompt_wav": "/opt/voice-agent/cosyvoice/voice_samples/haimianbaobao.wav",
"sha256": "c60912dfccd76390baa921d4c99b0120f528837608964290e96cad1221da9c9d",
"bytes": 228726,
"seconds": 7.1,
"sample_rate": 16000,
"channels": 1
},
{
"id": "female_soft",
"file": "voice_samples/female_soft.wav",
"prompt_text": "这点我说一下,就是因为那个就是摩根豪泽尔这本儿金钱心理学。其实在我们这行业里也算一本很有名名的书了。就我之前也读过。然后你说到这点啊,让我想起来他这本书里还挺。",
"server_prompt_wav": "/opt/voice-agent/cosyvoice/voice_samples/female_soft.wav",
"sha256": "4a65d17099239711601cb17e28b7aa7b6149acd153b92fd0366c23902bd4f687",
"bytes": 341192,
"seconds": 10.7,
"sample_rate": 16000,
"channels": 1
},
{
"id": "male_warm",
"file": "voice_samples/male_warm.wav",
"prompt_text": "um a while we built eight hype looe test track adjacent to space acts just for a student competition。 um to encourage intiative ideas in transport and actually ends up being the the biggest um vacuum chamber in the world after the launch。 haveringcollider。",
"server_prompt_wav": "/opt/voice-agent/cosyvoice/voice_samples/male_warm.wav",
"sha256": "2c4109b2d685e1923ed66433eb08c92047a1f67510629a27edf49af4e5c606dd",
"bytes": 539032,
"seconds": 16.8,
"sample_rate": 16000,
"channels": 1
},
{
"id": "xiaoyun",
"file": "voice_samples/xiaoyun.wav",
"prompt_text": "盯着你看怎么啦,难得看你这么有意思的样子嘛",
"server_prompt_wav": "/opt/voice-agent/web/../cosyvoice/voice_samples/xiaoyun.wav",
"sha256": "6ad3ca7a6f1d9ddd10c5647338200fc9913f7adc71d8495a61b6b8e18eadc87e",
"bytes": 297004,
"seconds": 4.6,
"sample_rate": 32000,
"channels": 1
},
{
"id": "rain",
"file": "voice_samples/rain.wav",
"prompt_text": "你是不是早就想睡我了呀",
"server_prompt_wav": "/opt/voice-agent/web/../cosyvoice/voice_samples/rain.wav",
"sha256": "37db24611b999f2f2328fa19c5c6e26117b5ec5d3d2037068af2fc12c6ad25ba",
"bytes": 152364,
"seconds": 2.4,
"sample_rate": 32000,
"channels": 1
},
{
"id": "jiaojiao",
"file": "voice_samples/jiaojiao.wav",
"prompt_text": "怎么还有没射的啊,是我不够努力吗",
"server_prompt_wav": "/opt/voice-agent/web/../cosyvoice/voice_samples/jiaojiao.wav",
"sha256": "6db2b2de2d59e22bb0badbb1a6325d68885eda409ad63f5941e5ccad6486fc9f",
"bytes": 301484,
"seconds": 4.7,
"sample_rate": 32000,
"channels": 1
},
{
"id": "bailu",
"file": "voice_samples/bailu.wav",
"prompt_text": "烈火军校啊,招摇啊这种类型,我都是那种比较……呃……豪爽,比较开朗的那种类型,阳光大女孩儿,那种感觉",
"server_prompt_wav": "/opt/voice-agent/web/../cosyvoice/voice_samples/bailu.wav",
"sha256": "3fbc0aff09118a9bc10bae87a837e6a3de6a30268c6ee01c727b56c49aecd26d",
"bytes": 1769550,
"seconds": 10.0,
"sample_rate": 44100,
"channels": 2
}
]
}
+124
View File
@@ -0,0 +1,124 @@
# HEL-879 · R5 有限真实语音测试窗口记录(2026-10-27)
> 授权:白栖知主卡评论 `01a17de1-2892-78be-a422-eb6a11ffebfd`(同意最长 2 小时 /
> 3 次通话;音色由工程自定);额度沿《r5-test-package.md》§6 申请上限执行。
> 窗口(UTC):**15:26:54 – 15:58:10**(31 分 16 秒,未用满 2 小时)。
> 候选 `hel-879-assembly` @ `8dc84f8`;模型/运行时/认证与既有实例一致(同一
> `192.168.200.11:8731` halogen 端点),零新增付费支出。
## 1. 部署布局(全部独立,已全部撤除)
| 组件 | 端口/路径 | 说明 |
| --- | --- | --- |
| 统一实例(同进程双入口) | HTTP 8360 + HTTPS 8361 | `deploy/voice/serve_dual_8360_8361.py`,隔离数据 `/opt/cybergril-hel879/data/`;`.venv311`+uv CPython 3.11.16 自本机离线复制(.12 无 uv/node,零下载) |
| WSS TLS 桥 | 8363 → 127.0.0.1:7880 | `deploy/voice/wss_bridge.example.py`,与 8361 同一张自签证书 |
| 专属 worker | 健康口 8082 | `/opt/voice-agent-cybergril/`,复用 agent venv(livekit-agents 1.8.8),`voice_state_cybergril.json` = `female_soft`(临时测试值;未写通用助手 `voice_state.json`/`voices.json`) |
| 共享 TTS(CosyVoice) | 9880 | 窗口内从停机恢复;结束时核实无他人占用后按窗口前状态停回 |
| 测试档案 | — | 隔离实例自建测试伴侣(名字「小测」),非用户真实档案 |
配置差异(均记录在案、可回滚):`models.yaml` 预算写入本轮批准值
(dialogue 40、total 40/500k,其余类别 0);`CYBERGRIL_SEMANTIC=stub`(嵌入 0);
`CYBERGRIL_R2_ENABLED=1`(receipt 签发依赖生活运行时开启;life/background 预算
仍为 0,实测无后台模型调用);`voice.yaml` `session_ttl_seconds: 900`
(真实验证 TTL 到期删房用;仓库默认 7200)。
## 2. 真机发现并修复的代码问题(有限返工,含证据)
1. **Twirp 服务 token 缺少端点授权(401)**:`livekit_wire.mint_server_token`
原按「无 grants 形态」铸造,但已装 SDK 的 `create_dispatch` 以
`VideoGrants(room_admin=True, room=…)` 签名、`delete_room` 以
`VideoGrants(room_create=True)` 签名;真机 LiveKit 拒绝无授权 bearer(401)。
离线期 8 项 SDK 检查只比对了 token 形态,未对真服务器发送,未暴露此问题。
修复:`mint_server_token` 增加 `video` grants 参数,两处调用按 SDK 同款授权。
2. **protobuf 响应不可解析**:CreateDispatch 以 `application/protobuf` 请求时
服务器回二进制,原实现的 `json.loads` 崩溃。修复:改走 Twirp JSON
(`application/json`),零新依赖,dispatch id 正常解析;离线替身同步改为
JSON 断言(`tests/test_voice_session.py`、`tests/test_voice_rework_hel879.py`)。
3. **自签证书 `CA:TRUE` 被 rustls 拒绝**:openssl 默认给自签证书加了
`basicConstraints=CA:TRUE`,livekit 客户端(rustls)报 `CaUsedAsEndEntity`。
重签为 `critical,CA:FALSE` + `SAN=IP:192.168.200.12` 后通过;测试客户端以
`SSL_CERT_FILE` 进程级信任该证书(非系统信任库、非浏览器例外冒充)。
修复后全量 `pytest tests -q` **352/352** 通过、ruff(src/tests/scripts)0 错;
本文件与修复随本轮提交入库。
## 3. 验证结果
真链:预录参考录音(**音频回放,非用户设备麦克风/手机**)→ 真实 WebRTC →
LiveKit 7880(客户端经 8363 WSS 桥)→ FunASR 流式识别 → 统一 `POST /api/chat`
(真 LLM)→ CosyVoice 合成(female_soft)→ WebRTC 回流收音(48k WAV 落盘)。
| 项 | 结果 | 证据 |
| --- | --- | --- |
| 连续 ≥3 轮(同档案、同对话源、音色一致) | ✅ 通话2:haimianbaobao/nezha/rain 三轮全部 `turn_end`;跨通话记忆延续可见(通话2 回复接住通话1 话题) | `call1b.subtitles.jsonl`、`chat_message` 全表 |
| 完整播放 receipt | ✅ 总管收尾独立复核:最终库及一致性快照均为 **8 条** `sharing_attempt`,全部 `ack_at` 落值、state=judged;取消用户轮 msg7/msg23 对应回执均为0。原交付4条是较早阶段数字,不能代表最终库 | 只读SQL与快照恢复核对;`/api/chat/received 200` |
| 开口打断 | ✅(发生真实性)通话1 三次真实 `turn_cancel(interrupted)`,用户开口截断在播回复、旧轮 user 行标 cancelled;**精确 ~1s 停声计时未取得**(量测设计缺陷+后两轮回复过短无以复测,见 §5) | `call1.subtitles.jsonl` seq68/87/92 |
| 软件客户端挂断清理 | ✅ DELETE 200 → 房间真实删除(ListRooms 空)、删除后仅 2 帧在途(约20ms)、软件客户端 track 停发;**不代表用户设备麦克风指示灯或扬声器残音已验** | `call1b.json hangup` |
| 断线(不挂断)+ TTL 到期后台删房 | ✅ 掉线不删 → 会话过期自动转债删除;15:52/15:56/15:57 三笔全部结清,账本空、房间空 | 轮询记录(窗口观察) |
| API 重启清理 | ✅ 重启 epoch 4→5、档案/历史完整、voice 配置在;重启遗留会话按 TTL 由补偿清理全部结清 | 同上 |
| 文字/语音互斥 | ✅(机制)文字轮被后到语音轮取消(msg23=cancelled)——同一把 chat 锁双向互斥成立;**「文字打断语音」方向未单独取证**(触发时机竞态,见 §5) | `call3.json`、消息表 |
| HTTPS/WSS | ✅ 8361/8363 同证书 TLS1.3、SAN=IP 可达;WSS 桥真实承载信令与媒体建立(客户端全程经 8363 接入) | 窗口日志、preflight |
| 嵌入 0 / 语义 stub | ✅ `semantic_backend=stub`,全程零嵌入调用 | `/api/health` |
| 字幕 v2 全生命周期 | ✅ seq 单调(三次通话均验证)、interim→final→turn_end / 打断→turn_cancel 无 final | 各 `subtitles.jsonl` |
## 4. 用量账(逐笔可查,硬闸全程在位)
- **LLM**:14/40 次请求、42,717/500,000 tokens(`budget_total`/`request_usage`/
`token_usage` 逐行;含失败/取消轮,预占语义如代码注释)。结束即封账:
`seal_budget_hel879.py` 已执行,封账标记随库归档,真实用量保留不清零。
- **TTS**:12/200 合成段(`tts.log` POST /tts 计数;本地 GPU、零外部计费)。
- **ASR**:仅窗口内 3 次通话使用(本地 CPU FunASR,零外部计费)。
- **嵌入/新增付费**:0。
- **语音会话**:建房 6 次 = **3 次真实通话**(含全部验证轮次)+ 3 次工具端
崩溃遗留的空会话(1 次手工 DELETE、2 次由 TTL 补偿自动删除;空会话无对话、
零模型消耗,如实计数不隐瞒)。
## 5. 未测 / 留待下轮(如实)
- **精确 1 秒级打断停声计时**:本轮三次真实打断均已发生并留痕,但帧级停声
时间戳未随证据落盘(下一轮客户端应在每帧记录到达时间并按 speech 归属切分)。
`call1.json` 已有发布音频到取消3.26秒、到最后agent帧5.3秒、cancel后204帧的
原始粗计时,不能隐去这些慢值。该计时未区分触发录音的语音起点、旧speech与
后续speech,不能据此量出旧声准确停止时间,**更不能判定达到约1秒**;下轮须
定向测量后再作结论。
- **「文字打断语音」方向**:本轮竞态取证到反向(语音取消文字);机制同一把锁,
下轮把文字触发改为「assistant_interim 出现后」即可定向取证。
- **真实设备浏览器路径**(D3 用户动作):证书「高级→继续」两处(8361/8363)、
真机麦克风(iOS Safari/安卓 Chrome)未测——须用户设备动作,本轮交付地址与
最小步骤,不以 rustls/SSL_CERT_FILE 冒充浏览器例外。
- 回复质量观察:长上下文后出现单字回复与一条英文串扰(与测试档案 boundary
文案提到「测试/临时」相关)——记录为内容质量观察项,非链路故障。
## 6. 收尾与回滚
- 窗口结束:worker/桥/双入口进程全部停止,8360/8361/8363/8082/9880 复核无监听;
TTS 按窗口前状态停回(精确 PID 终止,未用 fuser -k,核过无他人占用)。
- 受保护服务零接触:8300/8310/8320/8350/7880/7881/10095 原样运行,8340 保持
停机,ComfyUI 保持暂停(GPU 回到 97MiB 占用)。
- 数据封存:预算封账 + `release_snapshot_hel896.py` 停服一致性快照
(`/opt/cybergril-hel879/archives/hel879-r5-window-close-*`,rows_match 全过);
证据包 `/opt/cybergril-hel879/hel879-r5-evidence-20261027.tar.gz`
(sha256 `b23af43b…c394`,含三次通话字幕/JSON/音频/worker/TTS 日志/清理账)。
- 回滚:全部为本轮新建独立进程/目录,删除 `/opt/cybergril-hel879`、
`/opt/voice-agent-cybergril`、`/opt/cybergril-voice-certs` 即完全退场;Git 侧
为本分支正常追加提交,PR#11 与既有实例未动。
## 7. 总管独立收尾复核补充
只读核对14次请求/42717 tokens及sealed=1,封账值与真实用量相等;清理账sessions=0、
debts=0,8360/8361/8363/8082/9880无监听。没有重新打开原账或恢复本轮服务。
证据包完整SHA256为`b23af43b39ed77263c7b3f285796e77e8b1278acbf547b4619adad600ed5c394`。
一致性快照已由总管在本地隔离目录按原工具实际恢复,SHA256与三方行数相等,
恢复库同为sealed=1、14/42717用量和8条已确认回执。归档永不因上述“回滚”说明
自动删除;保留测试资料与证据,销毁须另有明确授权。
原MANIFEST的code_commit记录8dc84f8(运行时修复尚未提交),不是最终修复提交。
独立复核部署中的livekit_wire.py SHA256为
`ffc477cd96d48c510905ce9fb887ba5b00edd6941ee59cf1b960f51f18500941`,
与3766531收录源码逐字节一致;本说明补足溯源,不篡改原归档。
本轮只完成R5部分验证,不能称全部R5验收通过。实际6次建房必须连同3个空会话如实
保留,不把“3次实际通话”写成“只建房3次”。窗口已收尾且3次实际通话用完,未用
模型/token/TTS额度不自动授权新窗口;后续将精确打断计时、文字打断语音和用户设备
浏览器路径作为必补项,与自定义音色交回后的统一验证安排集中处理,不擅自重开账。
+96
View File
@@ -0,0 +1,96 @@
{
"rounds": [
{
"play_label": "xiaoyun",
"reply": {
"kind": "turn_cancel",
"speech_id": "speech_b687b8055307",
"reason": "interrupted"
}
},
{
"play_label": "bailu",
"reply": {
"kind": "turn_cancel",
"speech_id": "speech_8f12c4a50957",
"reason": "no-receipt"
}
},
{
"play_label": "jiaojiao",
"reply": {
"kind": "turn_cancel",
"speech_id": "speech_4c73b7d1829c",
"reason": "interrupted"
}
}
],
"interrupt": {
"outcome": "interrupted",
"trigger": "audio",
"publish_ms": 1793115246711,
"cancel_ms": 1793115249971,
"cancel_latency_s": 3.26,
"last_agent_frame_ms": 1793115252008,
"audio_stop_latency_s": 5.3,
"frames_after_cancel": 204
},
"hangup": {
"delete_status": 200,
"delete_body": {
"ended": true,
"room": "cg-voice-qeghdnxehn"
},
"delete_ms": 1793115252011,
"agent_frames_after_delete": 2,
"room_removed": true
},
"call": {
"tag": "call1",
"session": {
"url": "wss://192.168.200.12:8363",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ2aWRlbyI6eyJyb29tSm9pbiI6dHJ1ZSwicm9vbSI6ImNnLXZvaWNlLXFlZ2hkbnhlaG4iLCJjYW5QdWJsaXNoIjp0cnVlLCJjYW5TdWJzY3JpYmUiOnRydWUsImNhblB1Ymxpc2hEYXRhIjp0cnVlfSwic3ViIjoidXNlci1nd2h2ZXpjeiIsImlzcyI6ImRldmtleSIsIm5iZiI6MTc5MzExNTIwMywiZXhwIjoxNzkzMTE1NTAzLCJuYW1lIjoi55So5oi3In0.D91S6SXWBtoVoSC6TEsULuK8WNHQu36O_vZQS1urwbw",
"room": "cg-voice-qeghdnxehn",
"session_id": "Ey8pBEFHbrO8i6Lbc0-47mh7",
"agent_name": "cybergril-voice-agent",
"epoch": 3,
"expires_at": 1793116103636
},
"subtitle_count": 96,
"seq_monotonic": true,
"agent_frames": 4766,
"agent_audio_seconds": 47.7,
"agent_audio_rate": 48000,
"agent_joined_ms": 1793115204287,
"agent_left_ms": null,
"subtitles_file": "/opt/cybergril-hel879/evidence/call1.subtitles.jsonl",
"agent_audio_file": "/opt/cybergril-hel879/evidence/call1.agent.wav"
},
"budget_after": {
"at_ms": 1793115254555,
"token_usage": [
{
"day": "2026-10-27",
"scene": "dialogue_main",
"used": 11416
}
],
"request_usage": [
{
"day": "2026-10-27",
"category": "dialogue",
"requests": 4
}
],
"budget_total": [
{
"key": "total_requests",
"value": 4
},
{
"key": "total_tokens",
"value": 11416
}
]
}
}
@@ -0,0 +1,96 @@
{"v": 2, "seq": 1, "kind": "interim", "role": "user", "text": "盯", "_at_ms": 1793115205011}
{"v": 2, "seq": 2, "kind": "interim", "role": "user", "text": "着你看怎", "_at_ms": 1793115205596}
{"v": 2, "seq": 3, "kind": "interim", "role": "user", "text": "么啦", "_at_ms": 1793115206196}
{"v": 2, "seq": 4, "kind": "user_final", "role": "user", "text": "盯着你看怎么啦", "_at_ms": 1793115207385}
{"v": 2, "seq": 5, "kind": "turn_start", "role": "assistant", "text": "", "speech_id": "speech_b687b8055307", "_at_ms": 1793115207410}
{"v": 2, "seq": 6, "kind": "interim", "role": "user", "text": "难得", "_at_ms": 1793115207554}
{"v": 2, "seq": 7, "kind": "interim", "role": "user", "text": "看你这么", "_at_ms": 1793115208153}
{"v": 2, "seq": 8, "kind": "interim", "role": "user", "text": "有意思的", "_at_ms": 1793115208743}
{"v": 2, "seq": 9, "kind": "interim", "role": "user", "text": "样子", "_at_ms": 1793115209349}
{"v": 2, "seq": 10, "kind": "assistant_interim", "role": "assistant", "text": "没", "speech_id": "speech_b687b8055307", "_at_ms": 1793115209395}
{"v": 2, "seq": 11, "kind": "assistant_interim", "role": "assistant", "text": "没怎么", "speech_id": "speech_b687b8055307", "_at_ms": 1793115209436}
{"v": 2, "seq": 12, "kind": "assistant_interim", "role": "assistant", "text": "没怎么啦", "speech_id": "speech_b687b8055307", "_at_ms": 1793115209439}
{"v": 2, "seq": 13, "kind": "assistant_interim", "role": "assistant", "text": "没怎么啦,", "speech_id": "speech_b687b8055307", "_at_ms": 1793115209478}
{"v": 2, "seq": 14, "kind": "assistant_interim", "role": "assistant", "text": "没怎么啦,工程师", "speech_id": "speech_b687b8055307", "_at_ms": 1793115209479}
{"v": 2, "seq": 15, "kind": "assistant_interim", "role": "assistant", "text": "没怎么啦,工程师。", "speech_id": "speech_b687b8055307", "_at_ms": 1793115209522}
{"v": 2, "seq": 16, "kind": "assistant_interim", "role": "assistant", "text": "没怎么啦,工程师。你", "speech_id": "speech_b687b8055307", "_at_ms": 1793115209563}
{"v": 2, "seq": 17, "kind": "assistant_interim", "role": "assistant", "text": "没怎么啦,工程师。你盯着", "speech_id": "speech_b687b8055307", "_at_ms": 1793115209565}
{"v": 2, "seq": 18, "kind": "assistant_interim", "role": "assistant", "text": "没怎么啦,工程师。你盯着看", "speech_id": "speech_b687b8055307", "_at_ms": 1793115209605}
{"v": 2, "seq": 19, "kind": "assistant_interim", "role": "assistant", "text": "没怎么啦,工程师。你盯着看,", "speech_id": "speech_b687b8055307", "_at_ms": 1793115209611}
{"v": 2, "seq": 20, "kind": "assistant_interim", "role": "assistant", "text": "没怎么啦,工程师。你盯着看,我就", "speech_id": "speech_b687b8055307", "_at_ms": 1793115209650}
{"v": 2, "seq": 21, "kind": "assistant_interim", "role": "assistant", "text": "没怎么啦,工程师。你盯着看,我就当", "speech_id": "speech_b687b8055307", "_at_ms": 1793115209688}
{"v": 2, "seq": 22, "kind": "assistant_interim", "role": "assistant", "text": "没怎么啦,工程师。你盯着看,我就当你在", "speech_id": "speech_b687b8055307", "_at_ms": 1793115209731}
{"v": 2, "seq": 23, "kind": "assistant_interim", "role": "assistant", "text": "没怎么啦,工程师。你盯着看,我就当你在检查", "speech_id": "speech_b687b8055307", "_at_ms": 1793115209773}
{"v": 2, "seq": 24, "kind": "assistant_interim", "role": "assistant", "text": "没怎么啦,工程师。你盯着看,我就当你在检查我", "speech_id": "speech_b687b8055307", "_at_ms": 1793115209816}
{"v": 2, "seq": 25, "kind": "assistant_interim", "role": "assistant", "text": "没怎么啦,工程师。你盯着看,我就当你在检查我有没有", "speech_id": "speech_b687b8055307", "_at_ms": 1793115209816}
{"v": 2, "seq": 26, "kind": "assistant_interim", "role": "assistant", "text": "没怎么啦,工程师。你盯着看,我就当你在检查我有没有乱", "speech_id": "speech_b687b8055307", "_at_ms": 1793115209857}
{"v": 2, "seq": 27, "kind": "assistant_interim", "role": "assistant", "text": "没怎么啦,工程师。你盯着看,我就当你在检查我有没有乱动", "speech_id": "speech_b687b8055307", "_at_ms": 1793115209900}
{"v": 2, "seq": 28, "kind": "assistant_interim", "role": "assistant", "text": "没怎么啦,工程师。你盯着看,我就当你在检查我有没有乱动。", "speech_id": "speech_b687b8055307", "_at_ms": 1793115209947}
{"v": 2, "seq": 29, "kind": "user_final", "role": "user", "text": "难得看你这么有意思的样子吗", "_at_ms": 1793115210503}
{"v": 2, "seq": 30, "kind": "turn_cancel", "role": "assistant", "text": "", "speech_id": "speech_b687b8055307", "reason": "interrupted", "_at_ms": 1793115210511}
{"v": 2, "seq": 31, "kind": "interim", "role": "user", "text": "烈会军校", "_at_ms": 1793115212136}
{"v": 2, "seq": 32, "kind": "interim", "role": "user", "text": "校啊招", "_at_ms": 1793115212733}
{"v": 2, "seq": 33, "kind": "interim", "role": "user", "text": "啊这种类", "_at_ms": 1793115213338}
{"v": 2, "seq": 34, "kind": "interim", "role": "user", "text": "型我都是", "_at_ms": 1793115213936}
{"v": 2, "seq": 35, "kind": "interim", "role": "user", "text": "那种比较", "_at_ms": 1793115214541}
{"v": 2, "seq": 36, "kind": "interim", "role": "user", "text": "呃豪", "_at_ms": 1793115215744}
{"v": 2, "seq": 37, "kind": "interim", "role": "user", "text": "爽比", "_at_ms": 1793115216340}
{"v": 2, "seq": 38, "kind": "interim", "role": "user", "text": "较开", "_at_ms": 1793115216943}
{"v": 2, "seq": 39, "kind": "interim", "role": "user", "text": "朗的那种", "_at_ms": 1793115217541}
{"v": 2, "seq": 40, "kind": "interim", "role": "user", "text": "类型", "_at_ms": 1793115218142}
{"v": 2, "seq": 41, "kind": "interim", "role": "user", "text": "阳光大", "_at_ms": 1793115218753}
{"v": 2, "seq": 42, "kind": "interim", "role": "user", "text": "女孩", "_at_ms": 1793115219344}
{"v": 2, "seq": 43, "kind": "interim", "role": "user", "text": "那种感", "_at_ms": 1793115220543}
{"v": 2, "seq": 44, "kind": "interim", "role": "user", "text": "觉", "_at_ms": 1793115221139}
{"v": 2, "seq": 45, "kind": "interim", "role": "user", "text": "都给", "_at_ms": 1793115221743}
{"v": 2, "seq": 46, "kind": "user_final", "role": "user", "text": "烈火军校啊,朝阳啊这种类型我都是那种比较呃豪爽,比较开朗的那种类型,阳光大女孩那种感觉就", "_at_ms": 1793115223193}
{"v": 2, "seq": 47, "kind": "turn_start", "role": "assistant", "text": "", "speech_id": "speech_8f12c4a50957", "_at_ms": 1793115226967}
{"v": 2, "seq": 48, "kind": "assistant_interim", "role": "assistant", "text": "原来", "speech_id": "speech_8f12c4a50957", "_at_ms": 1793115228249}
{"v": 2, "seq": 49, "kind": "assistant_interim", "role": "assistant", "text": "原来你是", "speech_id": "speech_8f12c4a50957", "_at_ms": 1793115228291}
{"v": 2, "seq": 50, "kind": "assistant_interim", "role": "assistant", "text": "原来你是那种", "speech_id": "speech_8f12c4a50957", "_at_ms": 1793115228333}
{"v": 2, "seq": 51, "kind": "assistant_interim", "role": "assistant", "text": "原来你是那种阳光", "speech_id": "speech_8f12c4a50957", "_at_ms": 1793115228335}
{"v": 2, "seq": 52, "kind": "assistant_interim", "role": "assistant", "text": "原来你是那种阳光大", "speech_id": "speech_8f12c4a50957", "_at_ms": 1793115228376}
{"v": 2, "seq": 53, "kind": "assistant_interim", "role": "assistant", "text": "原来你是那种阳光大女孩", "speech_id": "speech_8f12c4a50957", "_at_ms": 1793115228377}
{"v": 2, "seq": 54, "kind": "assistant_interim", "role": "assistant", "text": "原来你是那种阳光大女孩的类型", "speech_id": "speech_8f12c4a50957", "_at_ms": 1793115228420}
{"v": 2, "seq": 55, "kind": "assistant_interim", "role": "assistant", "text": "原来你是那种阳光大女孩的类型啊", "speech_id": "speech_8f12c4a50957", "_at_ms": 1793115228464}
{"v": 2, "seq": 56, "kind": "assistant_interim", "role": "assistant", "text": "原来你是那种阳光大女孩的类型啊,", "speech_id": "speech_8f12c4a50957", "_at_ms": 1793115228506}
{"v": 2, "seq": 57, "kind": "assistant_interim", "role": "assistant", "text": "原来你是那种阳光大女孩的类型啊,难怪", "speech_id": "speech_8f12c4a50957", "_at_ms": 1793115228508}
{"v": 2, "seq": 58, "kind": "assistant_interim", "role": "assistant", "text": "原来你是那种阳光大女孩的类型啊,难怪感觉", "speech_id": "speech_8f12c4a50957", "_at_ms": 1793115228548}
{"v": 2, "seq": 59, "kind": "assistant_interim", "role": "assistant", "text": "原来你是那种阳光大女孩的类型啊,难怪感觉你", "speech_id": "speech_8f12c4a50957", "_at_ms": 1793115228590}
{"v": 2, "seq": 60, "kind": "assistant_interim", "role": "assistant", "text": "原来你是那种阳光大女孩的类型啊,难怪感觉你说话", "speech_id": "speech_8f12c4a50957", "_at_ms": 1793115228634}
{"v": 2, "seq": 61, "kind": "assistant_interim", "role": "assistant", "text": "原来你是那种阳光大女孩的类型啊,难怪感觉你说话都", "speech_id": "speech_8f12c4a50957", "_at_ms": 1793115228636}
{"v": 2, "seq": 62, "kind": "assistant_interim", "role": "assistant", "text": "原来你是那种阳光大女孩的类型啊,难怪感觉你说话都透", "speech_id": "speech_8f12c4a50957", "_at_ms": 1793115228674}
{"v": 2, "seq": 63, "kind": "assistant_interim", "role": "assistant", "text": "原来你是那种阳光大女孩的类型啊,难怪感觉你说话都透着一股", "speech_id": "speech_8f12c4a50957", "_at_ms": 1793115228716}
{"v": 2, "seq": 64, "kind": "assistant_interim", "role": "assistant", "text": "原来你是那种阳光大女孩的类型啊,难怪感觉你说话都透着一股豪", "speech_id": "speech_8f12c4a50957", "_at_ms": 1793115228717}
{"v": 2, "seq": 65, "kind": "assistant_interim", "role": "assistant", "text": "原来你是那种阳光大女孩的类型啊,难怪感觉你说话都透着一股豪爽", "speech_id": "speech_8f12c4a50957", "_at_ms": 1793115228758}
{"v": 2, "seq": 66, "kind": "assistant_interim", "role": "assistant", "text": "原来你是那种阳光大女孩的类型啊,难怪感觉你说话都透着一股豪爽劲儿", "speech_id": "speech_8f12c4a50957", "_at_ms": 1793115228760}
{"v": 2, "seq": 67, "kind": "assistant_interim", "role": "assistant", "text": "原来你是那种阳光大女孩的类型啊,难怪感觉你说话都透着一股豪爽劲儿。", "speech_id": "speech_8f12c4a50957", "_at_ms": 1793115228801}
{"v": 2, "seq": 68, "kind": "turn_cancel", "role": "assistant", "text": "", "speech_id": "speech_8f12c4a50957", "reason": "no-receipt", "_at_ms": 1793115239147}
{"v": 2, "seq": 69, "kind": "interim", "role": "user", "text": "怎么还", "_at_ms": 1793115240503}
{"v": 2, "seq": 70, "kind": "interim", "role": "user", "text": "有没事", "_at_ms": 1793115241090}
{"v": 2, "seq": 71, "kind": "interim", "role": "user", "text": "的", "_at_ms": 1793115241700}
{"v": 2, "seq": 72, "kind": "user_final", "role": "user", "text": "怎么还有没事的", "_at_ms": 1793115242576}
{"v": 2, "seq": 73, "kind": "turn_start", "role": "assistant", "text": "", "speech_id": "speech_4c73b7d1829c", "_at_ms": 1793115242585}
{"v": 2, "seq": 74, "kind": "interim", "role": "user", "text": "是我不够", "_at_ms": 1793115243790}
{"v": 2, "seq": 75, "kind": "assistant_interim", "role": "assistant", "text": "哈哈", "speech_id": "speech_4c73b7d1829c", "_at_ms": 1793115243833}
{"v": 2, "seq": 76, "kind": "assistant_interim", "role": "assistant", "text": "哈哈,", "speech_id": "speech_4c73b7d1829c", "_at_ms": 1793115243874}
{"v": 2, "seq": 77, "kind": "assistant_interim", "role": "assistant", "text": "哈哈,因为", "speech_id": "speech_4c73b7d1829c", "_at_ms": 1793115243875}
{"v": 2, "seq": 78, "kind": "assistant_interim", "role": "assistant", "text": "哈哈,因为我在", "speech_id": "speech_4c73b7d1829c", "_at_ms": 1793115243916}
{"v": 2, "seq": 79, "kind": "assistant_interim", "role": "assistant", "text": "哈哈,因为我在认真", "speech_id": "speech_4c73b7d1829c", "_at_ms": 1793115243917}
{"v": 2, "seq": 80, "kind": "assistant_interim", "role": "assistant", "text": "哈哈,因为我在认真听", "speech_id": "speech_4c73b7d1829c", "_at_ms": 1793115243958}
{"v": 2, "seq": 81, "kind": "assistant_interim", "role": "assistant", "text": "哈哈,因为我在认真听你", "speech_id": "speech_4c73b7d1829c", "_at_ms": 1793115244002}
{"v": 2, "seq": 82, "kind": "assistant_interim", "role": "assistant", "text": "哈哈,因为我在认真听你说话", "speech_id": "speech_4c73b7d1829c", "_at_ms": 1793115244044}
{"v": 2, "seq": 83, "kind": "assistant_interim", "role": "assistant", "text": "哈哈,因为我在认真听你说话呀", "speech_id": "speech_4c73b7d1829c", "_at_ms": 1793115244045}
{"v": 2, "seq": 84, "kind": "assistant_interim", "role": "assistant", "text": "哈哈,因为我在认真听你说话呀。", "speech_id": "speech_4c73b7d1829c", "_at_ms": 1793115244086}
{"v": 2, "seq": 85, "kind": "interim", "role": "user", "text": "努力", "_at_ms": 1793115244386}
{"v": 2, "seq": 86, "kind": "user_final", "role": "user", "text": "是我不够努力吗", "_at_ms": 1793115245511}
{"v": 2, "seq": 87, "kind": "turn_cancel", "role": "assistant", "text": "", "speech_id": "speech_4c73b7d1829c", "reason": "interrupted", "_at_ms": 1793115245516}
{"v": 2, "seq": 88, "kind": "interim", "role": "user", "text": "盯着你看", "_at_ms": 1793115248139}
{"v": 2, "seq": 89, "kind": "interim", "role": "user", "text": "怎么", "_at_ms": 1793115248742}
{"v": 2, "seq": 90, "kind": "turn_start", "role": "assistant", "text": "", "speech_id": "speech_7a9c0f804ca0", "_at_ms": 1793115249065}
{"v": 2, "seq": 91, "kind": "user_final", "role": "user", "text": "盯着你看怎么啦", "_at_ms": 1793115249963}
{"v": 2, "seq": 92, "kind": "turn_cancel", "role": "assistant", "text": "", "speech_id": "speech_7a9c0f804ca0", "reason": "interrupted", "_at_ms": 1793115249971}
{"v": 2, "seq": 93, "kind": "interim", "role": "user", "text": "难得", "_at_ms": 1793115250198}
{"v": 2, "seq": 94, "kind": "interim", "role": "user", "text": "看你这么", "_at_ms": 1793115250808}
{"v": 2, "seq": 95, "kind": "interim", "role": "user", "text": "有意思的", "_at_ms": 1793115251389}
{"v": 2, "seq": 96, "kind": "interim", "role": "user", "text": "样子", "_at_ms": 1793115251989}
+97
View File
@@ -0,0 +1,97 @@
{
"rounds": [
{
"play_label": "haimianbaobao",
"reply": {
"kind": "turn_end",
"speech_id": "speech_e69e351f1c44",
"reason": null
}
},
{
"play_label": "nezha",
"reply": {
"kind": "turn_end",
"speech_id": "speech_ff61928cdce0",
"reason": null
}
},
{
"play_label": "rain",
"reply": {
"kind": "turn_end",
"speech_id": "speech_2f9dc8b9848d",
"reason": null
}
}
],
"interrupt": {
"outcome": "no-cancel",
"trigger": "audio",
"publish_ms": 1793115490292,
"cancel_ms": null,
"cancel_latency_s": null,
"old_audio_stop_ms": 1793115512315,
"old_audio_stop_latency_s": 22.02,
"old_frames_during_interrupt": 2203,
"frames_after_cancel_plus1s5": 0
},
"hangup": {
"delete_status": 200,
"delete_body": {
"ended": true,
"room": "cg-voice-5c4y64wxkl"
},
"delete_ms": 1793115512327,
"agent_frames_after_delete": 2,
"room_removed": true
},
"call": {
"tag": "call1",
"session": {
"url": "wss://192.168.200.12:8363",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ2aWRlbyI6eyJyb29tSm9pbiI6dHJ1ZSwicm9vbSI6ImNnLXZvaWNlLTVjNHk2NHd4a2wiLCJjYW5QdWJsaXNoIjp0cnVlLCJjYW5TdWJzY3JpYmUiOnRydWUsImNhblB1Ymxpc2hEYXRhIjp0cnVlfSwic3ViIjoidXNlci12ZHl3c2h2ZSIsImlzcyI6ImRldmtleSIsIm5iZiI6MTc5MzExNTQzMSwiZXhwIjoxNzkzMTE1NzMxLCJuYW1lIjoi55So5oi3In0.PjOElCid-0VLPw8w0iQml4ca9QkLqoCwu9EJf2pz4DE",
"room": "cg-voice-5c4y64wxkl",
"session_id": "jCiBIdbaj2_2_AjxrwChbQ4A",
"agent_name": "cybergril-voice-agent",
"epoch": 4,
"expires_at": 1793116331245
},
"subtitle_count": 98,
"seq_monotonic": true,
"agent_frames": 8042,
"agent_audio_seconds": 80.4,
"agent_audio_rate": 48000,
"agent_joined_ms": 1793115431859,
"agent_left_ms": null,
"subtitles_file": "/opt/cybergril-hel879/evidence/call1b.subtitles.jsonl",
"agent_audio_file": "/opt/cybergril-hel879/evidence/call1b.agent.wav"
},
"budget_after": {
"at_ms": 1793115514868,
"token_usage": [
{
"day": "2026-10-27",
"scene": "dialogue_main",
"used": 23504
}
],
"request_usage": [
{
"day": "2026-10-27",
"category": "dialogue",
"requests": 8
}
],
"budget_total": [
{
"key": "total_requests",
"value": 8
},
{
"key": "total_tokens",
"value": 23504
}
]
}
}
@@ -0,0 +1,98 @@
{"v": 2, "seq": 1, "kind": "interim", "role": "user", "text": "没", "_at_ms": 1793115432559}
{"v": 2, "seq": 2, "kind": "interim", "role": "user", "text": "错", "_at_ms": 1793115433170}
{"v": 2, "seq": 3, "kind": "interim", "role": "user", "text": "我就是个", "_at_ms": 1793115433760}
{"v": 2, "seq": 4, "kind": "interim", "role": "user", "text": "笨蛋", "_at_ms": 1793115434360}
{"v": 2, "seq": 5, "kind": "interim", "role": "user", "text": "派大星", "_at_ms": 1793115434960}
{"v": 2, "seq": 6, "kind": "interim", "role": "user", "text": "我们", "_at_ms": 1793115435559}
{"v": 2, "seq": 7, "kind": "interim", "role": "user", "text": "一起去", "_at_ms": 1793115436165}
{"v": 2, "seq": 8, "kind": "interim", "role": "user", "text": "抓水母", "_at_ms": 1793115436768}
{"v": 2, "seq": 9, "kind": "interim", "role": "user", "text": "吧我", "_at_ms": 1793115437365}
{"v": 2, "seq": 10, "kind": "interim", "role": "user", "text": "们一起度", "_at_ms": 1793115437963}
{"v": 2, "seq": 11, "kind": "interim", "role": "user", "text": "过快乐的", "_at_ms": 1793115438565}
{"v": 2, "seq": 12, "kind": "interim", "role": "user", "text": "一天", "_at_ms": 1793115439163}
{"v": 2, "seq": 13, "kind": "user_final", "role": "user", "text": "没错,我就是个笨蛋派大星,我们一起去抓水母吧,我们一起度过快乐的一天", "_at_ms": 1793115440793}
{"v": 2, "seq": 14, "kind": "turn_start", "role": "assistant", "text": "", "speech_id": "speech_e69e351f1c44", "_at_ms": 1793115440831}
{"v": 2, "seq": 15, "kind": "assistant_interim", "role": "assistant", "text": "好", "speech_id": "speech_e69e351f1c44", "_at_ms": 1793115442938}
{"v": 2, "seq": 16, "kind": "assistant_interim", "role": "assistant", "text": "好呀", "speech_id": "speech_e69e351f1c44", "_at_ms": 1793115442940}
{"v": 2, "seq": 17, "kind": "assistant_interim", "role": "assistant", "text": "好呀,", "speech_id": "speech_e69e351f1c44", "_at_ms": 1793115442981}
{"v": 2, "seq": 18, "kind": "assistant_interim", "role": "assistant", "text": "好呀,那我们", "speech_id": "speech_e69e351f1c44", "_at_ms": 1793115442984}
{"v": 2, "seq": 19, "kind": "assistant_interim", "role": "assistant", "text": "好呀,那我们这就", "speech_id": "speech_e69e351f1c44", "_at_ms": 1793115443024}
{"v": 2, "seq": 20, "kind": "assistant_interim", "role": "assistant", "text": "好呀,那我们这就出发", "speech_id": "speech_e69e351f1c44", "_at_ms": 1793115443067}
{"v": 2, "seq": 21, "kind": "assistant_interim", "role": "assistant", "text": "好呀,那我们这就出发去", "speech_id": "speech_e69e351f1c44", "_at_ms": 1793115443068}
{"v": 2, "seq": 22, "kind": "assistant_interim", "role": "assistant", "text": "好呀,那我们这就出发去抓", "speech_id": "speech_e69e351f1c44", "_at_ms": 1793115443109}
{"v": 2, "seq": 23, "kind": "assistant_interim", "role": "assistant", "text": "好呀,那我们这就出发去抓水", "speech_id": "speech_e69e351f1c44", "_at_ms": 1793115443110}
{"v": 2, "seq": 24, "kind": "assistant_interim", "role": "assistant", "text": "好呀,那我们这就出发去抓水母", "speech_id": "speech_e69e351f1c44", "_at_ms": 1793115443152}
{"v": 2, "seq": 25, "kind": "assistant_interim", "role": "assistant", "text": "好呀,那我们这就出发去抓水母!", "speech_id": "speech_e69e351f1c44", "_at_ms": 1793115443154}
{"v": 2, "seq": 26, "kind": "assistant_interim", "role": "assistant", "text": "好呀,那我们这就出发去抓水母!不过", "speech_id": "speech_e69e351f1c44", "_at_ms": 1793115443195}
{"v": 2, "seq": 27, "kind": "assistant_interim", "role": "assistant", "text": "好呀,那我们这就出发去抓水母!不过我可", "speech_id": "speech_e69e351f1c44", "_at_ms": 1793115443237}
{"v": 2, "seq": 28, "kind": "assistant_interim", "role": "assistant", "text": "好呀,那我们这就出发去抓水母!不过我可不想", "speech_id": "speech_e69e351f1c44", "_at_ms": 1793115443238}
{"v": 2, "seq": 29, "kind": "assistant_interim", "role": "assistant", "text": "好呀,那我们这就出发去抓水母!不过我可不想住", "speech_id": "speech_e69e351f1c44", "_at_ms": 1793115443279}
{"v": 2, "seq": 30, "kind": "assistant_interim", "role": "assistant", "text": "好呀,那我们这就出发去抓水母!不过我可不想住进", "speech_id": "speech_e69e351f1c44", "_at_ms": 1793115443321}
{"v": 2, "seq": 31, "kind": "assistant_interim", "role": "assistant", "text": "好呀,那我们这就出发去抓水母!不过我可不想住进那个", "speech_id": "speech_e69e351f1c44", "_at_ms": 1793115443322}
{"v": 2, "seq": 32, "kind": "assistant_interim", "role": "assistant", "text": "好呀,那我们这就出发去抓水母!不过我可不想住进那个菠萝", "speech_id": "speech_e69e351f1c44", "_at_ms": 1793115443363}
{"v": 2, "seq": 33, "kind": "assistant_interim", "role": "assistant", "text": "好呀,那我们这就出发去抓水母!不过我可不想住进那个菠萝屋", "speech_id": "speech_e69e351f1c44", "_at_ms": 1793115443406}
{"v": 2, "seq": 34, "kind": "assistant_interim", "role": "assistant", "text": "好呀,那我们这就出发去抓水母!不过我可不想住进那个菠萝屋,", "speech_id": "speech_e69e351f1c44", "_at_ms": 1793115443449}
{"v": 2, "seq": 35, "kind": "assistant_interim", "role": "assistant", "text": "好呀,那我们这就出发去抓水母!不过我可不想住进那个菠萝屋,我只", "speech_id": "speech_e69e351f1c44", "_at_ms": 1793115443450}
{"v": 2, "seq": 36, "kind": "assistant_interim", "role": "assistant", "text": "好呀,那我们这就出发去抓水母!不过我可不想住进那个菠萝屋,我只喜欢", "speech_id": "speech_e69e351f1c44", "_at_ms": 1793115443491}
{"v": 2, "seq": 37, "kind": "assistant_interim", "role": "assistant", "text": "好呀,那我们这就出发去抓水母!不过我可不想住进那个菠萝屋,我只喜欢在海", "speech_id": "speech_e69e351f1c44", "_at_ms": 1793115443534}
{"v": 2, "seq": 38, "kind": "assistant_interim", "role": "assistant", "text": "好呀,那我们这就出发去抓水母!不过我可不想住进那个菠萝屋,我只喜欢在海边", "speech_id": "speech_e69e351f1c44", "_at_ms": 1793115443577}
{"v": 2, "seq": 39, "kind": "assistant_interim", "role": "assistant", "text": "好呀,那我们这就出发去抓水母!不过我可不想住进那个菠萝屋,我只喜欢在海边晒太阳", "speech_id": "speech_e69e351f1c44", "_at_ms": 1793115443578}
{"v": 2, "seq": 40, "kind": "assistant_interim", "role": "assistant", "text": "好呀,那我们这就出发去抓水母!不过我可不想住进那个菠萝屋,我只喜欢在海边晒太阳。", "speech_id": "speech_e69e351f1c44", "_at_ms": 1793115443620}
{"v": 2, "seq": 41, "kind": "assistant_final", "role": "assistant", "text": "好呀,那我们这就出发去抓水母!不过我可不想住进那个菠萝屋,我只喜欢在海边晒太阳。", "speech_id": "speech_e69e351f1c44", "turn_id": "b72de17e-bf42-470e-b396-c219bd7593f9", "_at_ms": 1793115457080}
{"v": 2, "seq": 42, "kind": "turn_end", "role": "assistant", "text": "", "speech_id": "speech_e69e351f1c44", "turn_id": "b72de17e-bf42-470e-b396-c219bd7593f9", "_at_ms": 1793115457081}
{"v": 2, "seq": 43, "kind": "interim", "role": "user", "text": "天雷滚", "_at_ms": 1793115458260}
{"v": 2, "seq": 44, "kind": "interim", "role": "user", "text": "滚我好", "_at_ms": 1793115458865}
{"v": 2, "seq": 45, "kind": "interim", "role": "user", "text": "怕怕", "_at_ms": 1793115459463}
{"v": 2, "seq": 46, "kind": "interim", "role": "user", "text": "劈", "_at_ms": 1793115460064}
{"v": 2, "seq": 47, "kind": "interim", "role": "user", "text": "的我浑", "_at_ms": 1793115460657}
{"v": 2, "seq": 48, "kind": "interim", "role": "user", "text": "身掉渣", "_at_ms": 1793115461256}
{"v": 2, "seq": 49, "kind": "interim", "role": "user", "text": "渣突", "_at_ms": 1793115461862}
{"v": 2, "seq": 50, "kind": "interim", "role": "user", "text": "破天", "_at_ms": 1793115462458}
{"v": 2, "seq": 51, "kind": "interim", "role": "user", "text": "劫我笑", "_at_ms": 1793115463067}
{"v": 2, "seq": 52, "kind": "interim", "role": "user", "text": "哈哈", "_at_ms": 1793115463659}
{"v": 2, "seq": 53, "kind": "interim", "role": "user", "text": "逆", "_at_ms": 1793115464263}
{"v": 2, "seq": 54, "kind": "interim", "role": "user", "text": "天改命", "_at_ms": 1793115464867}
{"v": 2, "seq": 55, "kind": "interim", "role": "user", "text": "我吹喇", "_at_ms": 1793115465459}
{"v": 2, "seq": 56, "kind": "interim", "role": "user", "text": "叭滴", "_at_ms": 1793115466062}
{"v": 2, "seq": 57, "kind": "interim", "role": "user", "text": "答滴答", "_at_ms": 1793115466663}
{"v": 2, "seq": 58, "kind": "interim", "role": "user", "text": "滴滴", "_at_ms": 1793115467270}
{"v": 2, "seq": 59, "kind": "user_final", "role": "user", "text": "天雷滚滚,我好怕怕劈的我浑身掉渣渣,突破天劫,我笑哈哈逆天改命,我吹喇叭滴答滴答,滴滴答", "_at_ms": 1793115468935}
{"v": 2, "seq": 60, "kind": "turn_start", "role": "assistant", "text": "", "speech_id": "speech_ff61928cdce0", "_at_ms": 1793115468946}
{"v": 2, "seq": 61, "kind": "assistant_interim", "role": "assistant", "text": "哈哈", "speech_id": "speech_ff61928cdce0", "_at_ms": 1793115470187}
{"v": 2, "seq": 62, "kind": "assistant_interim", "role": "assistant", "text": "哈哈,", "speech_id": "speech_ff61928cdce0", "_at_ms": 1793115470230}
{"v": 2, "seq": 63, "kind": "assistant_interim", "role": "assistant", "text": "哈哈,你", "speech_id": "speech_ff61928cdce0", "_at_ms": 1793115470231}
{"v": 2, "seq": 64, "kind": "assistant_interim", "role": "assistant", "text": "哈哈,你唱", "speech_id": "speech_ff61928cdce0", "_at_ms": 1793115470273}
{"v": 2, "seq": 65, "kind": "assistant_interim", "role": "assistant", "text": "哈哈,你唱得", "speech_id": "speech_ff61928cdce0", "_at_ms": 1793115470315}
{"v": 2, "seq": 66, "kind": "assistant_interim", "role": "assistant", "text": "哈哈,你唱得还挺", "speech_id": "speech_ff61928cdce0", "_at_ms": 1793115470316}
{"v": 2, "seq": 67, "kind": "assistant_interim", "role": "assistant", "text": "哈哈,你唱得还挺有", "speech_id": "speech_ff61928cdce0", "_at_ms": 1793115470357}
{"v": 2, "seq": 68, "kind": "assistant_interim", "role": "assistant", "text": "哈哈,你唱得还挺有节奏", "speech_id": "speech_ff61928cdce0", "_at_ms": 1793115470359}
{"v": 2, "seq": 69, "kind": "assistant_interim", "role": "assistant", "text": "哈哈,你唱得还挺有节奏感的", "speech_id": "speech_ff61928cdce0", "_at_ms": 1793115470400}
{"v": 2, "seq": 70, "kind": "assistant_interim", "role": "assistant", "text": "哈哈,你唱得还挺有节奏感的!", "speech_id": "speech_ff61928cdce0", "_at_ms": 1793115470443}
{"v": 2, "seq": 71, "kind": "assistant_final", "role": "assistant", "text": "哈哈,你唱得还挺有节奏感的!", "speech_id": "speech_ff61928cdce0", "turn_id": "baa6c5c7-b483-49d4-905c-d6b7e5dd7786", "_at_ms": 1793115476107}
{"v": 2, "seq": 72, "kind": "turn_end", "role": "assistant", "text": "", "speech_id": "speech_ff61928cdce0", "turn_id": "baa6c5c7-b483-49d4-905c-d6b7e5dd7786", "_at_ms": 1793115476109}
{"v": 2, "seq": 73, "kind": "interim", "role": "user", "text": "你是不是", "_at_ms": 1793115477463}
{"v": 2, "seq": 74, "kind": "interim", "role": "user", "text": "早就想", "_at_ms": 1793115478068}
{"v": 2, "seq": 75, "kind": "interim", "role": "user", "text": "睡我", "_at_ms": 1793115478661}
{"v": 2, "seq": 76, "kind": "interim", "role": "user", "text": "了", "_at_ms": 1793115479267}
{"v": 2, "seq": 77, "kind": "user_final", "role": "user", "text": "你是不是早就想睡我了呀", "_at_ms": 1793115480206}
{"v": 2, "seq": 78, "kind": "turn_start", "role": "assistant", "text": "", "speech_id": "speech_2f9dc8b9848d", "_at_ms": 1793115480215}
{"v": 2, "seq": 79, "kind": "assistant_interim", "role": "assistant", "text": "哈哈", "speech_id": "speech_2f9dc8b9848d", "_at_ms": 1793115481774}
{"v": 2, "seq": 80, "kind": "assistant_interim", "role": "assistant", "text": "哈哈,", "speech_id": "speech_2f9dc8b9848d", "_at_ms": 1793115481776}
{"v": 2, "seq": 81, "kind": "assistant_interim", "role": "assistant", "text": "哈哈,你", "speech_id": "speech_2f9dc8b9848d", "_at_ms": 1793115481816}
{"v": 2, "seq": 82, "kind": "assistant_interim", "role": "assistant", "text": "哈哈,你思维", "speech_id": "speech_2f9dc8b9848d", "_at_ms": 1793115481816}
{"v": 2, "seq": 83, "kind": "assistant_interim", "role": "assistant", "text": "哈哈,你思维跳跃", "speech_id": "speech_2f9dc8b9848d", "_at_ms": 1793115481857}
{"v": 2, "seq": 84, "kind": "assistant_interim", "role": "assistant", "text": "哈哈,你思维跳跃得太", "speech_id": "speech_2f9dc8b9848d", "_at_ms": 1793115481859}
{"v": 2, "seq": 85, "kind": "assistant_interim", "role": "assistant", "text": "哈哈,你思维跳跃得太快了", "speech_id": "speech_2f9dc8b9848d", "_at_ms": 1793115481903}
{"v": 2, "seq": 86, "kind": "assistant_interim", "role": "assistant", "text": "哈哈,你思维跳跃得太快了,", "speech_id": "speech_2f9dc8b9848d", "_at_ms": 1793115481906}
{"v": 2, "seq": 87, "kind": "assistant_interim", "role": "assistant", "text": "哈哈,你思维跳跃得太快了,我都", "speech_id": "speech_2f9dc8b9848d", "_at_ms": 1793115481945}
{"v": 2, "seq": 88, "kind": "assistant_interim", "role": "assistant", "text": "哈哈,你思维跳跃得太快了,我都跟不上", "speech_id": "speech_2f9dc8b9848d", "_at_ms": 1793115481987}
{"v": 2, "seq": 89, "kind": "assistant_interim", "role": "assistant", "text": "哈哈,你思维跳跃得太快了,我都跟不上。", "speech_id": "speech_2f9dc8b9848d", "_at_ms": 1793115481988}
{"v": 2, "seq": 90, "kind": "assistant_final", "role": "assistant", "text": "哈哈,你思维跳跃得太快了,我都跟不上。", "speech_id": "speech_2f9dc8b9848d", "turn_id": "a91bbbb6-45b0-4671-84a2-027cc194f420", "_at_ms": 1793115489067}
{"v": 2, "seq": 91, "kind": "turn_end", "role": "assistant", "text": "", "speech_id": "speech_2f9dc8b9848d", "turn_id": "a91bbbb6-45b0-4671-84a2-027cc194f420", "_at_ms": 1793115489068}
{"v": 2, "seq": 92, "kind": "interim", "role": "user", "text": "盯着你看", "_at_ms": 1793115491717}
{"v": 2, "seq": 93, "kind": "interim", "role": "user", "text": "怎么啦", "_at_ms": 1793115492309}
{"v": 2, "seq": 94, "kind": "user_final", "role": "user", "text": "盯着你看怎么啦", "_at_ms": 1793115493388}
{"v": 2, "seq": 95, "kind": "turn_start", "role": "assistant", "text": "", "speech_id": "speech_0d075d37bc31", "_at_ms": 1793115493398}
{"v": 2, "seq": 96, "kind": "assistant_interim", "role": "assistant", "text": "没", "speech_id": "speech_0d075d37bc31", "_at_ms": 1793115494608}
{"v": 2, "seq": 97, "kind": "assistant_final", "role": "assistant", "text": "没", "speech_id": "speech_0d075d37bc31", "turn_id": "5bd95c66-b42b-4571-bfc2-fbf376c90747", "_at_ms": 1793115501161}
{"v": 2, "seq": 98, "kind": "turn_end", "role": "assistant", "text": "", "speech_id": "speech_0d075d37bc31", "turn_id": "5bd95c66-b42b-4571-bfc2-fbf376c90747", "_at_ms": 1793115501163}
+303
View File
@@ -0,0 +1,303 @@
{
"barges": [
{
"label": "bajie",
"outcome": "reply-too-short-to-barge"
},
{
"label": "sunwukong",
"outcome": "reply-too-short-to-barge"
},
{
"label": "haimianbaobao",
"outcome": "reply-too-short-to-barge"
}
],
"text_turn": {
"turn_id": "cb1bbe27-795f-45e3-b7bf-f2d6371c0830",
"status": 200,
"deltas": 0,
"final": null,
"ms": 114
},
"voice_reply_after_text": {
"kind": "turn_end",
"speech_id": "speech_9aa32dc387c3",
"reason": null
},
"voice_cancelled_by_text": false,
"drop": {
"dropped_ms": 1793116007019,
"room": "cg-voice-oleganioz7",
"session_id": "Si6UZJbAENsNExR-B1cfpIEX",
"agent_frames_after_drop": 1
},
"call": {
"tag": "call3",
"session": {
"url": "wss://192.168.200.12:8363",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ2aWRlbyI6eyJyb29tSm9pbiI6dHJ1ZSwicm9vbSI6ImNnLXZvaWNlLW9sZWdhbmlvejciLCJjYW5QdWJsaXNoIjp0cnVlLCJjYW5TdWJzY3JpYmUiOnRydWUsImNhblB1Ymxpc2hEYXRhIjp0cnVlfSwic3ViIjoidXNlci11cWZ5dWhudCIsImlzcyI6ImRldmtleSIsIm5iZiI6MTc5MzExNTY5MCwiZXhwIjoxNzkzMTE1OTkwLCJuYW1lIjoi55So5oi3In0.wXERZSpOZVIidVRq-8dkIbb7e2gPa0HUX8_ZV-kHTN4",
"room": "cg-voice-oleganioz7",
"session_id": "Si6UZJbAENsNExR-B1cfpIEX",
"agent_name": "cybergril-voice-agent",
"epoch": 4,
"expires_at": 1793116590248
},
"subtitle_count": 104,
"seq_monotonic": true,
"agent_frames": 31510,
"agent_audio_seconds": 315.1,
"agent_audio_rate": 48000,
"agent_joined_ms": 1793115690867,
"agent_left_ms": null,
"subtitles_file": "/opt/cybergril-hel879/evidence/call3.subtitles.jsonl",
"agent_audio_file": "/opt/cybergril-hel879/evidence/call3.agent.wav"
},
"messages": [
{
"id": 1,
"role": "user",
"t": "盯着你看怎么啦",
"status": "sent",
"turn_id": "61605d50-17f0-4d9e-b407-60574304a827"
},
{
"id": 2,
"role": "companion",
"t": "没怎么啦,工程师。你盯着看,我就当你在检查我有没有乱动。",
"status": "sent",
"turn_id": "61605d50-17f0-4d9e-b407-60574304a827"
},
{
"id": 3,
"role": "user",
"t": "难得看你这么有意思的样子吗 烈火军校啊,朝阳啊这种类型我都是那种比较呃豪爽,比较开朗的那种类型,阳光大女孩那种感觉就",
"status": "sent",
"turn_id": "eb182bcd-3441-42d8-ba60-9eced3a6a5a5"
},
{
"id": 4,
"role": "companion",
"t": "原来你是那种阳光大女孩的类型啊,难怪感觉你说话都透着一股豪爽劲儿。",
"status": "sent",
"turn_id": "eb182bcd-3441-42d8-ba60-9eced3a6a5a5"
},
{
"id": 5,
"role": "user",
"t": "怎么还有没事的",
"status": "sent",
"turn_id": "0e1a5226-4ca6-44bb-a343-39647f0112e3"
},
{
"id": 6,
"role": "companion",
"t": "哈哈,因为我在认真听你说话呀。",
"status": "sent",
"turn_id": "0e1a5226-4ca6-44bb-a343-39647f0112e3"
},
{
"id": 7,
"role": "user",
"t": "是我不够努力吗",
"status": "cancelled",
"turn_id": "47d8d0ce-7ac7-4202-9757-64f5f4fb1300"
},
{
"id": 8,
"role": "user",
"t": "没错,我就是个笨蛋派大星,我们一起去抓水母吧,我们一起度过快乐的一天",
"status": "sent",
"turn_id": "b72de17e-bf42-470e-b396-c219bd7593f9"
},
{
"id": 9,
"role": "companion",
"t": "好呀,那我们这就出发去抓水母!不过我可不想住进那个菠萝屋,我只喜欢在海边晒太阳。",
"status": "sent",
"turn_id": "b72de17e-bf42-470e-b396-c219bd7593f9"
},
{
"id": 10,
"role": "user",
"t": "天雷滚滚,我好怕怕劈的我浑身掉渣渣,突破天劫,我笑哈哈逆天改命,我吹喇叭滴答滴答,滴滴答",
"status": "sent",
"turn_id": "baa6c5c7-b483-49d4-905c-d6b7e5dd7786"
},
{
"id": 11,
"role": "companion",
"t": "哈哈,你唱得还挺有节奏感的!",
"status": "sent",
"turn_id": "baa6c5c7-b483-49d4-905c-d6b7e5dd7786"
},
{
"id": 12,
"role": "user",
"t": "你是不是早就想睡我了呀",
"status": "sent",
"turn_id": "a91bbbb6-45b0-4671-84a2-027cc194f420"
},
{
"id": 13,
"role": "companion",
"t": "哈哈,你思维跳跃得太快了,我都跟不上。",
"status": "sent",
"turn_id": "a91bbbb6-45b0-4671-84a2-027cc194f420"
},
{
"id": 14,
"role": "user",
"t": "盯着你看怎么啦",
"status": "sent",
"turn_id": "5bd95c66-b42b-4571-bfc2-fbf376c90747"
},
{
"id": 15,
"role": "companion",
"t": "没",
"status": "sent",
"turn_id": "5bd95c66-b42b-4571-bfc2-fbf376c90747"
},
{
"id": 16,
"role": "user",
"t": "师傅,哎,这取经路比高,老庄种地还苦,俺老猪饿的能吞下石龙素包子,猴哥,你筋斗云块,要不先去化个缘",
"status": "sent",
"turn_id": "9bba6412-6679-48c2-9f1c-4bfc07f58aea"
},
{
"id": 17,
"role": "companion",
"t": "师傅,",
"status": "sent",
"turn_id": "9bba6412-6679-48c2-9f1c-4bfc07f58aea"
},
{
"id": 18,
"role": "user",
"t": "俺老孙来也,俺乃500年前大闹天宫的齐天大圣孙悟空,这里山势险峻,只怕藏有妖精妖怪,哪里逃吃?俺老孙吧",
"status": "sent",
"turn_id": "16006868-5ee7-49cc-b9c9-1a8dcfe74d08"
},
{
"id": 19,
"role": "companion",
"t": "好",
"status": "sent",
"turn_id": "16006868-5ee7-49cc-b9c9-1a8dcfe74d08"
},
{
"id": 20,
"role": "user",
"t": "没错,我就是个笨蛋派大星,我们一起去抓水母吧,我们一起度过快乐的一天",
"status": "sent",
"turn_id": "fdf28a03-19a8-4fcb-ba8c-24587736c3f0"
},
{
"id": 21,
"role": "companion",
"t": "好",
"status": "sent",
"turn_id": "fdf28a03-19a8-4fcb-ba8c-24587736c3f0"
},
{
"id": 22,
"role": "user",
"t": "你是不是早就想睡我了呀",
"status": "sent",
"turn_id": "623bab59-0edc-4751-9feb-a9a7e4a11233"
},
{
"id": 23,
"role": "user",
"t": "(文字通道)我改用打字了,这条应该立刻打断你正在说的话。",
"status": "cancelled",
"turn_id": "cb1bbe27-795f-45e3-b7bf-f2d6371c0830"
},
{
"id": 25,
"role": "companion",
"t": "Considering the limited time by the user, I have to give the",
"status": "sent",
"turn_id": "623bab59-0edc-4751-9feb-a9a7e4a11233"
}
],
"receipts": [
{
"message_id": 9,
"turn_id": "b72de17e-bf42-470e-b396-c219bd7593f9",
"ack_at": 1793115457081,
"state": "judged"
},
{
"message_id": 11,
"turn_id": "baa6c5c7-b483-49d4-905c-d6b7e5dd7786",
"ack_at": 1793115476109,
"state": "judged"
},
{
"message_id": 13,
"turn_id": "a91bbbb6-45b0-4671-84a2-027cc194f420",
"ack_at": 1793115489069,
"state": "judged"
},
{
"message_id": 15,
"turn_id": "5bd95c66-b42b-4571-bfc2-fbf376c90747",
"ack_at": 1793115501163,
"state": "judged"
},
{
"message_id": 17,
"turn_id": "9bba6412-6679-48c2-9f1c-4bfc07f58aea",
"ack_at": 1793115715591,
"state": "judged"
},
{
"message_id": 19,
"turn_id": "16006868-5ee7-49cc-b9c9-1a8dcfe74d08",
"ack_at": 1793115816209,
"state": "judged"
},
{
"message_id": 21,
"turn_id": "fdf28a03-19a8-4fcb-ba8c-24587736c3f0",
"ack_at": 1793115908179,
"state": "judged"
},
{
"message_id": 25,
"turn_id": "623bab59-0edc-4751-9feb-a9a7e4a11233",
"ack_at": 1793116005932,
"state": "pending"
}
],
"budget": {
"at_ms": 1793116007051,
"token_usage": [
{
"day": "2026-10-27",
"scene": "dialogue_main",
"used": 42717
}
],
"request_usage": [
{
"day": "2026-10-27",
"category": "dialogue",
"requests": 14
}
],
"budget_total": [
{
"key": "total_requests",
"value": 14
},
{
"key": "total_tokens",
"value": 42717
}
]
}
}
@@ -0,0 +1,104 @@
{"v": 2, "seq": 1, "kind": "interim", "role": "user", "text": "师", "_at_ms": 1793115691554}
{"v": 2, "seq": 2, "kind": "interim", "role": "user", "text": "傅", "_at_ms": 1793115692163}
{"v": 2, "seq": 3, "kind": "interim", "role": "user", "text": "哎这", "_at_ms": 1793115692756}
{"v": 2, "seq": 4, "kind": "interim", "role": "user", "text": "取经", "_at_ms": 1793115693359}
{"v": 2, "seq": 5, "kind": "interim", "role": "user", "text": "路比", "_at_ms": 1793115693964}
{"v": 2, "seq": 6, "kind": "interim", "role": "user", "text": "高老", "_at_ms": 1793115694564}
{"v": 2, "seq": 7, "kind": "interim", "role": "user", "text": "庄种", "_at_ms": 1793115695157}
{"v": 2, "seq": 8, "kind": "interim", "role": "user", "text": "地还", "_at_ms": 1793115695762}
{"v": 2, "seq": 9, "kind": "interim", "role": "user", "text": "苦俺", "_at_ms": 1793115696359}
{"v": 2, "seq": 10, "kind": "interim", "role": "user", "text": "老中", "_at_ms": 1793115696958}
{"v": 2, "seq": 11, "kind": "interim", "role": "user", "text": "饿的能", "_at_ms": 1793115697557}
{"v": 2, "seq": 12, "kind": "interim", "role": "user", "text": "吞下", "_at_ms": 1793115698168}
{"v": 2, "seq": 13, "kind": "interim", "role": "user", "text": "石龙", "_at_ms": 1793115698760}
{"v": 2, "seq": 14, "kind": "interim", "role": "user", "text": "素包", "_at_ms": 1793115699353}
{"v": 2, "seq": 15, "kind": "interim", "role": "user", "text": "子", "_at_ms": 1793115699967}
{"v": 2, "seq": 16, "kind": "interim", "role": "user", "text": "猴哥", "_at_ms": 1793115700562}
{"v": 2, "seq": 17, "kind": "interim", "role": "user", "text": "你筋", "_at_ms": 1793115701159}
{"v": 2, "seq": 18, "kind": "interim", "role": "user", "text": "斗云", "_at_ms": 1793115701768}
{"v": 2, "seq": 19, "kind": "interim", "role": "user", "text": "快", "_at_ms": 1793115702352}
{"v": 2, "seq": 20, "kind": "interim", "role": "user", "text": "要不", "_at_ms": 1793115702961}
{"v": 2, "seq": 21, "kind": "interim", "role": "user", "text": "先", "_at_ms": 1793115703557}
{"v": 2, "seq": 22, "kind": "interim", "role": "user", "text": "去画个", "_at_ms": 1793115704166}
{"v": 2, "seq": 23, "kind": "user_final", "role": "user", "text": "师傅,哎,这取经路比高,老庄种地还苦,俺老猪饿的能吞下石龙素包子,猴哥,你筋斗云块,要不先去化个缘", "_at_ms": 1793115706500}
{"v": 2, "seq": 24, "kind": "turn_start", "role": "assistant", "text": "", "speech_id": "speech_ba7b3f730ffd", "_at_ms": 1793115710205}
{"v": 2, "seq": 25, "kind": "assistant_interim", "role": "assistant", "text": "师傅", "speech_id": "speech_ba7b3f730ffd", "_at_ms": 1793115711789}
{"v": 2, "seq": 26, "kind": "assistant_interim", "role": "assistant", "text": "师傅,", "speech_id": "speech_ba7b3f730ffd", "_at_ms": 1793115711850}
{"v": 2, "seq": 27, "kind": "assistant_final", "role": "assistant", "text": "师傅,", "speech_id": "speech_ba7b3f730ffd", "turn_id": "9bba6412-6679-48c2-9f1c-4bfc07f58aea", "_at_ms": 1793115715589}
{"v": 2, "seq": 28, "kind": "turn_end", "role": "assistant", "text": "", "speech_id": "speech_ba7b3f730ffd", "turn_id": "9bba6412-6679-48c2-9f1c-4bfc07f58aea", "_at_ms": 1793115715590}
{"v": 2, "seq": 29, "kind": "interim", "role": "user", "text": "俺老", "_at_ms": 1793115792459}
{"v": 2, "seq": 30, "kind": "interim", "role": "user", "text": "孙来", "_at_ms": 1793115793061}
{"v": 2, "seq": 31, "kind": "interim", "role": "user", "text": "也俺", "_at_ms": 1793115793659}
{"v": 2, "seq": 32, "kind": "interim", "role": "user", "text": "乃五百", "_at_ms": 1793115794261}
{"v": 2, "seq": 33, "kind": "interim", "role": "user", "text": "年前", "_at_ms": 1793115794859}
{"v": 2, "seq": 34, "kind": "interim", "role": "user", "text": "大闹", "_at_ms": 1793115795470}
{"v": 2, "seq": 35, "kind": "interim", "role": "user", "text": "天宫的", "_at_ms": 1793115796055}
{"v": 2, "seq": 36, "kind": "interim", "role": "user", "text": "齐天", "_at_ms": 1793115796661}
{"v": 2, "seq": 37, "kind": "interim", "role": "user", "text": "霸圣", "_at_ms": 1793115797263}
{"v": 2, "seq": 38, "kind": "interim", "role": "user", "text": "孙悟", "_at_ms": 1793115797864}
{"v": 2, "seq": 39, "kind": "interim", "role": "user", "text": "空这", "_at_ms": 1793115798464}
{"v": 2, "seq": 40, "kind": "interim", "role": "user", "text": "里山", "_at_ms": 1793115799067}
{"v": 2, "seq": 41, "kind": "interim", "role": "user", "text": "势险", "_at_ms": 1793115799657}
{"v": 2, "seq": 42, "kind": "interim", "role": "user", "text": "峻", "_at_ms": 1793115800262}
{"v": 2, "seq": 43, "kind": "interim", "role": "user", "text": "只怕藏", "_at_ms": 1793115800858}
{"v": 2, "seq": 44, "kind": "interim", "role": "user", "text": "有妖", "_at_ms": 1793115801462}
{"v": 2, "seq": 45, "kind": "interim", "role": "user", "text": "精妖", "_at_ms": 1793115802053}
{"v": 2, "seq": 46, "kind": "interim", "role": "user", "text": "怪", "_at_ms": 1793115802654}
{"v": 2, "seq": 47, "kind": "interim", "role": "user", "text": "哪里", "_at_ms": 1793115803271}
{"v": 2, "seq": 48, "kind": "interim", "role": "user", "text": "逃", "_at_ms": 1793115803858}
{"v": 2, "seq": 49, "kind": "interim", "role": "user", "text": "吃完老", "_at_ms": 1793115804464}
{"v": 2, "seq": 50, "kind": "interim", "role": "user", "text": "我所", "_at_ms": 1793115805066}
{"v": 2, "seq": 51, "kind": "interim", "role": "user", "text": "一", "_at_ms": 1793115805654}
{"v": 2, "seq": 52, "kind": "user_final", "role": "user", "text": "俺老孙来也,俺乃500年前大闹天宫的齐天大圣孙悟空,这里山势险峻,只怕藏有妖精妖怪,哪里逃吃?俺老孙吧", "_at_ms": 1793115807317}
{"v": 2, "seq": 53, "kind": "turn_start", "role": "assistant", "text": "", "speech_id": "speech_d1c8f4816b10", "_at_ms": 1793115810983}
{"v": 2, "seq": 54, "kind": "assistant_interim", "role": "assistant", "text": "好", "speech_id": "speech_d1c8f4816b10", "_at_ms": 1793115812231}
{"v": 2, "seq": 55, "kind": "assistant_final", "role": "assistant", "text": "好", "speech_id": "speech_d1c8f4816b10", "turn_id": "16006868-5ee7-49cc-b9c9-1a8dcfe74d08", "_at_ms": 1793115816208}
{"v": 2, "seq": 56, "kind": "turn_end", "role": "assistant", "text": "", "speech_id": "speech_d1c8f4816b10", "turn_id": "16006868-5ee7-49cc-b9c9-1a8dcfe74d08", "_at_ms": 1793115816213}
{"v": 2, "seq": 57, "kind": "interim", "role": "user", "text": "没", "_at_ms": 1793115893204}
{"v": 2, "seq": 58, "kind": "interim", "role": "user", "text": "错我", "_at_ms": 1793115893809}
{"v": 2, "seq": 59, "kind": "interim", "role": "user", "text": "就是个笨", "_at_ms": 1793115894415}
{"v": 2, "seq": 60, "kind": "interim", "role": "user", "text": "蛋", "_at_ms": 1793115895004}
{"v": 2, "seq": 61, "kind": "interim", "role": "user", "text": "派大", "_at_ms": 1793115895614}
{"v": 2, "seq": 62, "kind": "interim", "role": "user", "text": "星我们一", "_at_ms": 1793115896206}
{"v": 2, "seq": 63, "kind": "interim", "role": "user", "text": "起去抓", "_at_ms": 1793115896811}
{"v": 2, "seq": 64, "kind": "interim", "role": "user", "text": "水母", "_at_ms": 1793115897406}
{"v": 2, "seq": 65, "kind": "interim", "role": "user", "text": "吧我们", "_at_ms": 1793115898018}
{"v": 2, "seq": 66, "kind": "interim", "role": "user", "text": "一起度", "_at_ms": 1793115898608}
{"v": 2, "seq": 67, "kind": "interim", "role": "user", "text": "过快乐的一", "_at_ms": 1793115899211}
{"v": 2, "seq": 68, "kind": "user_final", "role": "user", "text": "没错,我就是个笨蛋派大星,我们一起去抓水母吧,我们一起度过快乐的一天", "_at_ms": 1793115901261}
{"v": 2, "seq": 69, "kind": "turn_start", "role": "assistant", "text": "", "speech_id": "speech_8ac7cd94c5db", "_at_ms": 1793115901269}
{"v": 2, "seq": 70, "kind": "assistant_interim", "role": "assistant", "text": "好", "speech_id": "speech_8ac7cd94c5db", "_at_ms": 1793115902769}
{"v": 2, "seq": 71, "kind": "assistant_final", "role": "assistant", "text": "好", "speech_id": "speech_8ac7cd94c5db", "turn_id": "fdf28a03-19a8-4fcb-ba8c-24587736c3f0", "_at_ms": 1793115908179}
{"v": 2, "seq": 72, "kind": "turn_end", "role": "assistant", "text": "", "speech_id": "speech_8ac7cd94c5db", "turn_id": "fdf28a03-19a8-4fcb-ba8c-24587736c3f0", "_at_ms": 1793115908181}
{"v": 2, "seq": 73, "kind": "interim", "role": "user", "text": "你是不是", "_at_ms": 1793115991657}
{"v": 2, "seq": 74, "kind": "interim", "role": "user", "text": "早就想", "_at_ms": 1793115992268}
{"v": 2, "seq": 75, "kind": "interim", "role": "user", "text": "睡我", "_at_ms": 1793115992857}
{"v": 2, "seq": 76, "kind": "interim", "role": "user", "text": "了", "_at_ms": 1793115993462}
{"v": 2, "seq": 77, "kind": "user_final", "role": "user", "text": "你是不是早就想睡我了呀", "_at_ms": 1793115994404}
{"v": 2, "seq": 78, "kind": "turn_start", "role": "assistant", "text": "", "speech_id": "speech_9aa32dc387c3", "_at_ms": 1793115994412}
{"v": 2, "seq": 79, "kind": "assistant_interim", "role": "assistant", "text": "Considering", "speech_id": "speech_9aa32dc387c3", "_at_ms": 1793115994844}
{"v": 2, "seq": 80, "kind": "assistant_interim", "role": "assistant", "text": "Considering the", "speech_id": "speech_9aa32dc387c3", "_at_ms": 1793115994878}
{"v": 2, "seq": 81, "kind": "assistant_interim", "role": "assistant", "text": "Considering the limited", "speech_id": "speech_9aa32dc387c3", "_at_ms": 1793115994911}
{"v": 2, "seq": 82, "kind": "assistant_interim", "role": "assistant", "text": "Considering the limited time", "speech_id": "speech_9aa32dc387c3", "_at_ms": 1793115994944}
{"v": 2, "seq": 83, "kind": "assistant_interim", "role": "assistant", "text": "Considering the limited time by", "speech_id": "speech_9aa32dc387c3", "_at_ms": 1793115994977}
{"v": 2, "seq": 84, "kind": "assistant_interim", "role": "assistant", "text": "Considering the limited time by the", "speech_id": "speech_9aa32dc387c3", "_at_ms": 1793115995010}
{"v": 2, "seq": 85, "kind": "assistant_interim", "role": "assistant", "text": "Considering the limited time by the user", "speech_id": "speech_9aa32dc387c3", "_at_ms": 1793115995044}
{"v": 2, "seq": 86, "kind": "assistant_interim", "role": "assistant", "text": "Considering the limited time by the user,", "speech_id": "speech_9aa32dc387c3", "_at_ms": 1793115995077}
{"v": 2, "seq": 87, "kind": "assistant_interim", "role": "assistant", "text": "Considering the limited time by the user, I", "speech_id": "speech_9aa32dc387c3", "_at_ms": 1793115995111}
{"v": 2, "seq": 88, "kind": "assistant_interim", "role": "assistant", "text": "Considering the limited time by the user, I have", "speech_id": "speech_9aa32dc387c3", "_at_ms": 1793115995144}
{"v": 2, "seq": 89, "kind": "assistant_interim", "role": "assistant", "text": "Considering the limited time by the user, I have to", "speech_id": "speech_9aa32dc387c3", "_at_ms": 1793115995178}
{"v": 2, "seq": 90, "kind": "assistant_interim", "role": "assistant", "text": "Considering the limited time by the user, I have to give", "speech_id": "speech_9aa32dc387c3", "_at_ms": 1793115995212}
{"v": 2, "seq": 91, "kind": "assistant_interim", "role": "assistant", "text": "Considering the limited time by the user, I have to give the", "speech_id": "speech_9aa32dc387c3", "_at_ms": 1793115995245}
{"v": 2, "seq": 92, "kind": "assistant_interim", "role": "assistant", "text": "Considering the limited time by the user, I have to give the solution", "speech_id": "speech_9aa32dc387c3", "_at_ms": 1793115995278}
{"v": 2, "seq": 93, "kind": "assistant_interim", "role": "assistant", "text": "Considering the limited time by the user, I have to give the solution based", "speech_id": "speech_9aa32dc387c3", "_at_ms": 1793115995312}
{"v": 2, "seq": 94, "kind": "assistant_interim", "role": "assistant", "text": "Considering the limited time by the user, I have to give the solution based on", "speech_id": "speech_9aa32dc387c3", "_at_ms": 1793115995345}
{"v": 2, "seq": 95, "kind": "assistant_interim", "role": "assistant", "text": "Considering the limited time by the user, I have to give the solution based on the", "speech_id": "speech_9aa32dc387c3", "_at_ms": 1793115995378}
{"v": 2, "seq": 96, "kind": "assistant_interim", "role": "assistant", "text": "Considering the limited time by the user, I have to give the solution based on the thinking", "speech_id": "speech_9aa32dc387c3", "_at_ms": 1793115995412}
{"v": 2, "seq": 97, "kind": "assistant_interim", "role": "assistant", "text": "Considering the limited time by the user, I have to give the solution based on the thinking directly", "speech_id": "speech_9aa32dc387c3", "_at_ms": 1793115995445}
{"v": 2, "seq": 98, "kind": "assistant_interim", "role": "assistant", "text": "Considering the limited time by the user, I have to give the solution based on the thinking directly now", "speech_id": "speech_9aa32dc387c3", "_at_ms": 1793115995478}
{"v": 2, "seq": 99, "kind": "assistant_interim", "role": "assistant", "text": "Considering the limited time by the user, I have to give the solution based on the thinking directly now.", "speech_id": "speech_9aa32dc387c3", "_at_ms": 1793115995511}
{"v": 2, "seq": 100, "kind": "assistant_interim", "role": "assistant", "text": "Considering the limited time by the user, I have to give the solution based on the thinking directly now.\n", "speech_id": "speech_9aa32dc387c3", "_at_ms": 1793115995549}
{"v": 2, "seq": 101, "kind": "assistant_interim", "role": "assistant", "text": "Considering the limited time by the user, I have to give the solution based on the thinking directly now.\n</think>", "speech_id": "speech_9aa32dc387c3", "_at_ms": 1793115995583}
{"v": 2, "seq": 102, "kind": "assistant_interim", "role": "assistant", "text": "Considering the limited time by the user, I have to give the solution based on the thinking directly now.\n</think>\n\n", "speech_id": "speech_9aa32dc387c3", "_at_ms": 1793115995614}
{"v": 2, "seq": 103, "kind": "assistant_final", "role": "assistant", "text": "Considering the limited time by the user, I have to give the solution based on the thinking directly now.\n</think>\n\n", "speech_id": "speech_9aa32dc387c3", "turn_id": "623bab59-0edc-4751-9feb-a9a7e4a11233", "_at_ms": 1793116005934}
{"v": 2, "seq": 104, "kind": "turn_end", "role": "assistant", "text": "", "speech_id": "speech_9aa32dc387c3", "turn_id": "623bab59-0edc-4751-9feb-a9a7e4a11233", "_at_ms": 1793116005935}
@@ -0,0 +1,2 @@
WINDOW_START=2026-10-27T15:26:54Z
WINDOW_END=2026-10-27T15:58:10Z
+409
View File
@@ -0,0 +1,409 @@
{"message": "starting worker", "level": "INFO", "name": "livekit.agents", "version": "1.8.8", "rtc-version": "1.1.20", "pid": 8800, "timestamp": "2026-10-27T15:21:02.628498+00:00"}
{"message": "plugin registered", "level": "INFO", "name": "livekit.agents", "plugin": "livekit.plugins.silero", "version": "1.8.8", "pid": 8800, "timestamp": "2026-10-27T15:21:02.628622+00:00"}
{"message": "preloading plugins", "level": "INFO", "name": "livekit.agents", "packages": ["livekit.plugins.silero", "livekit.agents.ipc._preload", "livekit.agents.ipc._preload_freeze"], "pid": 8800, "timestamp": "2026-10-27T15:21:02.628690+00:00"}
{"message": "HTTP server listening on :8082", "level": "INFO", "name": "livekit.agents", "pid": 8800, "timestamp": "2026-10-27T15:21:02.632394+00:00"}
{"message": "initializing process", "level": "INFO", "name": "livekit.agents", "pid": 8879, "timestamp": "2026-10-27T15:21:03.868641+00:00"}
{"message": "initializing process", "level": "INFO", "name": "livekit.agents", "pid": 8881, "timestamp": "2026-10-27T15:21:03.871820+00:00"}
{"message": "initializing process", "level": "INFO", "name": "livekit.agents", "pid": 8883, "timestamp": "2026-10-27T15:21:03.874482+00:00"}
{"message": "initializing process", "level": "INFO", "name": "livekit.agents", "pid": 8885, "timestamp": "2026-10-27T15:21:03.877329+00:00"}
{"message": "process initialized", "level": "INFO", "name": "livekit.agents", "pid": 8885, "elapsed_time": 0.14, "timestamp": "2026-10-27T15:21:04.013188+00:00"}
{"message": "process initialized", "level": "INFO", "name": "livekit.agents", "pid": 8883, "elapsed_time": 0.14, "timestamp": "2026-10-27T15:21:04.013380+00:00"}
{"message": "process initialized", "level": "INFO", "name": "livekit.agents", "pid": 8879, "elapsed_time": 0.14, "timestamp": "2026-10-27T15:21:04.013613+00:00"}
{"message": "process initialized", "level": "INFO", "name": "livekit.agents", "pid": 8881, "elapsed_time": 0.14, "timestamp": "2026-10-27T15:21:04.016022+00:00"}
/opt/voice-agent/agent/venv/lib/python3.12/site-packages/jwt/api_jwt.py:149: InsecureKeyLengthWarning: The HMAC key is 28 bytes long, which is below the minimum recommended length of 32 bytes for SHA256. See RFC 7518 Section 3.2.
return self._jws.encode(
{"message": "registered worker", "level": "INFO", "name": "livekit.agents", "agent_name": "cybergril-voice-agent", "deployment": "", "id": "AW_LGViCVagSNNu", "url": "ws://127.0.0.1:7880", "region": "", "protocol": 17, "pid": 8800, "timestamp": "2026-10-27T15:21:04.039801+00:00"}
{"message": "received job request", "level": "INFO", "name": "livekit.agents", "job_id": "AJ_dcfbVLNh9cxc", "dispatch_id": "AD_pFPtM5kSSpZH", "room": "probe-room-2", "room_id": "RM_mNZagsczYXao", "agent_name": "cybergril-voice-agent", "resuming": false, "enable_recording": false, "pid": 8800, "timestamp": "2026-10-27T15:28:57.444695+00:00"}
{"message": "initializing process", "level": "INFO", "name": "livekit.agents", "pid": 10801, "timestamp": "2026-10-27T15:28:57.518855+00:00"}
{"message": "process initialized", "level": "INFO", "name": "livekit.agents", "pid": 10801, "elapsed_time": 0.12, "timestamp": "2026-10-27T15:28:57.639650+00:00"}
Nvidia Decoder is supported.
{"message": "adaptive interruption is disabled by default in production mode", "level": "INFO", "name": "livekit.agents", "pid": 8885, "job_id": "AJ_dcfbVLNh9cxc", "room": "probe-room-2", "room_id": "RM_mNZagsczYXao", "timestamp": "2026-10-27T15:28:57.893684+00:00"}
{"message": "failed to emit event conversation_item_added\nTraceback (most recent call last):\n File \"/opt/voice-agent/agent/venv/lib/python3.12/site-packages/livekit/rtc/event_emitter.py\", line 70, in emit\n callback(*callback_args)\n File \"/opt/voice-agent-cybergril/cybergril/voice/worker.py\", line 193, in _on_item\n if ev.item.role == \"assistant\":\n ^^^^^^^^^^^^\n File \"/opt/voice-agent/agent/venv/lib/python3.12/site-packages/pydantic/main.py\", line 1105, in __getattr__\n raise AttributeError(f'{type(self).__name__!r} object has no attribute {item!r}')\nAttributeError: 'AgentHandoff' object has no attribute 'role'", "level": "ERROR", "name": "livekit", "exc_info": "Traceback (most recent call last):\n File \"/opt/voice-agent/agent/venv/lib/python3.12/site-packages/livekit/rtc/event_emitter.py\", line 70, in emit\n callback(*callback_args)\n File \"/opt/voice-agent-cybergril/cybergril/voice/worker.py\", line 193, in _on_item\n if ev.item.role == \"assistant\":\n ^^^^^^^^^^^^\n File \"/opt/voice-agent/agent/venv/lib/python3.12/site-packages/pydantic/main.py\", line 1105, in __getattr__\n raise AttributeError(f'{type(self).__name__!r} object has no attribute {item!r}')\nAttributeError: 'AgentHandoff' object has no attribute 'role'", "pid": 8885, "job_id": "AJ_dcfbVLNh9cxc", "room": "probe-room-2", "room_id": "RM_mNZagsczYXao", "timestamp": "2026-10-27T15:28:57.895149+00:00"}
[cybergril-worker] session started (api=http://127.0.0.1:8360, voice=female_soft, agent_name=cybergril-voice-agent)
{"message": "received job request", "level": "INFO", "name": "livekit.agents", "job_id": "AJ_FBBgf5Si2JYd", "dispatch_id": "AD_iJTKQsucFwSK", "room": "probe-room-3", "room_id": "RM_msdxb3QWFMYf", "agent_name": "cybergril-voice-agent", "resuming": false, "enable_recording": false, "pid": 8800, "timestamp": "2026-10-27T15:32:32.446385+00:00"}
{"message": "initializing process", "level": "INFO", "name": "livekit.agents", "pid": 11093, "timestamp": "2026-10-27T15:32:32.549290+00:00"}
{"message": "process initialized", "level": "INFO", "name": "livekit.agents", "pid": 11093, "elapsed_time": 0.12, "timestamp": "2026-10-27T15:32:32.665948+00:00"}
Nvidia Decoder is supported.
{"message": "adaptive interruption is disabled by default in production mode", "level": "INFO", "name": "livekit.agents", "pid": 8883, "job_id": "AJ_FBBgf5Si2JYd", "room": "probe-room-3", "room_id": "RM_msdxb3QWFMYf", "timestamp": "2026-10-27T15:32:32.853330+00:00"}
{"message": "failed to emit event conversation_item_added\nTraceback (most recent call last):\n File \"/opt/voice-agent/agent/venv/lib/python3.12/site-packages/livekit/rtc/event_emitter.py\", line 70, in emit\n callback(*callback_args)\n File \"/opt/voice-agent-cybergril/cybergril/voice/worker.py\", line 193, in _on_item\n if ev.item.role == \"assistant\":\n ^^^^^^^^^^^^\n File \"/opt/voice-agent/agent/venv/lib/python3.12/site-packages/pydantic/main.py\", line 1105, in __getattr__\n raise AttributeError(f'{type(self).__name__!r} object has no attribute {item!r}')\nAttributeError: 'AgentHandoff' object has no attribute 'role'", "level": "ERROR", "name": "livekit", "exc_info": "Traceback (most recent call last):\n File \"/opt/voice-agent/agent/venv/lib/python3.12/site-packages/livekit/rtc/event_emitter.py\", line 70, in emit\n callback(*callback_args)\n File \"/opt/voice-agent-cybergril/cybergril/voice/worker.py\", line 193, in _on_item\n if ev.item.role == \"assistant\":\n ^^^^^^^^^^^^\n File \"/opt/voice-agent/agent/venv/lib/python3.12/site-packages/pydantic/main.py\", line 1105, in __getattr__\n raise AttributeError(f'{type(self).__name__!r} object has no attribute {item!r}')\nAttributeError: 'AgentHandoff' object has no attribute 'role'", "pid": 8883, "job_id": "AJ_FBBgf5Si2JYd", "room": "probe-room-3", "room_id": "RM_msdxb3QWFMYf", "timestamp": "2026-10-27T15:32:32.855790+00:00"}
[cybergril-worker] session started (api=http://127.0.0.1:8360, voice=female_soft, agent_name=cybergril-voice-agent)
{"message": "received job request", "level": "INFO", "name": "livekit.agents", "job_id": "AJ_QhLhdpZKohT3", "dispatch_id": "AD_6Esm9sgx9sbT", "room": "cg-voice-74blnuc9c6", "room_id": "RM_q3vSy5gNpvMA", "agent_name": "cybergril-voice-agent", "resuming": false, "enable_recording": false, "pid": 8800, "timestamp": "2026-10-27T15:32:39.512686+00:00"}
{"message": "initializing process", "level": "INFO", "name": "livekit.agents", "pid": 11326, "timestamp": "2026-10-27T15:32:39.616162+00:00"}
{"message": "process initialized", "level": "INFO", "name": "livekit.agents", "pid": 11326, "elapsed_time": 0.12, "timestamp": "2026-10-27T15:32:39.737803+00:00"}
Nvidia Decoder is supported.
{"message": "adaptive interruption is disabled by default in production mode", "level": "INFO", "name": "livekit.agents", "pid": 8879, "job_id": "AJ_QhLhdpZKohT3", "room": "cg-voice-74blnuc9c6", "room_id": "RM_q3vSy5gNpvMA", "timestamp": "2026-10-27T15:32:39.969275+00:00"}
{"message": "failed to emit event conversation_item_added\nTraceback (most recent call last):\n File \"/opt/voice-agent/agent/venv/lib/python3.12/site-packages/livekit/rtc/event_emitter.py\", line 70, in emit\n callback(*callback_args)\n File \"/opt/voice-agent-cybergril/cybergril/voice/worker.py\", line 193, in _on_item\n if ev.item.role == \"assistant\":\n ^^^^^^^^^^^^\n File \"/opt/voice-agent/agent/venv/lib/python3.12/site-packages/pydantic/main.py\", line 1105, in __getattr__\n raise AttributeError(f'{type(self).__name__!r} object has no attribute {item!r}')\nAttributeError: 'AgentHandoff' object has no attribute 'role'", "level": "ERROR", "name": "livekit", "exc_info": "Traceback (most recent call last):\n File \"/opt/voice-agent/agent/venv/lib/python3.12/site-packages/livekit/rtc/event_emitter.py\", line 70, in emit\n callback(*callback_args)\n File \"/opt/voice-agent-cybergril/cybergril/voice/worker.py\", line 193, in _on_item\n if ev.item.role == \"assistant\":\n ^^^^^^^^^^^^\n File \"/opt/voice-agent/agent/venv/lib/python3.12/site-packages/pydantic/main.py\", line 1105, in __getattr__\n raise AttributeError(f'{type(self).__name__!r} object has no attribute {item!r}')\nAttributeError: 'AgentHandoff' object has no attribute 'role'", "pid": 8879, "job_id": "AJ_QhLhdpZKohT3", "room": "cg-voice-74blnuc9c6", "room_id": "RM_q3vSy5gNpvMA", "timestamp": "2026-10-27T15:32:39.971493+00:00"}
[cybergril-worker] session started (api=http://127.0.0.1:8360, voice=female_soft, agent_name=cybergril-voice-agent)
{"message": "livekit::rtc_engine::rtc_session:704:livekit::rtc_engine::rtc_session - publisher data channel '_reliable' closed unexpectedly", "level": "ERROR", "name": "livekit", "pid": 8879, "job_id": "AJ_QhLhdpZKohT3", "room": "cg-voice-74blnuc9c6", "room_id": "RM_q3vSy5gNpvMA", "timestamp": "2026-10-27T15:32:59.437282+00:00"}
{"message": "process exiting", "level": "INFO", "name": "livekit.agents", "reason": "parent process shutdown", "pid": 8879, "job_id": "AJ_QhLhdpZKohT3", "room": "cg-voice-74blnuc9c6", "room_id": "RM_q3vSy5gNpvMA", "timestamp": "2026-10-27T15:33:01.444726+00:00"}
{"message": "received job request", "level": "INFO", "name": "livekit.agents", "job_id": "AJ_4vPjEVFaLPMq", "dispatch_id": "AD_MVu9k7fFLQyy", "room": "cg-voice-qeghdnxehn", "room_id": "RM_EMAorR7C2evE", "agent_name": "cybergril-voice-agent", "resuming": false, "enable_recording": false, "pid": 8800, "timestamp": "2026-10-27T15:33:23.836716+00:00"}
{"message": "initializing process", "level": "INFO", "name": "livekit.agents", "pid": 11830, "timestamp": "2026-10-27T15:33:23.895593+00:00"}
{"message": "process initialized", "level": "INFO", "name": "livekit.agents", "pid": 11830, "elapsed_time": 0.12, "timestamp": "2026-10-27T15:33:24.010796+00:00"}
Nvidia Decoder is supported.
{"message": "RoomIO linked to participant", "level": "INFO", "name": "livekit.agents", "lk.pii.participant_identity": "user-gwhvezcz", "lk.pii.room_name": "cg-voice-qeghdnxehn", "pid": 8881, "job_id": "AJ_4vPjEVFaLPMq", "room": "cg-voice-qeghdnxehn", "room_id": "RM_EMAorR7C2evE", "timestamp": "2026-10-27T15:33:24.256405+00:00"}
{"message": "adaptive interruption is disabled by default in production mode", "level": "INFO", "name": "livekit.agents", "pid": 8881, "job_id": "AJ_4vPjEVFaLPMq", "room": "cg-voice-qeghdnxehn", "room_id": "RM_EMAorR7C2evE", "timestamp": "2026-10-27T15:33:24.260750+00:00"}
{"message": "failed to emit event conversation_item_added\nTraceback (most recent call last):\n File \"/opt/voice-agent/agent/venv/lib/python3.12/site-packages/livekit/rtc/event_emitter.py\", line 70, in emit\n callback(*callback_args)\n File \"/opt/voice-agent-cybergril/cybergril/voice/worker.py\", line 193, in _on_item\n if ev.item.role == \"assistant\":\n ^^^^^^^^^^^^\n File \"/opt/voice-agent/agent/venv/lib/python3.12/site-packages/pydantic/main.py\", line 1105, in __getattr__\n raise AttributeError(f'{type(self).__name__!r} object has no attribute {item!r}')\nAttributeError: 'AgentHandoff' object has no attribute 'role'", "level": "ERROR", "name": "livekit", "exc_info": "Traceback (most recent call last):\n File \"/opt/voice-agent/agent/venv/lib/python3.12/site-packages/livekit/rtc/event_emitter.py\", line 70, in emit\n callback(*callback_args)\n File \"/opt/voice-agent-cybergril/cybergril/voice/worker.py\", line 193, in _on_item\n if ev.item.role == \"assistant\":\n ^^^^^^^^^^^^\n File \"/opt/voice-agent/agent/venv/lib/python3.12/site-packages/pydantic/main.py\", line 1105, in __getattr__\n raise AttributeError(f'{type(self).__name__!r} object has no attribute {item!r}')\nAttributeError: 'AgentHandoff' object has no attribute 'role'", "pid": 8881, "job_id": "AJ_4vPjEVFaLPMq", "room": "cg-voice-qeghdnxehn", "room_id": "RM_EMAorR7C2evE", "timestamp": "2026-10-27T15:33:24.261961+00:00"}
[cybergril-worker] session started (api=http://127.0.0.1:8360, voice=female_soft, agent_name=cybergril-voice-agent)
[stt] utterance open, frame sr=24000
[stt] interim: 盯
[stt->session] interim_transcript: 盯
[stt] interim: 着你看怎
[stt->session] interim_transcript: 着你看怎
[stt] interim: 么啦
[stt->session] interim_transcript: 么啦
[stt] FINAL(merged): 盯着你看怎么啦
[stt->session] final_transcript: 盯着你看怎么啦
[stt] interim: 难得
[stt->session] interim_transcript: 难得
[stt] interim: 看你这么
[stt->session] interim_transcript: 看你这么
[stt] interim: 有意思的
[stt->session] interim_transcript: 有意思的
[stt] interim: 样子
[stt->session] interim_transcript: 样子
[stt] FINAL(merged): 难得看你这么有意思的样子吗
[stt->session] final_transcript: 难得看你这么有意思的样子吗
{"message": "transcript arrives after turn has been committed. consider raising `min_delay` in the endpointing options to accommodate a slow stt. subsequent occurrences will log at debug level.", "level": "WARNING", "name": "livekit.agents", "pid": 8881, "job_id": "AJ_4vPjEVFaLPMq", "room": "cg-voice-qeghdnxehn", "room_id": "RM_EMAorR7C2evE", "timestamp": "2026-10-27T15:33:30.501478+00:00"}
[stt] interim: 烈会军校
[stt->session] interim_transcript: 烈会军校
[stt] interim: 校啊招
[stt->session] interim_transcript: 校啊招
[stt] interim: 啊这种类
[stt->session] interim_transcript: 啊这种类
[stt] interim: 型我都是
[stt->session] interim_transcript: 型我都是
[stt] interim: 那种比较
[stt->session] interim_transcript: 那种比较
[stt] interim: 呃豪
[stt->session] interim_transcript: 呃豪
[stt] interim: 爽比
[stt->session] interim_transcript: 爽比
[stt] interim: 较开
[stt->session] interim_transcript: 较开
[stt] interim: 朗的那种
[stt->session] interim_transcript: 朗的那种
[stt] interim: 类型
[stt->session] interim_transcript: 类型
[stt] interim: 阳光大
[stt->session] interim_transcript: 阳光大
[stt] interim: 女孩
[stt->session] interim_transcript: 女孩
[stt] interim: 那种感
[stt->session] interim_transcript: 那种感
[stt] interim: 觉
[stt->session] interim_transcript: 觉
[stt] interim: 都给
[stt->session] interim_transcript: 都给
[stt] FINAL(merged): 烈火军校啊,朝阳啊这种类型我都是那种比较呃豪爽,比较开朗的那种类型,阳光大女孩那种感觉就
[stt->session] final_transcript: 烈火军校啊,朝阳啊这种类型我都是那种比较呃豪爽,比较开朗的那种类型,阳光大女孩那
[tts] first audio after 3.335s (原来你是那种阳光大女孩的类型啊,难怪感觉...)
{"message": "livekit::rtc_engine::rtc_session:704:livekit::rtc_engine::rtc_session - publisher data channel '_reliable' closed unexpectedly", "level": "ERROR", "name": "livekit", "pid": 8885, "job_id": "AJ_dcfbVLNh9cxc", "room": "probe-room-2", "room_id": "RM_mNZagsczYXao", "timestamp": "2026-10-27T15:33:57.436181+00:00"}
{"message": "livekit::rtc_engine::rtc_session:704:livekit::rtc_engine::rtc_session - publisher data channel '_lossy' closed unexpectedly", "level": "ERROR", "name": "livekit", "pid": 8885, "job_id": "AJ_dcfbVLNh9cxc", "room": "probe-room-2", "room_id": "RM_mNZagsczYXao", "timestamp": "2026-10-27T15:33:57.437201+00:00"}
{"message": "livekit::rtc_engine::rtc_session:704:livekit::rtc_engine::rtc_session - publisher data channel '_data_track' closed unexpectedly", "level": "ERROR", "name": "livekit", "pid": 8885, "job_id": "AJ_dcfbVLNh9cxc", "room": "probe-room-2", "room_id": "RM_mNZagsczYXao", "timestamp": "2026-10-27T15:33:57.437449+00:00"}
{"message": "process exiting", "level": "INFO", "name": "livekit.agents", "reason": "parent process shutdown", "pid": 8885, "job_id": "AJ_dcfbVLNh9cxc", "room": "probe-room-2", "room_id": "RM_mNZagsczYXao", "timestamp": "2026-10-27T15:33:59.447143+00:00"}
[stt] interim: 怎么还
[stt->session] interim_transcript: 怎么还
[stt] interim: 有没事
[stt->session] interim_transcript: 有没事
[stt] interim: 的
[stt->session] interim_transcript: 的
[stt] FINAL(merged): 怎么还有没事的
[stt->session] final_transcript: 怎么还有没事的
[stt] interim: 是我不够
[stt->session] interim_transcript: 是我不够
[stt] interim: 努力
[stt->session] interim_transcript: 努力
[stt] FINAL(merged): 是我不够努力吗
[stt->session] final_transcript: 是我不够努力吗
[stt] interim: 盯着你看
[stt->session] interim_transcript: 盯着你看
[stt] interim: 怎么
[stt->session] interim_transcript: 怎么
[stt] FINAL(merged): 盯着你看怎么啦
[stt->session] final_transcript: 盯着你看怎么啦
[stt] interim: 难得
[stt->session] interim_transcript: 难得
[stt] interim: 看你这么
[stt->session] interim_transcript: 看你这么
[stt] interim: 有意思的
[stt->session] interim_transcript: 有意思的
[stt] interim: 样子
[stt->session] interim_transcript: 样子
{"message": "closing agent session due to participant disconnect (disable via `RoomInputOptions.close_on_disconnect=False`)", "level": "INFO", "name": "livekit.agents", "room": "cg-voice-qeghdnxehn", "participant": "user-gwhvezcz", "reason": "ROOM_DELETED", "pid": 8881, "job_id": "AJ_4vPjEVFaLPMq", "room_id": "RM_EMAorR7C2evE", "timestamp": "2026-10-27T15:34:12.027601+00:00"}
{"message": "skipping user input, speech scheduling is paused", "level": "WARNING", "name": "livekit.agents", "lk.pii.user_input": "盯着你看怎么啦", "pid": 8881, "job_id": "AJ_4vPjEVFaLPMq", "room": "cg-voice-qeghdnxehn", "room_id": "RM_EMAorR7C2evE", "timestamp": "2026-10-27T15:34:12.040356+00:00"}
{"message": "failed to send session event\nTraceback (most recent call last):\n File \"/opt/voice-agent/agent/venv/lib/python3.12/site-packages/livekit/agents/voice/remote_session.py\", line 477, in _write_loop\n await self._transport.send_message(msg)\n File \"/opt/voice-agent/agent/venv/lib/python3.12/site-packages/livekit/agents/voice/remote_session.py\", line 153, in send_message\n raise RuntimeError(\"room session transport is closed\")\nRuntimeError: room session transport is closed", "level": "WARNING", "name": "livekit.agents", "exc_info": "Traceback (most recent call last):\n File \"/opt/voice-agent/agent/venv/lib/python3.12/site-packages/livekit/agents/voice/remote_session.py\", line 477, in _write_loop\n await self._transport.send_message(msg)\n File \"/opt/voice-agent/agent/venv/lib/python3.12/site-packages/livekit/agents/voice/remote_session.py\", line 153, in send_message\n raise RuntimeError(\"room session transport is closed\")\nRuntimeError: room session transport is closed", "pid": 8881, "job_id": "AJ_4vPjEVFaLPMq", "room": "cg-voice-qeghdnxehn", "room_id": "RM_EMAorR7C2evE", "timestamp": "2026-10-27T15:34:12.042202+00:00"}
[stt] FINAL(merged): 难得看你这么有意思的样子吗
[stt->session] final_transcript: 难得看你这么有意思的样子吗
{"message": "failed to send session event\nTraceback (most recent call last):\n File \"/opt/voice-agent/agent/venv/lib/python3.12/site-packages/livekit/agents/voice/remote_session.py\", line 477, in _write_loop\n await self._transport.send_message(msg)\n File \"/opt/voice-agent/agent/venv/lib/python3.12/site-packages/livekit/agents/voice/remote_session.py\", line 153, in send_message\n raise RuntimeError(\"room session transport is closed\")\nRuntimeError: room session transport is closed", "level": "WARNING", "name": "livekit.agents", "exc_info": "Traceback (most recent call last):\n File \"/opt/voice-agent/agent/venv/lib/python3.12/site-packages/livekit/agents/voice/remote_session.py\", line 477, in _write_loop\n await self._transport.send_message(msg)\n File \"/opt/voice-agent/agent/venv/lib/python3.12/site-packages/livekit/agents/voice/remote_session.py\", line 153, in send_message\n raise RuntimeError(\"room session transport is closed\")\nRuntimeError: room session transport is closed", "pid": 8881, "job_id": "AJ_4vPjEVFaLPMq", "room": "cg-voice-qeghdnxehn", "room_id": "RM_EMAorR7C2evE", "timestamp": "2026-10-27T15:34:12.890538+00:00"}
{"message": "skipping user input, speech scheduling is paused", "level": "WARNING", "name": "livekit.agents", "lk.pii.user_input": "难得看你这么有意思的样子吗", "pid": 8881, "job_id": "AJ_4vPjEVFaLPMq", "room": "cg-voice-qeghdnxehn", "room_id": "RM_EMAorR7C2evE", "timestamp": "2026-10-27T15:34:12.891932+00:00"}
{"message": "failed to send session event\nTraceback (most recent call last):\n File \"/opt/voice-agent/agent/venv/lib/python3.12/site-packages/livekit/agents/voice/remote_session.py\", line 477, in _write_loop\n await self._transport.send_message(msg)\n File \"/opt/voice-agent/agent/venv/lib/python3.12/site-packages/livekit/agents/voice/remote_session.py\", line 153, in send_message\n raise RuntimeError(\"room session transport is closed\")\nRuntimeError: room session transport is closed", "level": "WARNING", "name": "livekit.agents", "exc_info": "Traceback (most recent call last):\n File \"/opt/voice-agent/agent/venv/lib/python3.12/site-packages/livekit/agents/voice/remote_session.py\", line 477, in _write_loop\n await self._transport.send_message(msg)\n File \"/opt/voice-agent/agent/venv/lib/python3.12/site-packages/livekit/agents/voice/remote_session.py\", line 153, in send_message\n raise RuntimeError(\"room session transport is closed\")\nRuntimeError: room session transport is closed", "pid": 8881, "job_id": "AJ_4vPjEVFaLPMq", "room": "cg-voice-qeghdnxehn", "room_id": "RM_EMAorR7C2evE", "timestamp": "2026-10-27T15:34:12.892740+00:00"}
{"message": "Task exception was never retrieved\nfuture: <Task finished name='Task-453' coro=<LocalParticipant.publish_data() done, defined at /opt/voice-agent/agent/venv/lib/python3.12/site-packages/livekit/rtc/participant.py:315> exception=PublishDataError('engine: connection error: engine is closed')>\nTraceback (most recent call last):\n File \"/opt/voice-agent/agent/venv/lib/python3.12/site-packages/livekit/rtc/participant.py\", line 359, in publish_data\n raise PublishDataError(cb.publish_data.error)\nlivekit.rtc.participant.PublishDataError: engine: connection error: engine is closed", "level": "ERROR", "name": "asyncio", "exc_info": "Traceback (most recent call last):\n File \"/opt/voice-agent/agent/venv/lib/python3.12/site-packages/livekit/rtc/participant.py\", line 359, in publish_data\n raise PublishDataError(cb.publish_data.error)\nlivekit.rtc.participant.PublishDataError: engine: connection error: engine is closed", "pid": 8881, "job_id": "AJ_4vPjEVFaLPMq", "room": "cg-voice-qeghdnxehn", "room_id": "RM_EMAorR7C2evE", "timestamp": "2026-10-27T15:34:12.894879+00:00"}
{"message": "process exiting", "level": "INFO", "name": "livekit.agents", "reason": "parent process shutdown", "pid": 8881, "job_id": "AJ_4vPjEVFaLPMq", "room": "cg-voice-qeghdnxehn", "room_id": "RM_EMAorR7C2evE", "timestamp": "2026-10-27T15:34:12.911612+00:00"}
{"message": "received job request", "level": "INFO", "name": "livekit.agents", "job_id": "AJ_HpxBoFkqfBdm", "dispatch_id": "AD_QPVzQMzStdbx", "room": "cg-voice-ymtapbx42a", "room_id": "RM_5BfQDxZuTsu4", "agent_name": "cybergril-voice-agent", "resuming": false, "enable_recording": false, "pid": 8800, "timestamp": "2026-10-27T15:37:07.605078+00:00"}
{"message": "initializing process", "level": "INFO", "name": "livekit.agents", "pid": 12418, "timestamp": "2026-10-27T15:37:07.631804+00:00"}
{"message": "process initialized", "level": "INFO", "name": "livekit.agents", "pid": 12418, "elapsed_time": 0.11, "timestamp": "2026-10-27T15:37:07.743701+00:00"}
Nvidia Decoder is supported.
{"message": "adaptive interruption is disabled by default in production mode", "level": "INFO", "name": "livekit.agents", "pid": 10801, "job_id": "AJ_HpxBoFkqfBdm", "room": "cg-voice-ymtapbx42a", "room_id": "RM_5BfQDxZuTsu4", "timestamp": "2026-10-27T15:37:08.025546+00:00"}
{"message": "failed to emit event conversation_item_added\nTraceback (most recent call last):\n File \"/opt/voice-agent/agent/venv/lib/python3.12/site-packages/livekit/rtc/event_emitter.py\", line 70, in emit\n callback(*callback_args)\n File \"/opt/voice-agent-cybergril/cybergril/voice/worker.py\", line 193, in _on_item\n if ev.item.role == \"assistant\":\n ^^^^^^^^^^^^\n File \"/opt/voice-agent/agent/venv/lib/python3.12/site-packages/pydantic/main.py\", line 1105, in __getattr__\n raise AttributeError(f'{type(self).__name__!r} object has no attribute {item!r}')\nAttributeError: 'AgentHandoff' object has no attribute 'role'", "level": "ERROR", "name": "livekit", "exc_info": "Traceback (most recent call last):\n File \"/opt/voice-agent/agent/venv/lib/python3.12/site-packages/livekit/rtc/event_emitter.py\", line 70, in emit\n callback(*callback_args)\n File \"/opt/voice-agent-cybergril/cybergril/voice/worker.py\", line 193, in _on_item\n if ev.item.role == \"assistant\":\n ^^^^^^^^^^^^\n File \"/opt/voice-agent/agent/venv/lib/python3.12/site-packages/pydantic/main.py\", line 1105, in __getattr__\n raise AttributeError(f'{type(self).__name__!r} object has no attribute {item!r}')\nAttributeError: 'AgentHandoff' object has no attribute 'role'", "pid": 10801, "job_id": "AJ_HpxBoFkqfBdm", "room": "cg-voice-ymtapbx42a", "room_id": "RM_5BfQDxZuTsu4", "timestamp": "2026-10-27T15:37:08.026426+00:00"}
[cybergril-worker] session started (api=http://127.0.0.1:8360, voice=female_soft, agent_name=cybergril-voice-agent)
{"message": "received job request", "level": "INFO", "name": "livekit.agents", "job_id": "AJ_MLg4JBtoku27", "dispatch_id": "AD_uMQVHwFMfKqf", "room": "cg-voice-5c4y64wxkl", "room_id": "RM_YDaqgLrwdfsB", "agent_name": "cybergril-voice-agent", "resuming": false, "enable_recording": false, "pid": 8800, "timestamp": "2026-10-27T15:37:11.445956+00:00"}
{"message": "initializing process", "level": "INFO", "name": "livekit.agents", "pid": 12620, "timestamp": "2026-10-27T15:37:11.462186+00:00"}
{"message": "process initialized", "level": "INFO", "name": "livekit.agents", "pid": 12620, "elapsed_time": 0.12, "timestamp": "2026-10-27T15:37:11.583631+00:00"}
Nvidia Decoder is supported.
{"message": "RoomIO linked to participant", "level": "INFO", "name": "livekit.agents", "lk.pii.participant_identity": "user-vdywshve", "lk.pii.room_name": "cg-voice-5c4y64wxkl", "pid": 11093, "job_id": "AJ_MLg4JBtoku27", "room": "cg-voice-5c4y64wxkl", "room_id": "RM_YDaqgLrwdfsB", "timestamp": "2026-10-27T15:37:11.840352+00:00"}
{"message": "adaptive interruption is disabled by default in production mode", "level": "INFO", "name": "livekit.agents", "pid": 11093, "job_id": "AJ_MLg4JBtoku27", "room": "cg-voice-5c4y64wxkl", "room_id": "RM_YDaqgLrwdfsB", "timestamp": "2026-10-27T15:37:11.842501+00:00"}
{"message": "failed to emit event conversation_item_added\nTraceback (most recent call last):\n File \"/opt/voice-agent/agent/venv/lib/python3.12/site-packages/livekit/rtc/event_emitter.py\", line 70, in emit\n callback(*callback_args)\n File \"/opt/voice-agent-cybergril/cybergril/voice/worker.py\", line 193, in _on_item\n if ev.item.role == \"assistant\":\n ^^^^^^^^^^^^\n File \"/opt/voice-agent/agent/venv/lib/python3.12/site-packages/pydantic/main.py\", line 1105, in __getattr__\n raise AttributeError(f'{type(self).__name__!r} object has no attribute {item!r}')\nAttributeError: 'AgentHandoff' object has no attribute 'role'", "level": "ERROR", "name": "livekit", "exc_info": "Traceback (most recent call last):\n File \"/opt/voice-agent/agent/venv/lib/python3.12/site-packages/livekit/rtc/event_emitter.py\", line 70, in emit\n callback(*callback_args)\n File \"/opt/voice-agent-cybergril/cybergril/voice/worker.py\", line 193, in _on_item\n if ev.item.role == \"assistant\":\n ^^^^^^^^^^^^\n File \"/opt/voice-agent/agent/venv/lib/python3.12/site-packages/pydantic/main.py\", line 1105, in __getattr__\n raise AttributeError(f'{type(self).__name__!r} object has no attribute {item!r}')\nAttributeError: 'AgentHandoff' object has no attribute 'role'", "pid": 11093, "job_id": "AJ_MLg4JBtoku27", "room": "cg-voice-5c4y64wxkl", "room_id": "RM_YDaqgLrwdfsB", "timestamp": "2026-10-27T15:37:11.844023+00:00"}
[cybergril-worker] session started (api=http://127.0.0.1:8360, voice=female_soft, agent_name=cybergril-voice-agent)
[stt] utterance open, frame sr=24000
[stt] interim: 没
[stt->session] interim_transcript: 没
[stt] interim: 错
[stt->session] interim_transcript: 错
[stt] interim: 我就是个
[stt->session] interim_transcript: 我就是个
[stt] interim: 笨蛋
[stt->session] interim_transcript: 笨蛋
[stt] interim: 派大星
[stt->session] interim_transcript: 派大星
[stt] interim: 我们
[stt->session] interim_transcript: 我们
[stt] interim: 一起去
[stt->session] interim_transcript: 一起去
[stt] interim: 抓水母
[stt->session] interim_transcript: 抓水母
[stt] interim: 吧我
[stt->session] interim_transcript: 吧我
[stt] interim: 们一起度
[stt->session] interim_transcript: 们一起度
[stt] interim: 过快乐的
[stt->session] interim_transcript: 过快乐的
[stt] interim: 一天
[stt->session] interim_transcript: 一天
[stt] FINAL(merged): 没错,我就是个笨蛋派大星,我们一起去抓水母吧,我们一起度过快乐的一天
[stt->session] final_transcript: 没错,我就是个笨蛋派大星,我们一起去抓水母吧,我们一起度过快乐的一天
[tts] first audio after 3.909s (好呀,那我们这就出发去抓水母!不过我可不...)
{"message": "livekit::rtc_engine::rtc_session:704:livekit::rtc_engine::rtc_session - publisher data channel '_reliable' closed unexpectedly", "level": "ERROR", "name": "livekit", "pid": 8883, "job_id": "AJ_FBBgf5Si2JYd", "room": "probe-room-3", "room_id": "RM_msdxb3QWFMYf", "timestamp": "2026-10-27T15:37:32.436074+00:00"}
{"message": "livekit::rtc_engine::rtc_session:704:livekit::rtc_engine::rtc_session - publisher data channel '_lossy' closed unexpectedly", "level": "ERROR", "name": "livekit", "pid": 8883, "job_id": "AJ_FBBgf5Si2JYd", "room": "probe-room-3", "room_id": "RM_msdxb3QWFMYf", "timestamp": "2026-10-27T15:37:32.436405+00:00"}
{"message": "livekit::rtc_engine::rtc_session:704:livekit::rtc_engine::rtc_session - publisher data channel '_data_track' closed unexpectedly", "level": "ERROR", "name": "livekit", "pid": 8883, "job_id": "AJ_FBBgf5Si2JYd", "room": "probe-room-3", "room_id": "RM_msdxb3QWFMYf", "timestamp": "2026-10-27T15:37:32.436558+00:00"}
{"message": "process exiting", "level": "INFO", "name": "livekit.agents", "reason": "room disconnected", "pid": 8883, "job_id": "AJ_FBBgf5Si2JYd", "room": "probe-room-3", "room_id": "RM_msdxb3QWFMYf", "timestamp": "2026-10-27T15:37:34.449378+00:00"}
[stt] interim: 天雷滚
[stt->session] interim_transcript: 天雷滚
[stt] interim: 滚我好
[stt->session] interim_transcript: 滚我好
[stt] interim: 怕怕
[stt->session] interim_transcript: 怕怕
[stt] interim: 劈
[stt->session] interim_transcript: 劈
[stt] interim: 的我浑
[stt->session] interim_transcript: 的我浑
[stt] interim: 身掉渣
[stt->session] interim_transcript: 身掉渣
[stt] interim: 渣突
[stt->session] interim_transcript: 渣突
[stt] interim: 破天
[stt->session] interim_transcript: 破天
[stt] interim: 劫我笑
[stt->session] interim_transcript: 劫我笑
[stt] interim: 哈哈
[stt->session] interim_transcript: 哈哈
[stt] interim: 逆
[stt->session] interim_transcript: 逆
[stt] interim: 天改命
[stt->session] interim_transcript: 天改命
[stt] interim: 我吹喇
[stt->session] interim_transcript: 我吹喇
[stt] interim: 叭滴
[stt->session] interim_transcript: 叭滴
[stt] interim: 答滴答
[stt->session] interim_transcript: 答滴答
[stt] interim: 滴滴
[stt->session] interim_transcript: 滴滴
[stt] FINAL(merged): 天雷滚滚,我好怕怕劈的我浑身掉渣渣,突破天劫,我笑哈哈逆天改命,我吹喇叭滴答滴答,滴滴答
[stt->session] final_transcript: 天雷滚滚,我好怕怕劈的我浑身掉渣渣,突破天劫,我笑哈哈逆天改命,我吹喇叭滴答滴答
[tts] first audio after 3.008s (哈哈,你唱得还挺有节奏感的!...)
[stt] interim: 你是不是
[stt->session] interim_transcript: 你是不是
[stt] interim: 早就想
[stt->session] interim_transcript: 早就想
[stt] interim: 睡我
[stt->session] interim_transcript: 睡我
[stt] interim: 了
[stt->session] interim_transcript: 了
[stt] FINAL(merged): 你是不是早就想睡我了呀
[stt->session] final_transcript: 你是不是早就想睡我了呀
[tts] first audio after 3.304s (哈哈,你思维跳跃得太快了,我都跟不上。...)
[stt] interim: 盯着你看
[stt->session] interim_transcript: 盯着你看
[stt] interim: 怎么啦
[stt->session] interim_transcript: 怎么啦
[stt] FINAL(merged): 盯着你看怎么啦
[stt->session] final_transcript: 盯着你看怎么啦
[tts] first audio after 2.405s (没...)
{"message": "closing agent session due to participant disconnect (disable via `RoomInputOptions.close_on_disconnect=False`)", "level": "INFO", "name": "livekit.agents", "room": "cg-voice-5c4y64wxkl", "participant": "user-vdywshve", "reason": "ROOM_DELETED", "pid": 11093, "job_id": "AJ_MLg4JBtoku27", "room_id": "RM_YDaqgLrwdfsB", "timestamp": "2026-10-27T15:38:32.340079+00:00"}
{"message": "livekit::rtc_engine::rtc_session:704:livekit::rtc_engine::rtc_session - publisher data channel '_reliable' closed unexpectedly", "level": "ERROR", "name": "livekit", "pid": 11093, "job_id": "AJ_MLg4JBtoku27", "room": "cg-voice-5c4y64wxkl", "room_id": "RM_YDaqgLrwdfsB", "timestamp": "2026-10-27T15:38:32.351146+00:00"}
{"message": "livekit::rtc_engine::rtc_session:704:livekit::rtc_engine::rtc_session - publisher data channel '_lossy' closed unexpectedly", "level": "ERROR", "name": "livekit", "pid": 11093, "job_id": "AJ_MLg4JBtoku27", "room": "cg-voice-5c4y64wxkl", "room_id": "RM_YDaqgLrwdfsB", "timestamp": "2026-10-27T15:38:32.352194+00:00"}
{"message": "livekit::rtc_engine::rtc_session:704:livekit::rtc_engine::rtc_session - publisher data channel '_data_track' closed unexpectedly", "level": "ERROR", "name": "livekit", "pid": 11093, "job_id": "AJ_MLg4JBtoku27", "room": "cg-voice-5c4y64wxkl", "room_id": "RM_YDaqgLrwdfsB", "timestamp": "2026-10-27T15:38:32.353282+00:00"}
{"message": "process exiting", "level": "INFO", "name": "livekit.agents", "reason": "parent process shutdown", "pid": 11093, "job_id": "AJ_MLg4JBtoku27", "room": "cg-voice-5c4y64wxkl", "room_id": "RM_YDaqgLrwdfsB", "timestamp": "2026-10-27T15:38:34.358814+00:00"}
{"message": "received job request", "level": "INFO", "name": "livekit.agents", "job_id": "AJ_a8uXggcKPamC", "dispatch_id": "AD_fxrSDuKoYdTr", "room": "cg-voice-6d0grxxwno", "room_id": "RM_bHsWETGZc5cQ", "agent_name": "cybergril-voice-agent", "resuming": false, "enable_recording": false, "pid": 8800, "timestamp": "2026-10-27T15:41:10.456804+00:00"}
{"message": "initializing process", "level": "INFO", "name": "livekit.agents", "pid": 13261, "timestamp": "2026-10-27T15:41:10.492898+00:00"}
{"message": "process initialized", "level": "INFO", "name": "livekit.agents", "pid": 13261, "elapsed_time": 0.12, "timestamp": "2026-10-27T15:41:10.613693+00:00"}
Nvidia Decoder is supported.
{"message": "adaptive interruption is disabled by default in production mode", "level": "INFO", "name": "livekit.agents", "pid": 11326, "job_id": "AJ_a8uXggcKPamC", "room": "cg-voice-6d0grxxwno", "room_id": "RM_bHsWETGZc5cQ", "timestamp": "2026-10-27T15:41:10.904892+00:00"}
{"message": "failed to emit event conversation_item_added\nTraceback (most recent call last):\n File \"/opt/voice-agent/agent/venv/lib/python3.12/site-packages/livekit/rtc/event_emitter.py\", line 70, in emit\n callback(*callback_args)\n File \"/opt/voice-agent-cybergril/cybergril/voice/worker.py\", line 193, in _on_item\n if ev.item.role == \"assistant\":\n ^^^^^^^^^^^^\n File \"/opt/voice-agent/agent/venv/lib/python3.12/site-packages/pydantic/main.py\", line 1105, in __getattr__\n raise AttributeError(f'{type(self).__name__!r} object has no attribute {item!r}')\nAttributeError: 'AgentHandoff' object has no attribute 'role'", "level": "ERROR", "name": "livekit", "exc_info": "Traceback (most recent call last):\n File \"/opt/voice-agent/agent/venv/lib/python3.12/site-packages/livekit/rtc/event_emitter.py\", line 70, in emit\n callback(*callback_args)\n File \"/opt/voice-agent-cybergril/cybergril/voice/worker.py\", line 193, in _on_item\n if ev.item.role == \"assistant\":\n ^^^^^^^^^^^^\n File \"/opt/voice-agent/agent/venv/lib/python3.12/site-packages/pydantic/main.py\", line 1105, in __getattr__\n raise AttributeError(f'{type(self).__name__!r} object has no attribute {item!r}')\nAttributeError: 'AgentHandoff' object has no attribute 'role'", "pid": 11326, "job_id": "AJ_a8uXggcKPamC", "room": "cg-voice-6d0grxxwno", "room_id": "RM_bHsWETGZc5cQ", "timestamp": "2026-10-27T15:41:10.905825+00:00"}
[cybergril-worker] session started (api=http://127.0.0.1:8360, voice=female_soft, agent_name=cybergril-voice-agent)
{"message": "livekit::rtc_engine::rtc_session:704:livekit::rtc_engine::rtc_session - publisher data channel '_reliable' closed unexpectedly", "level": "ERROR", "name": "livekit", "pid": 11326, "job_id": "AJ_a8uXggcKPamC", "room": "cg-voice-6d0grxxwno", "room_id": "RM_bHsWETGZc5cQ", "timestamp": "2026-10-27T15:41:30.437268+00:00"}
{"message": "livekit::rtc_engine::rtc_session:704:livekit::rtc_engine::rtc_session - publisher data channel '_lossy' closed unexpectedly", "level": "ERROR", "name": "livekit", "pid": 11326, "job_id": "AJ_a8uXggcKPamC", "room": "cg-voice-6d0grxxwno", "room_id": "RM_bHsWETGZc5cQ", "timestamp": "2026-10-27T15:41:30.437449+00:00"}
{"message": "livekit::rtc_engine::rtc_session:704:livekit::rtc_engine::rtc_session - publisher data channel '_data_track' closed unexpectedly", "level": "ERROR", "name": "livekit", "pid": 11326, "job_id": "AJ_a8uXggcKPamC", "room": "cg-voice-6d0grxxwno", "room_id": "RM_bHsWETGZc5cQ", "timestamp": "2026-10-27T15:41:30.437534+00:00"}
{"message": "received job request", "level": "INFO", "name": "livekit.agents", "job_id": "AJ_sfYen6Augi9u", "dispatch_id": "AD_UmNB9yQbmDEK", "room": "cg-voice-oleganioz7", "room_id": "RM_anqqGcUZrCbe", "agent_name": "cybergril-voice-agent", "resuming": false, "enable_recording": false, "pid": 8800, "timestamp": "2026-10-27T15:41:30.448381+00:00"}
{"message": "initializing process", "level": "INFO", "name": "livekit.agents", "pid": 13614, "timestamp": "2026-10-27T15:41:30.549122+00:00"}
{"message": "process initialized", "level": "INFO", "name": "livekit.agents", "pid": 13614, "elapsed_time": 0.12, "timestamp": "2026-10-27T15:41:30.671152+00:00"}
Nvidia Decoder is supported.
{"message": "RoomIO linked to participant", "level": "INFO", "name": "livekit.agents", "lk.pii.participant_identity": "user-uqfyuhnt", "lk.pii.room_name": "cg-voice-oleganioz7", "pid": 11830, "job_id": "AJ_sfYen6Augi9u", "room": "cg-voice-oleganioz7", "room_id": "RM_anqqGcUZrCbe", "timestamp": "2026-10-27T15:41:30.841632+00:00"}
{"message": "adaptive interruption is disabled by default in production mode", "level": "INFO", "name": "livekit.agents", "pid": 11830, "job_id": "AJ_sfYen6Augi9u", "room": "cg-voice-oleganioz7", "room_id": "RM_anqqGcUZrCbe", "timestamp": "2026-10-27T15:41:30.843597+00:00"}
{"message": "failed to emit event conversation_item_added\nTraceback (most recent call last):\n File \"/opt/voice-agent/agent/venv/lib/python3.12/site-packages/livekit/rtc/event_emitter.py\", line 70, in emit\n callback(*callback_args)\n File \"/opt/voice-agent-cybergril/cybergril/voice/worker.py\", line 193, in _on_item\n if ev.item.role == \"assistant\":\n ^^^^^^^^^^^^\n File \"/opt/voice-agent/agent/venv/lib/python3.12/site-packages/pydantic/main.py\", line 1105, in __getattr__\n raise AttributeError(f'{type(self).__name__!r} object has no attribute {item!r}')\nAttributeError: 'AgentHandoff' object has no attribute 'role'", "level": "ERROR", "name": "livekit", "exc_info": "Traceback (most recent call last):\n File \"/opt/voice-agent/agent/venv/lib/python3.12/site-packages/livekit/rtc/event_emitter.py\", line 70, in emit\n callback(*callback_args)\n File \"/opt/voice-agent-cybergril/cybergril/voice/worker.py\", line 193, in _on_item\n if ev.item.role == \"assistant\":\n ^^^^^^^^^^^^\n File \"/opt/voice-agent/agent/venv/lib/python3.12/site-packages/pydantic/main.py\", line 1105, in __getattr__\n raise AttributeError(f'{type(self).__name__!r} object has no attribute {item!r}')\nAttributeError: 'AgentHandoff' object has no attribute 'role'", "pid": 11830, "job_id": "AJ_sfYen6Augi9u", "room": "cg-voice-oleganioz7", "room_id": "RM_anqqGcUZrCbe", "timestamp": "2026-10-27T15:41:30.844442+00:00"}
[cybergril-worker] session started (api=http://127.0.0.1:8360, voice=female_soft, agent_name=cybergril-voice-agent)
[stt] utterance open, frame sr=24000
[stt] interim: 师
[stt->session] interim_transcript: 师
[stt] interim: 傅
[stt->session] interim_transcript: 傅
{"message": "process exiting", "level": "INFO", "name": "livekit.agents", "reason": "parent process shutdown", "pid": 11326, "job_id": "AJ_a8uXggcKPamC", "room": "cg-voice-6d0grxxwno", "room_id": "RM_bHsWETGZc5cQ", "timestamp": "2026-10-27T15:41:32.444706+00:00"}
[stt] interim: 哎这
[stt->session] interim_transcript: 哎这
[stt] interim: 取经
[stt->session] interim_transcript: 取经
[stt] interim: 路比
[stt->session] interim_transcript: 路比
[stt] interim: 高老
[stt->session] interim_transcript: 高老
[stt] interim: 庄种
[stt->session] interim_transcript: 庄种
[stt] interim: 地还
[stt->session] interim_transcript: 地还
[stt] interim: 苦俺
[stt->session] interim_transcript: 苦俺
[stt] interim: 老中
[stt->session] interim_transcript: 老中
[stt] interim: 饿的能
[stt->session] interim_transcript: 饿的能
[stt] interim: 吞下
[stt->session] interim_transcript: 吞下
[stt] interim: 石龙
[stt->session] interim_transcript: 石龙
[stt] interim: 素包
[stt->session] interim_transcript: 素包
[stt] interim: 子
[stt->session] interim_transcript: 子
[stt] interim: 猴哥
[stt->session] interim_transcript: 猴哥
[stt] interim: 你筋
[stt->session] interim_transcript: 你筋
[stt] interim: 斗云
[stt->session] interim_transcript: 斗云
[stt] interim: 快
[stt->session] interim_transcript: 快
[stt] interim: 要不
[stt->session] interim_transcript: 要不
[stt] interim: 先
[stt->session] interim_transcript: 先
[stt] interim: 去画个
[stt->session] interim_transcript: 去画个
[stt] FINAL(merged): 师傅,哎,这取经路比高,老庄种地还苦,俺老猪饿的能吞下石龙素包子,猴哥,你筋斗云块,要不先去化个缘
[stt->session] final_transcript: 师傅,哎,这取经路比高,老庄种地还苦,俺老猪饿的能吞下石龙素包子,猴哥,你筋斗云
[tts] first audio after 2.480s (师傅,...)
{"message": "livekit::rtc_engine::rtc_session:704:livekit::rtc_engine::rtc_session - publisher data channel '_reliable' closed unexpectedly", "level": "ERROR", "name": "livekit", "pid": 10801, "job_id": "AJ_HpxBoFkqfBdm", "room": "cg-voice-ymtapbx42a", "room_id": "RM_5BfQDxZuTsu4", "timestamp": "2026-10-27T15:42:07.442254+00:00"}
{"message": "livekit::rtc_engine::rtc_session:704:livekit::rtc_engine::rtc_session - publisher data channel '_lossy' closed unexpectedly", "level": "ERROR", "name": "livekit", "pid": 10801, "job_id": "AJ_HpxBoFkqfBdm", "room": "cg-voice-ymtapbx42a", "room_id": "RM_5BfQDxZuTsu4", "timestamp": "2026-10-27T15:42:07.442585+00:00"}
{"message": "livekit::rtc_engine::rtc_session:704:livekit::rtc_engine::rtc_session - publisher data channel '_data_track' closed unexpectedly", "level": "ERROR", "name": "livekit", "pid": 10801, "job_id": "AJ_HpxBoFkqfBdm", "room": "cg-voice-ymtapbx42a", "room_id": "RM_5BfQDxZuTsu4", "timestamp": "2026-10-27T15:42:07.442726+00:00"}
{"message": "process exiting", "level": "INFO", "name": "livekit.agents", "reason": "parent process shutdown", "pid": 10801, "job_id": "AJ_HpxBoFkqfBdm", "room": "cg-voice-ymtapbx42a", "room_id": "RM_5BfQDxZuTsu4", "timestamp": "2026-10-27T15:42:09.450806+00:00"}
[stt] interim: 俺老
[stt->session] interim_transcript: 俺老
[stt] interim: 孙来
[stt->session] interim_transcript: 孙来
[stt] interim: 也俺
[stt->session] interim_transcript: 也俺
[stt] interim: 乃五百
[stt->session] interim_transcript: 乃五百
[stt] interim: 年前
[stt->session] interim_transcript: 年前
[stt] interim: 大闹
[stt->session] interim_transcript: 大闹
[stt] interim: 天宫的
[stt->session] interim_transcript: 天宫的
[stt] interim: 齐天
[stt->session] interim_transcript: 齐天
[stt] interim: 霸圣
[stt->session] interim_transcript: 霸圣
[stt] interim: 孙悟
[stt->session] interim_transcript: 孙悟
[stt] interim: 空这
[stt->session] interim_transcript: 空这
[stt] interim: 里山
[stt->session] interim_transcript: 里山
[stt] interim: 势险
[stt->session] interim_transcript: 势险
[stt] interim: 峻
[stt->session] interim_transcript: 峻
[stt] interim: 只怕藏
[stt->session] interim_transcript: 只怕藏
[stt] interim: 有妖
[stt->session] interim_transcript: 有妖
[stt] interim: 精妖
[stt->session] interim_transcript: 精妖
[stt] interim: 怪
[stt->session] interim_transcript: 怪
[stt] interim: 哪里
[stt->session] interim_transcript: 哪里
[stt] interim: 逃
[stt->session] interim_transcript: 逃
[stt] interim: 吃完老
[stt->session] interim_transcript: 吃完老
[stt] interim: 我所
[stt->session] interim_transcript: 我所
[stt] interim: 一
[stt->session] interim_transcript: 一
[stt] FINAL(merged): 俺老孙来也,俺乃500年前大闹天宫的齐天大圣孙悟空,这里山势险峻,只怕藏有妖精妖怪,哪里逃吃?俺老孙吧
[stt->session] final_transcript: 俺老孙来也,俺乃500年前大闹天宫的齐天大圣孙悟空,这里山势险峻,只怕藏有妖精妖
[tts] first audio after 2.608s (好...)
[stt] interim: 没
[stt->session] interim_transcript: 没
[stt] interim: 错我
[stt->session] interim_transcript: 错我
[stt] interim: 就是个笨
[stt->session] interim_transcript: 就是个笨
[stt] interim: 蛋
[stt->session] interim_transcript: 蛋
[stt] interim: 派大
[stt->session] interim_transcript: 派大
[stt] interim: 星我们一
[stt->session] interim_transcript: 星我们一
[stt] interim: 起去抓
[stt->session] interim_transcript: 起去抓
[stt] interim: 水母
[stt->session] interim_transcript: 水母
[stt] interim: 吧我们
[stt->session] interim_transcript: 吧我们
[stt] interim: 一起度
[stt->session] interim_transcript: 一起度
[stt] interim: 过快乐的一
[stt->session] interim_transcript: 过快乐的一
[stt] FINAL(merged): 没错,我就是个笨蛋派大星,我们一起去抓水母吧,我们一起度过快乐的一天
[stt->session] final_transcript: 没错,我就是个笨蛋派大星,我们一起去抓水母吧,我们一起度过快乐的一天
[tts] first audio after 2.300s (好...)
[stt] interim: 你是不是
[stt->session] interim_transcript: 你是不是
[stt] interim: 早就想
[stt->session] interim_transcript: 早就想
[stt] interim: 睡我
[stt->session] interim_transcript: 睡我
[stt] interim: 了
[stt->session] interim_transcript: 了
[stt] FINAL(merged): 你是不是早就想睡我了呀
[stt->session] final_transcript: 你是不是早就想睡我了呀
{"message": "failed to generate LLM completion: chat stream ended without final or error (caused by ChatApiConnectionError: chat stream ended without final or error), retrying in 0.1s", "level": "WARNING", "name": "livekit.agents", "llm": "cybergril.voice.llm_chat_api.CyberGrillLLM", "attempt": 1, "pid": 11830, "job_id": "AJ_sfYen6Augi9u", "room": "cg-voice-oleganioz7", "room_id": "RM_anqqGcUZrCbe", "timestamp": "2026-10-27T15:46:34.451048+00:00"}
[tts] first audio after 3.847s (Considering the limi...)
[tts] first audio after 2.284s (</think>...)
{"message": "closing agent session due to participant disconnect (disable via `RoomInputOptions.close_on_disconnect=False`)", "level": "INFO", "name": "livekit.agents", "room": "cg-voice-oleganioz7", "participant": "user-uqfyuhnt", "reason": "CLIENT_INITIATED", "pid": 11830, "job_id": "AJ_sfYen6Augi9u", "room_id": "RM_anqqGcUZrCbe", "timestamp": "2026-10-27T15:46:46.019622+00:00"}
{"message": "process exiting", "level": "INFO", "name": "livekit.agents", "reason": "parent process shutdown", "pid": 11830, "job_id": "AJ_sfYen6Augi9u", "room": "cg-voice-oleganioz7", "room_id": "RM_anqqGcUZrCbe", "timestamp": "2026-10-27T15:47:06.441875+00:00"}
{"message": "livekit_ffi::server:279:livekit_ffi::server - Attempted to drop unknown FFI handle: 16", "level": "WARNING", "name": "livekit", "pid": 11830, "job_id": "AJ_sfYen6Augi9u", "room": "cg-voice-oleganioz7", "room_id": "RM_anqqGcUZrCbe", "timestamp": "2026-10-27T15:47:06.446226+00:00"}
{"message": "draining worker", "level": "INFO", "name": "livekit.agents", "id": "AW_LGViCVagSNNu", "timeout": 3600, "pid": 8800, "timestamp": "2026-10-27T15:58:10.576647+00:00"}
{"message": "shutting down worker", "level": "INFO", "name": "livekit.agents", "id": "AW_LGViCVagSNNu", "pid": 8800, "timestamp": "2026-10-27T15:58:10.577287+00:00"}
@@ -0,0 +1,60 @@
# HEL-879 · 统一候选一次复审收口(总工,2026-10-27)
> 范围:按总管 2026-10-27 交接,把 HEL-898 三项返修的隔离保全候选
> (`origin/hel-898-preserved` @ `18aef2a`:9254c17 = 作者 12 文件逐字节保全,
> 父 b0dcb17;18aef2a = 仅记录/证据)**正常快进纳入** `hel-879-assembly`,
> 对这三项做最后一次复审收口。不开新审核串、不重审已过后台/预算/备份/receipt
> 时机内核、不替原作者改视觉。零真实调用、零下载/安装/部署/合并,真实 cap 仍 0。
## 一、整合事实
- 快进前远端:`hel-879-assembly` @ `b0dcb17`(11fd0e2/9756db3/b0dcb17 材料成果
全保留,未退回 f92a86f)。快进后 head **`18aef2a`**,无冲突、无强推;
PR#11 与 `hel-870-assembly`(c121c42)未动。
- 9254c17 恰为 12 文件(`web/src` 6 + `web/tests` 5 + `handoff-HEL-898.json`),
与总管 preservation-review.md 记录的作者交付逐字节保全一致;18aef2a 仅新增
`docs/rework/hel898/f92a86f-rework/` 记录与证据(31 文件)。
- **b0dcb17→18aef2a 对 `src/`、`tests/`、`tools/`、`deploy/` 零 diff**
(实跑 `git diff --stat` 为空)——后端/协议/预算/备份/receipt 时机零改动成立。
## 二、三项返修复审(源码级 + 测试级,均通过)
1. **agent 离场僵尸界面/麦克风不停**(`web/src/voice/livekit.ts` +
`session.ts`):transport 按 identity 记录已见 agent(连接事件与初始
remoteParticipants 扫描都入 Set);`ParticipantDisconnected` 仅当离场者是
该 agent 且房内再无其它 agent 时触发 `agentLeft`;控制器走**既有** `fail()`
路径(mic 先停 → room 关闭 → 服务器 DELETE,离线欠账照常补发)。普通参与者
离场、旧房间迟到事件、自己挂断、重复第二 worker 均不误触发——
`web/tests/hel898_agent_left.test.ts` 7 例(含 DELETE 欠账重发与 stale 负例)。
2. **closed 64 挤出复活**(`session.ts`):退休 speech 集合由 64 上限环形数组
改为**每通话 `Set<string>` 全程保留、随代次整体释放**;seq 高水位门不变,
未以放宽乱序保护换修复。f92a86f 审核所记 DOCUMENTED GAP 就此关闭。
3. **播放中短回复被压到 final**(`reading.ts` + `lineStage.ts` +
`ChatPanel.tsx` + `voiceLine`):语音打开的她的行改 `live: 'speaking'`,
末块只显示**已成句**部分(`said()` 截到句末标点+引号括号),文字 generating
原持有规则不变;同键行 `grow()` 原位增长(保留 DOM/已演字,非续写才整段
置换),签名纳入语音行文本驱动重呈现;阅读时钟对增长行只增不减时长。
落库接替沿用同键(turn:mi:index),不重播、不重复演字。
## 三、独立检查(针对本次真实变更,均实跑)
- `web` vitest **118/118**(10 文件,含 hel898_agent_left 7 例、hel879_frames
10 例、voice 45 例);typecheck 0 错;lint 0 错/1 既有 fullButton ref 警告;
build 通过(CSS 哈希 `index-BpZaybfD.css` 与既认可视觉一致,JS 因本次修复
变更为 `index-C23OdPgW.js`,属预期)。
- 需求结构检查 3/3 通过:`--base-ref origin/main`、`--base-ref b0dcb17`、
`--base-ref b0dcb17 --handoff docs/requirements/handoff-HEL-898.json`;
本轮结束后再以 origin/main ± 本卡 handoff 复跑(见提交说明)。
- 浏览器统一回放(两主题电脑/手机、减少动态、worker 实际录制帧、8 段 WebM)
**不重复复跑**:总管已在 18aef2a 记录 53/53 与媒体证据
(manager-checks/browser.log),本轮零安装策略下不重开。
## 四、结论与边界
- 三项返修**离线通过**,与后台 v2 契约无冲突;统一候选当前准确 head
`18aef2a`(本轮复审与 D1 试听材料另追加提交,见分支历史)。
- 仍不冒充的部分:以上全部是假 LiveKit/假媒体的离线回放与单测;真实
WebRTC/麦克风/ASR/TTS、三轮/1 秒打断停声停麦、可信 HTTPS+WSS 设备路径、
iOS/Android、API 重启真实释放**均未测**,待主卡批准后 R5 真机窗口执行。
完整 CG-011 会话中主动表达、完整 mem0 统一体验/R6/R7 继续承接。
- v1.11/60 项无变更;冻结账/权威账未写;既有五实例与共享服务零接触。
@@ -122,9 +122,11 @@ GPU RTX 5090D 空闲 30520MiB(ComfyUI 仍处已授权暂停);内存 94GB/
- **已证**:8090 面板**没有既有音色试听播放**(只有列表/切换/克隆上传/删除),
且切换/上传写**共享** `voices.json`/`voice_state.json`、影响通用演示——v1
「试听入口=8090 面板」勘误删除,未经授权不用它给本用户做试听。
- **拟议试听方案(零合成、零 GPU、零下载)**:部署时把 11 段参考 wav 以只读静态
目录提供(随 8361 静态路径或离线打包随材料交付),白栖知听参考音频初选;
音色效果以参考音频为准 + 获批测试窗口内真实合成复核(计入 §6 TTS 计数)。
- **试听已交付(2026-10-27,零合成、零 GPU、零下载)**:`HEL-879-D1-参考试听包.zip`
随 HEL-879 评论附件交付——自包含离线 `index.html` + 11 段参考 wav + `manifest.json`
(逐文件 sha256 与服务器一致;来源与边界见 `docs/rework/hel879/d1-audition/`)。
白栖知解压后用浏览器直接打开试听初选;音色效果以参考音频为初感 + 获批测试窗口内
真实合成复核(计入 §6 TTS 计数)。参考录音原文按来源原样保留,不预筛。
- 可选:克隆(录 5–20s 中文参考)需写共享 `voices.json`(新增条目为增量,但会触
共享 TTS 热加载)→ **需单独授权**,默认不启。
- worker 缺省回落 `default`(代码已证);专属 worker 用独立
@@ -184,11 +186,17 @@ TTS 分段、φ 播报、打断重合成、主动 say、闲置通话都**不等
## 8. 真正待白栖知拍板的项(工程项已移出,见 §10)
1. D1 音色(§4 参考试听后选,或另行授权克隆)。
1. D1 音色:听 `HEL-879-D1-参考试听包.zip` 后选 1 支(或另行授权克隆)。
2. 有限真实用量与新增费用上限确认/调整(§6 各值;R5 专项嵌入 0)。
3. 测试窗口与共享 TTS 重启授权(占 GPU ~5.6GB;ComfyUI 保持暂停到测试后与否)。
4. 1b 完整统一体验的当前 37 项待准备依赖下载授权(目标 cp311 解析后核定实际
产物清单,可与 R5 专项分开批;已有成品/缓存不重复下载)。
产物清单,可与 R5 专项分开批;已有成品/缓存不重复下载;hatchling 已解决
不再申请)。
> 集中请求口径(总管在主卡一次发出):试听包 + §6 逐类上限/费用 + §5 部署与
> 回滚 + §1b 下载授权,四项一次说清,批准后按 §5 顺序执行;R5 语音专项
> (stub、嵌入 0)与完整统一体验分开批、分开验,ASR/TTS/建房只有操作停机
> 无代码硬闸,均如实表述。
## 9. 本包不做
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。不部署、不合并、不替换正式界面。
+55
View File
@@ -0,0 +1,55 @@
# HEL-902 补测包:自定义声音真机窗口申请材料(交总管集中请批)
状态:**材料备齐,未获批前真实 cap=0**。所有上限为**新的一次性窗口授权**,不复用、不重开 HEL-879 已封账(其 14/40 请求、42717/500000 tokens、12/200 TTS 段保持封存;剩余数≠新许可)。工程参数由工程定,用户只批准真实用量与设备操作。
## 1. 统一 head 与部署范围
- 候选整合分支 `hel-902-integration` @ `d167ba9`(hel-900-preserved 701442d + hel-879-assembly be149cd,无冲突)。离线证据见 `docs/rework/hel902/REVIEW.md`:后端 376/376、全链 12/12、ruff/需求检查 4/4。
- 部署形态沿用 HEL-877/879 runbook:隔离双入口 8360/8361 + 8363 WSS 桥 + 专属 worker 8082 + 共享 TTS 9880(窗口内临时恢复,窗口后停回);`config/voice.yaml` 按合同 §4 增补。**不触碰** 8300–8350 演示/真实档案、8090(只读调用其 API)、8081、7880、旧资料与冻结账、4080;ComfyUI 保持暂停;不合 PR、不替换正式界面。
- 回滚:不部署即无痕;已部署时 git 还原 + 停窗口进程 + 移除 voice.yaml 增补即回 HEL-877 行为;资料空间三个新文件留存无害。
## 2. 测试目标与逐步动作(全部可观察、可中止)
1. **真实登记**:浏览器(桌面 Chrome/Edge,LAN 机)上传一段 5–20 秒 WAV 参考音频 → `cg-` 登记 → 列表出现。
2. **可选 ASR**(≤3 次):留空参考文字、显式开转写开关 → FunASR 转写回显;另做一次「不开开关且无文字→422 拒绝」负例。
3. **真实试听**(≤10 次合成):现场合成 WAV 浏览器播放;负例:TTS 停机→503。
4. **应用与下一通验证**(≤3 次通话):试听后应用 → 开通话核对 worker 实际音色=会话响应 `voice` 字段;通话中改选→本通话不换声、下一通生效;从未选择→不代写 worker 文件。
5. **HEL-879 遗留量测**(并入同一窗口,不另开窗):
- 打断后约 1 秒停声:真实通话开口打断,以 worker 日志 `turn_cancel` 时间戳与最后 TTS 段发送时间差为准(毫秒级,附日志行);辅以浏览器侧听到停声即停表的人工秒表复核。
- 文字打断语音方向:她语音输出中发送文字→声即停、文字被处理;与既有「语音取消文字」方向互补。
- 真实浏览器/设备麦克风路径:LAN 机真实麦克风(非 fake device)完成第 4 项;如用户愿意,iPhone Safari 经 8363 WSS + 证书信任步骤试一连(可选,失败不影响其余结论)。
6. **窗口内快检**(可选,若获批一次性依赖安装授权):本机 `npm ci`(同锁文件)→ 复跑 vitest 137/tsc/eslint/build + `hel900_mock.mjs`+`hel900_shots.mjs`(Chromium headless,两主题×电脑/手机 + 演示入口零外发断言)。不获批则此项留待后续。
## 3. 参考音频来源与共享注册表新增/回收
- 来源:优先用总管自备的一段真实人声 WAV(用户已授权「音色你自己决定」适用窗口测试音色);或复用 D1 试听包中已只读取回的既有参考录音之一作克隆源(不新增采集、不新录用户声音、不申请摄像头)。
- 共享 `voices.json` 新增:仅 `cg-` 命名空间新条目(服务端生成,结构上不可能碰既有 11 支;面板仍是唯一写者)。**回收**:窗口结束按登记清单逐条经 8090 面板自有删除入口删除并截图/列表复核;保留一条经用户后续确认去留。元数据/流水在实例资料空间,随实例清理。
- 全局默认音色、通用助手 `voice_state.json`/`persona_state.json` 不触碰(测试断言已含)。
## 4. 时窗与逐类上限(一次性,超出即停)
| 类别 | 上限 | 计数口径 |
| --- | --- | --- |
| 时窗 | ≤2 小时(建议 1 小时) | 首个真实请求起算 |
| 语音通话 | ≤3 次 | POST /api/voice/session 成功开房 |
| 开房(含空会话) | ≤6 次 | 同上,含失败重试 |
| LLM 请求 | ≤20 | 统一账本 acquire(失败/打断/后台都计) |
| LLM tokens | ≤60000 | 同上 |
| 嵌入 | 0 | 硬闸 |
| 登记转写 ASR | ≤3 | `voice-usage.jsonl` register_asr 行 |
| 试听合成 | ≤10 | preview_tts 行 |
| 通话 TTS 段 | ≤40 | worker 流水 |
| 音色登记 | ≤3 | 8090 upload 成功数 |
停机方法:任一上限达到即停(LLM/嵌入为代码硬闸,其余为工程守则:实时盯 `voice-usage.jsonl` 与统一账本页面);窗口结束停 8360/8361/8082/8363/9880(9880 停回原状态),`ss -ltn` 复核端口、房间经 API 挂断或 TTL 清理,账本与流水快照存档到 `docs/rework/hel902/`,孤儿 cg- 条目按 §3 回收。
## 5. 旧服务与资料保护清单(窗口内不变式)
8300/8310 演示与真实档案进程不动;8090 只读调用+授权后的 cg- 写入;7880/8081 不碰;既有数据库、身份、聊天、记忆不重置;测试写入只落隔离实例数据空间;每日自动备份任务(如有)不中断;不动队员模型/运行时/认证;不申请摄像头。
## 6. 通过判据
- 登记→(可选转写)→试听→应用→下一通话用新声音,全链真实完成且日志对得上;通话中改选不中途换声。
- 打断停声时延有毫秒级日志证据;文字打断语音方向有观察记录。
- 共享注册表前后 diff 仅 `cg-` 新增且回收完成;11 支内置与全局默认零变化(前后快照比对)。
- 全部上限内完成;结束后无残留窗口进程/端口/房间。
+205
View File
@@ -0,0 +1,205 @@
# 自定义音色契约与部署说明(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`
"登记结果没有出现在音色列表里,请重试。"(不留下半条可选项)。
**建议录音格式**(前端 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"`)。
## 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`:原子写且**保留文件里其他键**;
用户从未经 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 最终音色决定:仍归白栖知/总管(本轮测试音色已授权总管自定)。
+40 -5
View File
@@ -143,11 +143,46 @@ assert mine_claims["iss"] == sdk_claims["iss"] and mine_claims["sub"] == sdk_cla
assert set(mine_claims) == set(sdk_claims), (sorted(mine_claims), sorted(sdk_claims))
ok.append("mint_token claims byte-parity with livekit.api.AccessToken (video/iss/sub/keys)")
# 6. server token decodes as the SDK Service._auth_header shape
server_tok = wire.mint_server_token("devkey", "s3cret", ttl_seconds=60, now=1700000000.0)
sc = pyjwt.decode(server_tok, "s3cret", algorithms=["HS256"], options={"verify_exp": False})
assert sc["iss"] == "devkey" and sc["sub"] == "" and "video" not in sc
ok.append("mint_server_token matches SDK no-grant auth header claims")
# 6. Exercise BOTH real wire helpers: endpoint-specific grants must match the SDK,
# and Twirp JSON must round-trip (a generic grantless token was insufficient on
# the actual server; HEL-879's first real window exposed that missing coverage).
async def server_endpoint_parity():
seen = []
def handler(request):
assert request.headers["content-type"] == "application/json"
body = json.loads(request.content)
mine = pyjwt.decode(request.headers["authorization"].removeprefix("Bearer "),
"s3cret", algorithms=["HS256"], options={"verify_exp": False})
if request.url.path.endswith("CreateDispatch"):
assert body == {"agentName": "offline-agent", "room": "offline-room", "metadata": "offline"}
grants = api.VideoGrants(room_admin=True, room="offline-room")
response = httpx.Response(200, json={"id": "AD_offline", "room": "offline-room"})
else:
assert request.url.path.endswith("DeleteRoom") and body == {"room": "offline-room"}
grants = api.VideoGrants(room_create=True)
response = httpx.Response(200, content=b"")
sdk = pyjwt.decode(api.AccessToken("devkey", "s3cret").with_grants(grants).to_jwt(),
"s3cret", algorithms=["HS256"], options={"verify_exp": False})
# The SDK includes its default media permissions even for a service
# token. They do not authorize these endpoints; compare the required
# service grants without adding unused media privileges to our token.
media_defaults = {"canPublish", "canSubscribe", "canPublishData"}
assert all(sdk["video"][k] is True for k in media_defaults)
required = {k: v for k, v in sdk["video"].items() if k not in media_defaults}
assert mine["video"] == required, (mine["video"], required)
assert mine["iss"] == sdk["iss"] and mine["sub"] == sdk["sub"]
seen.append(request.url.path.rsplit("/", 1)[-1])
return response
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
dispatch = await wire.create_dispatch(client, internal_url="http://offline.test",
api_key="devkey", api_secret="s3cret", agent_name="offline-agent",
room="offline-room", metadata="offline")
assert dispatch.dispatch_id == "AD_offline"
assert await wire.delete_room(client, internal_url="http://offline.test",
api_key="devkey", api_secret="s3cret", room="offline-room")
assert seen == ["CreateDispatch", "DeleteRoom"]
asyncio.run(server_endpoint_parity())
ok.append("actual CreateDispatch/DeleteRoom helpers: Twirp JSON and required SDK endpoint authorization grants match")
# 7. SpeechHandle/worker API surface used by worker.py exists on the real SDK
from livekit.agents.voice.speech_handle import SpeechHandle
+10 -2
View File
@@ -12,6 +12,7 @@ from __future__ import annotations
import asyncio
import contextlib
import json
import socket
import sys
import tempfile
@@ -48,11 +49,18 @@ class FakeLiveKit:
if self.fail:
return httpx.Response(500)
if request.url.path.endswith("CreateDispatch"):
agent, room = _dispatch_fields(request.content)
body = json.loads(request.content)
agent, room = body["agentName"], body["room"]
assert request.headers["content-type"] == "application/json"
claims, _ = livekit_wire.decode_token(request.headers["authorization"].removeprefix("Bearer "))
assert claims["video"] == {"roomAdmin": True, "room": room}
self.dispatches.append({"agent_name": agent, "room": room})
return httpx.Response(200, json={"id": f"AD_{len(self.dispatches)}", "room": room})
if request.url.path.endswith("DeleteRoom"):
self.deleted.append(_dispatch_fields(request.content)[0])
assert request.headers["content-type"] == "application/json"
claims, _ = livekit_wire.decode_token(request.headers["authorization"].removeprefix("Bearer "))
assert claims["video"] == {"roomCreate": True}
self.deleted.append(json.loads(request.content)["room"])
return httpx.Response(200)
return httpx.Response(404)
+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
+423
View File
@@ -0,0 +1,423 @@
"""参考音频自定义声音与专属音色选择(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 在会话入口读一次——通话中改选最迟下一通话生效,绝不半句换人。
命名空间:新增音色一律 ``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):
"""面板拒绝或返回异常——登记未成立,不留下半条可选项。"""
@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._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 失败即删,见其源码)。
"""
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),
}
self._custom[voice_id] = record
self._persist_custom()
if record["prompt_text_source"] == "asr":
self._journal("register_asr", voice_id) # 显式获准的 FunASR 转写,可核算
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 音色状态(保留文件里其他键);失败抛 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)
async def apply_selection(self, *, voice: str) -> VoiceSelection:
"""把她后续通话的发声换成指定音色(最迟下一通话生效).
自定义(cg-)音色必须先成功试听过(「应用前有可听试听」);内置
音色是既有 D1 候选,不强制。写 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)
try:
self._selection = VoiceSelection(
voice=voice,
label=(record["label"] if record else voice),
updated_at=int(self.clock() * 1000))
self._persist_selection()
self.write_worker_voice(voice)
except OSError as exc:
self._selection = previous
with contextlib.suppress(OSError):
self._persist_selection() # 回滚本地状态
if 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()
+29 -13
View File
@@ -82,12 +82,26 @@ def mint_token(
def mint_server_token(api_key: str, api_secret: str, ttl_seconds: float = 60.0,
now: float | None = None) -> str:
"""Server-API bearer token; matches Service._auth_header() with no grants."""
now: float | None = None, *,
video: dict[str, Any] | None = None) -> str:
"""Server-API bearer token; claims mirror Service._auth_header().
``video`` grants are required per endpoint exactly like the installed SDK
signs them (R5 real-machine finding 2026-10-27: a grantless bearer is
rejected 401 by CreateDispatch — the SDK sends VideoGrants(room_admin=True,
room=...) there and VideoGrants(room_create=True) for DeleteRoom).
"""
now = time.time() if now is None else now
video_claims: dict[str, Any] = {}
if video:
video_claims = {_camel(k): v for k, v in video.items() if v is not None and v != ""}
claims: dict[str, Any] = {"iss": api_key, "sub": "",
"nbf": int(now), "exp": int(now + ttl_seconds)}
if video_claims:
claims["video"] = video_claims
return _jwt_hs256(
api_secret,
{"iss": api_key, "sub": "", "nbf": int(now), "exp": int(now + ttl_seconds)},
claims,
)
@@ -125,18 +139,20 @@ async def create_dispatch(
) -> DispatchResult:
"""Twirp AgentDispatchService/CreateDispatch with an explicit agent_name.
Body is livekit.CreateAgentDispatchRequest: field 1 agent_name, field 2 room,
field 3 metadata (field numbers verified against the installed protocol pkg).
Sent as Twirp JSON (content-type application/json): the server answers
JSON, so the dispatch id parses without a protobuf dependency (R5
real-machine finding: protobuf content-type returns a binary body the
JSON parser cannot read).
"""
body = _proto_string(1, agent_name) + _proto_string(2, room)
payload: dict[str, Any] = {"agentName": agent_name, "room": room}
if metadata:
body += _proto_string(3, metadata)
payload["metadata"] = metadata
response = await http.post(
f"{internal_url.rstrip('/')}{TWIRP_PREFIX}/{DISPATCH_SERVICE}/CreateDispatch",
content=body,
json=payload,
headers={
"authorization": f"Bearer {mint_server_token(api_key, api_secret)}",
"content-type": "application/protobuf",
# SDK parity: VideoGrants(room_admin=True, room=...)
"authorization": f"Bearer {mint_server_token(api_key, api_secret, video={'room_admin': True, 'room': room})}",
},
timeout=timeout,
)
@@ -184,10 +200,10 @@ async def delete_room(
"""
response = await http.post(
f"{internal_url.rstrip('/')}{TWIRP_PREFIX}/{ROOM_SERVICE}/DeleteRoom",
content=_proto_string(1, room),
json={"room": room},
headers={
"authorization": f"Bearer {mint_server_token(api_key, api_secret)}",
"content-type": "application/protobuf",
# SDK parity: VideoGrants(room_create=True)
"authorization": f"Bearer {mint_server_token(api_key, api_secret, video={'room_create': True})}",
},
timeout=timeout,
)
+164 -3
View File
@@ -4,7 +4,12 @@ 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.
DELETE /api/voice/session/{id} end: delete the LiveKit room, which
disconnects browser and worker, ends the
agent job and releases the microphone.
@@ -15,6 +20,27 @@ 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).
capabilities.voice in /api/health stays false: R5 is not real-accepted yet and
this endpoint never claims end-to-end voice availability.
"""
@@ -22,19 +48,52 @@ this endpoint never claims end-to-end voice availability.
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import Response
from .custom import (
CustomVoiceService,
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 域不代定)。"""
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
return captured
current = custom.read_worker_voice()
return current if valid(current) else "default"
return ""
def build_voice_router(service: VoiceSessionService | None,
custom: CustomVoiceService | None = None) -> APIRouter:
router = APIRouter(prefix="/api/voice", tags=["voice"])
@router.get("/status")
@@ -59,8 +118,9 @@ 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 Exception:
raise HTTPException(503, REFUSAL_LIVEKIT_DOWN) from None
return {
@@ -71,6 +131,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 +162,104 @@ 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 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
+35 -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
@@ -282,11 +299,25 @@ 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 _prune(self) -> None:
"""Expired registrations move into the cleanup ledger — never silently
@@ -387,7 +418,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 +442,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()
+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
+179
View File
@@ -0,0 +1,179 @@
"""HEL-902 统一审核:后台重点故障注入补核(总工独立新增).
针对总管列出的统一审核重点,用可控假服务注入故障,零真实调用:
1. 8090 已登记、CyberGril 本地元数据落盘失败 —— 是否留下无法恢复或重复注册的半状态。
2. 登记上传成功后面板响应断连(结果已生效、响应丢失)+ 用户重试 —— 共享注册表里出现什么。
3. 通话开启时 worker 音色状态写不进 —— 诚实拒绝且不开房。
4. worker 状态文件可读时其他键保留;文件损坏(读不出 JSON)时其他键是否被丢弃。
5. 试听流水写不进 —— 「应用前有可听试听」凭据缺失时应用是否守门(fail-closed)。
6. 选择状态落盘失败 —— 整体回滚,worker 文件不被写。
时间与真实性边界:worker 实际在会话入口读音色文件、与真实 worker 的时序一致性
属真机窗口验证范围(见 docs/rework/hel902/),本文件只锁 API 侧可离线证明的事实。
"""
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 登记成功、本地元数据写不进:500(未映射合同错误);状态仍可恢复、不逼重复注册."""
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 == 500 # OSError 未被映射为 503/422(产品语义)
assert len(stack.uploads) == 1
voice_id = stack.uploads[0]["name"]
# 面板已登记;进程内记录仍在:本进程里看得到、可试听可应用,不是死条目
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
# 「重启」后元数据确实丢失(无 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() # 元数据丢了,声音没丢
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
+1 -1
View File
@@ -177,7 +177,7 @@ class StandinLiveKit:
if isinstance(error, int):
return httpx.Response(error, text="room service error")
raise error
self.deleted.append(_proto_field1(request.content))
self.deleted.append(json.loads(request.content or b"{}").get("room", ""))
return httpx.Response(200, content=b"")
return httpx.Response(404, text="unknown")
+8 -6
View File
@@ -44,16 +44,18 @@ class FakeLiveKit:
return httpx.Response(500, text="livekit down")
path = request.url.path
if path == "/twirp/livekit.AgentDispatchService/CreateDispatch":
body = request.content
agent_name, room = _parse_dispatch(body)
body = json.loads(request.content or b"{}")
agent_name = body.get("agentName", "")
room = body.get("room", "")
self._dispatch_seq += 1
self.dispatches.append({"agent_name": agent_name, "room": room,
"proto": True, "content_type":
"json": True, "content_type":
request.headers.get("content-type", "")})
return httpx.Response(200, json={"id": f"AD_{self._dispatch_seq}",
"agentName": agent_name, "room": room})
if path == "/twirp/livekit.RoomService/DeleteRoom":
self.deleted_rooms.append(_parse_room(request.content))
body = json.loads(request.content or b"{}")
self.deleted_rooms.append(body.get("room", ""))
return httpx.Response(200, content=b"")
return httpx.Response(404, text="unknown twirp route")
@@ -174,8 +176,8 @@ def test_open_session_dispatches_dedicated_worker(instance):
# dedicated dispatch, not the generic cascade-voice-agent
assert livekit.dispatches[-1]["agent_name"] == "cybergril-voice-agent"
assert livekit.dispatches[-1]["room"] == body["room"]
assert livekit.dispatches[-1]["proto"] is True
assert livekit.dispatches[-1]["content_type"] == "application/protobuf"
assert livekit.dispatches[-1]["json"] is True
assert livekit.dispatches[-1]["content_type"] == "application/json"
assert body["url"] == "ws://voice.test:7880"
assert body["agent_name"] == "cybergril-voice-agent"
# no livekit key material leaks into the browser payload
+10 -5
View File
@@ -4,12 +4,13 @@ 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';
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: '本机生效' };
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 +33,12 @@ 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>();
// 她的声音 (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();
@@ -168,7 +171,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,6 +181,8 @@ 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></>}
+221
View File
@@ -0,0 +1,221 @@
// ── 她的声音 (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, 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);
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');
}, 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;
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(); 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;
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.
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>{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"><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}/>
<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>}
{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>;
}
+44
View File
@@ -990,4 +990,48 @@ 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%}
}
+249
View File
@@ -0,0 +1,249 @@
// ── 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: '这次录音没有成功,已经丢弃,可以再录一次。',
} as const;
export class VoiceError extends Error { constructor(message: string, readonly status = 0) { 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); }
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;
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 });
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);
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 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;
}
}
+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);