Compare commits

..
34 changed files with 1719 additions and 1452 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
# 会话登记有效期(秒):房间早空/挂断后登记自然过期
+4 -3
View File
@@ -1,6 +1,6 @@
{
"schema_version": 1,
"version": "1.11",
"version": "1.12",
"approval_comment_id": "01a1734c-1159-7a44-a81f-3105f1b8d98d",
"owner_id": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
"scope": "完整要求;v1.11在v1.10的59项上新增成熟方案优先复用执行约束,原产品与验收边界保留;不代表已选定底座、授权迁移或功能通过。",
@@ -118,10 +118,11 @@
"category": "产品",
"topic": "连续语音与文字",
"conclusion": "用户主动开启语音后自然连续交谈,支持开口打断她;结束语音后停止收音,同时保留文字聊天。",
"boundary": "响应速度、回声消除和打断效果需用现有语音服务实测,未承诺固定毫秒数。",
"boundary": "响应速度、回声消除和打断效果需用现有语音服务实测,未承诺固定毫秒数。音色入口以可自定义音色为准:在网页上传或录制参考音频→试听→应用到同一位她的后续通话,直接复用已装TTS的零样本克隆;不以仅选预设音色替代,先前参考包试听方案不采用。本轮通话测试授权总管自定音色、最长2小时/3次通话;自定义音色交付不等于用户已验收音色,也不改变换声音只换发声、不重建伴侣、不改出生底色/名字/关系/聊天与记忆的边界。",
"source_comment_ids": [
"01a15750-2cf9-7f23-9269-0f150d2c5e61",
"01a15750-e061-733b-9d80-38459d306b97"
"01a15750-e061-733b-9d80-38459d306b97",
"01a17de1-2892-78be-a422-eb6a11ffebfd"
]
},
{
+10
View File
@@ -108,6 +108,16 @@
"GOV-006"
],
"impact": "原59项保持;不预设保留自研底座,也不指定任何最终开源项目;未授权产品迁移、依赖安装、现网部署或改变资料/隐私边界。"
},
{
"version": "1.12",
"approval_comment_id": "01a17de1-2892-78be-a422-eb6a11ffebfd",
"approved_by": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
"summary": "CG-010音色边界更新:以可自定义音色为准(网页上传/录制参考音频→试听→应用),直接复用已装TTS零样本克隆;不以仅选预设替代,参考包试听方案不采用;本轮通话测试授权总管自定音色、最长2小时/3次通话。",
"affected_ids": [
"CG-010"
],
"impact": "只改音色入口与测试授权;换声音只换发声、不重建伴侣、不改底色/名字/关系/聊天/记忆的既有边界不变;交付与阶段通过不等于用户已验收音色;不新增付费服务、不改变部署与资料边界。"
}
]
}
+10
View File
@@ -39,3 +39,13 @@ v1.7:把原始31项、已确认视觉和后续用户裁定整理到同一入
> 记住我的原则,能借鉴成熟方案的就直接借鉴,从零创造的难度太大需要的是一个专业的团队,不适用于我这个不专业的人加上没有决策性的AI的组合;另外我之前让你看的我跟另外智能体的交流内容希望你提取值得借鉴的地方
旧记录未明确规定通用内核必须优先复用成熟实现;现新增GOV-006,减少从零研发和长期自行维护,不因沉没工作量而默认保留自研内核。原59项不删减。交流材料提炼为候选机制,具体技术栈、底座、情绪映射及迁移范围未获批;不将本次文档记录当功能通过或现网施工授权。
## v1.12:音色入口定为可自定义音色
来源评论:`01a17de1-2892-78be-a422-eb6a11ffebfd`(2026-10-27 15:01:10 UTC,回应总管阻塞通报 `01a17d45-f14e-7b62-9850-646e040c7318`)。白栖知原话:
> .12已恢复,另外以上一条让我解压附件包试听,我不明白是什么意思,是让我听预设音色吗?如果是我觉得你的设计不够合理,我想要的是可以自定义音色,我的.12上安装的TTS是支持自定义音色的。至于最长2小时,3次通话的测试,我同意,音色你自己决定就行
旧边界(CG-010/D1 路线):音色按"先在现有中文女声里挑、都不满意再做零样本克隆"的条件路线推进,本轮以参考包编号试听为交付形式。新要求:**音色入口以可自定义音色为准**——在网页上传或录制参考音频→试听→应用到同一位她的后续通话,直接复用已装TTS的零样本克隆;不以仅选预设音色替代,参考包试听方案不采用。另明确:本轮通话测试授权总管自定音色、最长2小时/3次通话(操作授权,记 HEL-879 测试执行,不进需求边界)。
影响范围:仅 CG-010 音色入口与测试授权;换声音只换发声、不重建伴侣、不改出生底色/名字/关系/聊天/记忆的既有边界不变;CG-010 其余条文与原59项不动。工程交付或测试通过不等于用户已验收音色,也不得把"用户已授权自定义音色"改写成"用户已选定某支音色"。原话记录见 `sources/comment-evidence-custom-voice-20261027.json`(施工员2号按平台原话逐字核对录入)。
+1 -1
View File
@@ -1,5 +1,5 @@
{
"baseline_version": "1.11",
"baseline_version": "1.12",
"items": [
{
"requirement_id": "CG-001",
+5 -3
View File
@@ -1,9 +1,11 @@
# CyberGril 完整当前需求 · v1.11
# CyberGril 完整当前需求 · v1.12
完整要求;版本1.10在v1.9(交流静图认可)之上,把白栖知已体验的对坐动态小样 v2 整体效果(演示层面)记入 VIS-011 与 CG-004 边界;不证明其余页面、真实功能或完整产品已验收。
v1.11新增GOV-006成熟方案优先复用;原59项与v1.10阶段验收边界保持。
v1.12按白栖知2026-10-27原话(`01a17de1-2892-78be-a422-eb6a11ffebfd`)把CG-010音色边界更新为可自定义音色:网页上传/录制参考音频→试听→应用到同一位她的后续通话,直接复用已装TTS零样本克隆,不以仅选预设替代;先前参考包试听方案不采用。本轮通话测试授权总管自定音色、最长2小时/3次通话。该项交付不等于用户已验收音色。
## 阅读与效力
用户明确的新决定覆盖旧决定并记录来源;完整当前记录优先于任务摘录、技术方案和旧仓库 SPEC。无明确用户裁定的冲突先查来源,不擅自取舍。
@@ -84,9 +86,9 @@ v1.11新增GOV-006成熟方案优先复用;原59项与v1.10阶段验收边界
用户主动开启语音后自然连续交谈,支持开口打断她;结束语音后停止收音,同时保留文字聊天。
边界:响应速度、回声消除和打断效果需用现有语音服务实测,未承诺固定毫秒数。
边界:响应速度、回声消除和打断效果需用现有语音服务实测,未承诺固定毫秒数。音色入口以可自定义音色为准:在网页上传或录制参考音频→试听→应用到同一位她的后续通话,直接复用已装TTS的零样本克隆;不以仅选预设音色替代,先前参考包试听方案不采用。本轮通话测试授权总管自定音色、最长2小时/3次通话;自定义音色交付不等于用户已验收音色,也不改变换声音只换发声、不重建伴侣、不改出生底色/名字/关系/聊天与记忆的边界。
来源评论:`01a15750-2cf9-7f23-9269-0f150d2c5e61`、`01a15750-e061-733b-9d80-38459d306b97`。
来源评论:`01a15750-2cf9-7f23-9269-0f150d2c5e61`、`01a15750-e061-733b-9d80-38459d306b97`、`01a17de1-2892-78be-a422-eb6a11ffebfd`。
## CG-011 · 主动联系范围
+57
View File
@@ -0,0 +1,57 @@
{
"baseline_version": "1.12",
"read_commit": "8dc84f88e9d5e961e17b246f24c23eaf526b012e",
"task": "HEL-899:把现有 CosyVoice 参考音频零样本克隆能力接入 CyberGril 统一 API(网页上传/录制参考音频→登记 cg- 专属 ID→试听现合成→应用到同一位她的后续通话);直接复用 .12 语音面板 8090 的 zero-shot 注册与 CosyVoice 热加载、9880 /tts 合成,不训练新模型、不重造克隆引擎、不自建第二写者;本卡只做后台/协议与零真实调用自测,前端按合同接视觉总监,真实试听放统一审核",
"requirement_ids": [
"CG-006",
"CG-009",
"CG-010",
"CG-013",
"CG-014",
"CG-015",
"CG-016",
"CG-018",
"CG-019",
"CG-020",
"CG-021",
"AM-001",
"GOV-001",
"GOV-002",
"GOV-003",
"GOV-004",
"GOV-005",
"GOV-006",
"DEL-001"
],
"deliverable": "src/cybergril/voice/custom.py(CustomVoiceService:8090 面板代理=共享 voices.json 唯一写者、cg-+8hex 服务端生成 ID 不撞既有 11 支、WAV 头服务端复验 RIFF/WAVE≤15MB≥2s、显式 allow_asr 才走 FunASR 转写、试听先验存在拦住 CosyVoice 未知音色静默回落 default、原子落盘+失败整体回滚、选择/元数据/资源流水持久在实例资料空间重启读回、备份清单 manifest);router.py 五个新端点(GET/POST /api/voice/voices、POST /api/voice/preview、GET/POST /api/voice/selection)+ /api/voice/session 响应新增 voice=本次通话捕获音色(选择失效诚实回落 default;未选择不代写 worker 文件不替 D1 定音色);session.py 会话记录 voice 字段+/health 名单读回;worker 语义不变(入口读一次→通话中改选最迟下一通话生效、绝不半句换人);契约文档 docs/voice-custom-contract-HEL899.md(前端可直接实现的请求/响应/错误/录音格式/部署与回滚);config/voice.example.yaml 增补(panel_url/tts_url/registry_file/worker_state_file,env 可覆盖);零新依赖(登记端点原始字节体+查询串,特意不引 multipart 库);tests/test_voice_custom.py 18 项 + scripts/verify_hel899_custom_voice.py 12/12;治理:v1.12(CG-010 边界更新,来源评论 01a17de1-2892-78be-a422-eb6a11ffebfd 逐字录入 sources/comment-evidence-custom-voice-20261027.json + manifest/baseline/coverage/changes 同步)",
"acceptance": [
"完整最小合同成立:列出可用声音(builtin 11 支+custom 标记、选择/试听状态、备份清单字段)、登记参考音频(服务端生成 cg- 专属 ID,不可能撞名覆盖既有 11 支,撞名重试不覆盖)、试听生成(每次现合成 WAV 24k/mono、无缓存不拿旧音频冒充)、读取/应用专属音色(换声音只换发声)全部经统一 API,浏览器不直连 8090/9880,凭据不下发",
"当前 session/chat 业务不分叉:worker/统一聊天链零改动;通话开启捕获有效音色并写入会话记录与 worker 状态文件,通话中改选不影响本通话、最迟下一通话生效(测试断言会话 voice 定住)、不打断或误认原轮次/receipt(receipt 链路未动)",
"原子与失败诚实:登记失败(面板 400/5xx/断连/谎报成功)不留下半条可选项、无元数据残留;应用失败整体回滚(选择状态与 worker 文件都回旧值);服务不可用一律 503 中文文案;ASR 仅在 allow_asr=true 显式准许且参考文字为空时发生(面板 FunASR,非 LLM),每次记 register_asr/preview_tts 流水可核算",
"试听不写聊天/记忆/生活/回执(四表计数零变化测试断言);自定义音色未试听不得应用(409),内置 D1 候选不强制;未知音色 404 且 TTS 零调用(前置拦住 CosyVoice 静默回落 default);未知选择通话开启诚实回落 default 不冒充",
"持久化与身份不变:选择/元数据/试听凭据重启读回(重建服务同批文件测试);换声音不改 companion/bazi_core/chat_message(逐字节对比测试);通用助手 voice_state/persona_state/既有音色零写入零删除(桩端点计数零);无人经 API 选择时不代写 worker 文件(D1 域不替用户定)",
"零真实调用:全链 fake 面板/TTS/LiveKit 传输,零真实 LLM/嵌入/ASR/TTS、零模型下载/安装、零共享音色实写、零部署、零现网触碰、未访问 4080、未动 HEL-879 现场"
],
"deferred": [
"真实 CosyVoice 试听/真实 FunASR 转写/克隆相似度:统一审核卡在获批环境实测,复用合法剩余额度,不另开账绕 HEL-879 上限(最长2小时/3次通话授权属总管测试执行,不在本卡)",
"前端入口(上传/录音/试听/应用 UI 与入口呈现):视觉总监按 docs/voice-custom-contract-HEL899.md §2 契约接线;本卡未动 web/",
"自定义音色删除与完整备份/恢复(CG-009/R6):本卡最小合同不含删除(失败/多余登记不影响使用),备份清单字段已就位,恢复=按清单重新登记(面板无导出接口)",
"D1 最终音色决定仍归白栖知/总管;本轮测试音色已授权总管自定",
"R5 整体真机验收与 R1–R7 完整要求继续承接;自定义音色交付不等于 R5 完成或整产品完成"
],
"requirement_changes": [
{
"summary": "v1.12:CG-010 音色边界更新为可自定义音色(网页上传/录制参考音频→试听→应用,直接复用已装TTS零样本克隆,不以仅选预设替代,参考包试听方案不采用);影响仅 CG-010 音色入口与测试授权,换声音只换发声等既有边界不变",
"approval_comment_id": "01a17de1-2892-78be-a422-eb6a11ffebfd",
"record": "sources/comment-evidence-custom-voice-20261027.json + changes.json/changes.md v1.12 + baseline.json/coverage.json/current.md 同步"
}
],
"verification": [
"PYTHONPATH=src .venv311/bin/python -m pytest tests -q:370 通过(基线 8dc84f8 全量 352 + 本卡 18:tests/test_voice_custom.py,全 fake 面板/TTS/LiveKit httpx.MockTransport + 临时文件 + 隔离 registry,零网络零真实模型调用)",
"scripts/verify_hel899_custom_voice.py:12/12 全过(真实 app+router+service,fake 传输:列表→无准许拒ASR→显式登记→未试听409→试听现合成→应用→会话捕获→通话中改选定住本通话→下一通话生效→capabilities.voice=false→重启读回选择/元数据/试听凭据)",
"ruff check src tests scripts deploy:本卡代码 0 错(既有 deploy/hel835_guard.py 的 SIM115 遗留不变,非本卡)",
"python3 scripts/check_requirements.py --base-ref origin/main 与 --base-ref 8dc84f8(实际基准):含本 handoff 通过(v1.12 升版+新来源+变更记录合法,历史来源与变更未改写)",
".12 只读取证(SSH 零写入零服务触碰):/opt/voice-agent/web/server.py 实读核对 upload 校验(名字规则/15MB/WAV magic/32000字节/空参考文本→FunASR 转写/失败删 wav/voices.json 原子写)、/opt/voice-agent/cosyvoice/server.py 实读核对 mtime 热加载与 /tts 未知音色静默回落 default、voices.json 11 支音色清单——契约按实际源码写,不按记忆",
"未验证如实声明:真实 CosyVoice 合成与 FunASR 转写未调用(TTS 9880 停机中且本卡零真实调用);浏览器端录音上传未实现(前端合同已交);.12 未部署、未改任何现网配置/端口/数据"
]
}
+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"
}
]
-8
View File
@@ -4,14 +4,6 @@
> 可离线播放的 HTML 试听页 + 音频包,作为主卡 D1 拍板材料。**零 TTS 合成、零
> 克隆、零共享音色切换、零下载安装**;对 .12 只做 SSH 只读复制,未改服务器任何
> 文件与状态。
>
> **2026-10-27 勘误(主卡授权轮)**:白栖知反馈「让我解压附件包试听」的设计
> 不合理(主卡评论 `01a17de1-2892-78be-a422-eb6a11ffebfd`)——他要的是可
> 自定义音色,而不是从预设音色里挑一个。本包正名为「**非定制功能入口**」:
> 它只是既有 11 支登记音色的参考原声对照材料,**不是**用户音色定制/上传/录制
> 的功能入口;正式的自定义音色(上传、录制、试听、应用)由独立施工分支接续,
> 未完成前不宣称可用。本轮 R5 有限测试由总管选定临时测试音色 `female_soft`
> (已核服务器登记;临时测试值,不是用户最终音色),不要求用户解压本包选 ID。
## 包内容(随本卡评论附件交付,不入仓库大媒体)
-124
View File
@@ -1,124 +0,0 @@
# 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
@@ -1,96 +0,0 @@
{
"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
}
]
}
}
@@ -1,96 +0,0 @@
{"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
@@ -1,97 +0,0 @@
{
"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
}
]
}
}
@@ -1,98 +0,0 @@
{"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
@@ -1,303 +0,0 @@
{
"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
}
]
}
}
@@ -1,104 +0,0 @@
{"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}
@@ -1,2 +0,0 @@
WINDOW_START=2026-10-27T15:26:54Z
WINDOW_END=2026-10-27T15:58:10Z
-409
View File
@@ -1,409 +0,0 @@
{"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"}
+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 最终音色决定:仍归白栖知/总管(本轮测试音色已授权总管自定)。
+5 -40
View File
@@ -143,46 +143,11 @@ 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. 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")
# 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")
# 7. SpeechHandle/worker API surface used by worker.py exists on the real SDK
from livekit.agents.voice.speech_handle import SpeechHandle
+2 -10
View File
@@ -12,7 +12,6 @@ from __future__ import annotations
import asyncio
import contextlib
import json
import socket
import sys
import tempfile
@@ -49,18 +48,11 @@ class FakeLiveKit:
if self.fail:
return httpx.Response(500)
if request.url.path.endswith("CreateDispatch"):
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}
agent, room = _dispatch_fields(request.content)
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"):
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"])
self.deleted.append(_dispatch_fields(request.content)[0])
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()
+13 -29
View File
@@ -82,26 +82,12 @@ def mint_token(
def mint_server_token(api_key: str, api_secret: str, ttl_seconds: float = 60.0,
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: float | None = None) -> str:
"""Server-API bearer token; matches Service._auth_header() with no grants."""
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,
claims,
{"iss": api_key, "sub": "", "nbf": int(now), "exp": int(now + ttl_seconds)},
)
@@ -139,20 +125,18 @@ async def create_dispatch(
) -> DispatchResult:
"""Twirp AgentDispatchService/CreateDispatch with an explicit agent_name.
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 is livekit.CreateAgentDispatchRequest: field 1 agent_name, field 2 room,
field 3 metadata (field numbers verified against the installed protocol pkg).
"""
payload: dict[str, Any] = {"agentName": agent_name, "room": room}
body = _proto_string(1, agent_name) + _proto_string(2, room)
if metadata:
payload["metadata"] = metadata
body += _proto_string(3, metadata)
response = await http.post(
f"{internal_url.rstrip('/')}{TWIRP_PREFIX}/{DISPATCH_SERVICE}/CreateDispatch",
json=payload,
content=body,
headers={
# SDK parity: VideoGrants(room_admin=True, room=...)
"authorization": f"Bearer {mint_server_token(api_key, api_secret, video={'room_admin': True, 'room': room})}",
"authorization": f"Bearer {mint_server_token(api_key, api_secret)}",
"content-type": "application/protobuf",
},
timeout=timeout,
)
@@ -200,10 +184,10 @@ async def delete_room(
"""
response = await http.post(
f"{internal_url.rstrip('/')}{TWIRP_PREFIX}/{ROOM_SERVICE}/DeleteRoom",
json={"room": room},
content=_proto_string(1, room),
headers={
# SDK parity: VideoGrants(room_create=True)
"authorization": f"Bearer {mint_server_token(api_key, api_secret, video={'room_create': True})}",
"authorization": f"Bearer {mint_server_token(api_key, api_secret)}",
"content-type": "application/protobuf",
},
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
+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(json.loads(request.content or b"{}").get("room", ""))
self.deleted.append(_proto_field1(request.content))
return httpx.Response(200, content=b"")
return httpx.Response(404, text="unknown")
+6 -8
View File
@@ -44,18 +44,16 @@ class FakeLiveKit:
return httpx.Response(500, text="livekit down")
path = request.url.path
if path == "/twirp/livekit.AgentDispatchService/CreateDispatch":
body = json.loads(request.content or b"{}")
agent_name = body.get("agentName", "")
room = body.get("room", "")
body = request.content
agent_name, room = _parse_dispatch(body)
self._dispatch_seq += 1
self.dispatches.append({"agent_name": agent_name, "room": room,
"json": True, "content_type":
"proto": 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":
body = json.loads(request.content or b"{}")
self.deleted_rooms.append(body.get("room", ""))
self.deleted_rooms.append(_parse_room(request.content))
return httpx.Response(200, content=b"")
return httpx.Response(404, text="unknown twirp route")
@@ -176,8 +174,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]["json"] is True
assert livekit.dispatches[-1]["content_type"] == "application/json"
assert livekit.dispatches[-1]["proto"] is True
assert livekit.dispatches[-1]["content_type"] == "application/protobuf"
assert body["url"] == "ws://voice.test:7880"
assert body["agent_name"] == "cybergril-voice-agent"
# no livekit key material leaks into the browser payload