HEL-903: preserve visual author voice status, call voice and recovery fixes

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
总管
2026-10-28 01:10:48 +08:00
co-authored by multica-agent
parent b09a907747
commit 51669aae6d
11 changed files with 656 additions and 24 deletions
+57
View File
@@ -0,0 +1,57 @@
{
"baseline_version": "1.12",
"read_commit": "b09a907747f9836c4aea04016757321d28b7cb6b",
"task": "HEL-903:HEL-902 统一审核后的原作者有限返修(只改 web/ 与本卡交接/证据)——外接服务语音两行按 /api/voice/status 如实区分状态;通话栏显示 session 响应 voice 固定的「本次通话声音」;登记后无明确结果(500/503/响应断连)时提示先刷新列表看编号条目,列表标出缺本地元数据的 cg- 条目。基于 origin/hel-902-integration @ b09a907,保留 HEL-879 真实窗口修复与 HEL-900 作者源码,不推写统一母线、不回退 ce7279e,零真实调用",
"requirement_ids": [
"CG-006",
"CG-009",
"CG-010",
"CG-013",
"CG-014",
"CG-015",
"CG-016",
"CG-018",
"CG-019",
"CG-020",
"CG-021",
"VIS-003",
"VIS-004",
"VIS-008",
"VIS-010",
"VIS-013",
"UX-001",
"UX-003",
"UX-005",
"UX-006",
"AM-001",
"GOV-001",
"GOV-002",
"GOV-003",
"GOV-004",
"GOV-005",
"GOV-006",
"DEL-001"
],
"deliverable": "web/src/voice/session.ts:readVoiceStatus 把 GET /api/voice/status 分成 none(无接口)/off(未配置)/ok(已配置且合成健康检查通过)/down(已配置但健康检查未过)/unknown(读不到)五态,voiceStatus 改为由它派生、行为不变;VoiceView 新增 voice(本次通话固定声音,开房前 null,服务器未给或不合规为 {id:null}),只在拿到本代 grant 时写入,结束/失败/迟到旧请求一律随 view 复位;callVoiceOf 只接受列表可容纳的 id。web/src/chat/ChatPanel.tsx:在既有通话条加「本次通话 · 声音名」(default→默认声音、缺字段→声音未说明,名字只按该 id 查列表,查不到显示编号,不用设置里的当前选择),title 写明到本次结束不变、设置里的新选择下一次通话生效。web/src/manage/ManagePanel.tsx:外接服务的语音识别/语音合成两行改为进入分区及点「重新读取状态」时读 /api/voice/status(仅真实入口),按五态如实说明,识别一行写明没有单独健康检查;配置框、按钮、说明卡与文字检查提示随之更正,视觉观察仍写尚未接通。web/src/voice/custom.ts + VoiceSection.tsx:VoiceError 增加 uncertain(已发出后断连、5xx、成功响应体读不全),登记遇到时提示「可能已经登记上了,先刷新列表看 cg- 编号条目,不用再登记」,提供「刷新列表」并把再登记改为「确认没有,再登记」;刷新后对比失败时的自定义列表,指出新出现条目或说明暂无新条目需稍等;列表把无本地元数据的 cg- 条目标「只有编号,缺本地资料」并加说明。4xx 与离线未发出仍为原明确提示。不新增删除入口、不自动删声音。components.css 追加通话条声音标签、状态行配色、编号条目说明与手机按钮换行(只用既有 token,两主题)。测试:tests/hel903_voice_rework.test.ts(11 例)、tests/hel903_mock.mjs、tests/hel903_shots.mjs;tests/hel898_agent_left.test.ts 期望视图补 voice:null。",
"acceptance": [
"外接服务:语音合成在 ok/down/off/none/unknown(500)/unknown(断连) 下分别显示「已配置 · 合成服务健康检查可连接」「已配置 · 合成服务这会儿暂不可用」「未配置 · 这台服务没有语音配置」「这台服务没有语音状态接口」「状态未知 · 这次没读到语音状态」;语音识别不声称有自己的健康检查;任一状态下语音两行都不出现「尚未接通」或「已接通」;读取状态不申请麦克风;说明写明「可连接」不代表整套语音通话已验收",
"通话:开房后通话条显示 session 响应 voice 对应的「本次通话 · 名称」;通话中另一端改选声音,本次通话显示不变;挂断后标签消失、麦克风释放;下一次通话显示新选择;响应缺 voice 字段显示「声音未说明」而非设置当前选择;default 显示「默认声音」;更早一次通话迟到的 grant 不覆盖新通话(单测)",
"登记:服务器已收下却回 500、或响应中途断开、或 503 时,提示可能已登记并先刷新列表;不自动重读、不自动再登记(上传计数验证);点刷新列表后能指出新出现的 cg- 编号条目并标「只有编号,缺本地资料」;若无新条目则说明稍等再刷、确认没有再登记,确认后再登记一次只发一次;参考声音与已填内容在失败后保留",
"回归:既有上传→登记→试听 canplay→应用流程、换声音只换发声的说明、录音点击开启/停止保留/取消释放麦克风、通话字幕与结束清理均保持;演示入口打开外接服务、她的声音与交谈零 /api 请求"
],
"deferred": [
"后台新错误映射(本地元数据落盘失败 OSError→503/422 中文文案等,HEL-902 §5-2)由另卡处理;本卡先兼容既有 500/503,后续统一整合时按合同补核前端文案",
"语音识别没有独立健康状态接口,外接服务只能如实写「没有单独的健康检查」;如需显示识别服务真实状态需后台另行提供",
"编号条目的删除/回收(经 8090 面板自有删除)、自定义音色删除与完整备份恢复(CG-009/R6)继续由后续卡承接,本卡不新增删除入口",
"_capture_voice→worker 读文件的秒级竞态(HEL-902 §5-6)属后台;前端显示的是服务器声明的本次捕获值",
"真实 CosyVoice/FunASR/LiveKit、真机(iPhone/Android/Safari)与 R5 整体验收、R1–R7 完整功能继续承接;HEL-901 仍停放;本卡不部署、不合并"
],
"requirement_changes": [],
"verification": [
"web(复用原作者 HEL-900 工作区已存在的同版 node_modules,package-lock SHA256 F950DE58…7A27 一致,经目录联接使用,零下载安装):tsc --noEmit 0 错;eslint src 0 错 1 既有警告(ChatPanel fullButton,非本卡);vitest 148/148(基线 137 + 本卡 11);vite build 通过",
"浏览器:node tests/hel903_mock.mjs 8903 + node tests/hel903_shots.mjs <msedge.exe> <outDir> 8903,headless Edge + 假 LiveKit(tests/fake-livekit.js)+ Chromium 假音频设备,90/90 通过:月光/雾光 × 电脑 1440×900/手机 390×844 各 18 项(外接服务、应用声音、本次通话声音固定、下一通更新、字幕、挂断释放、登记 500 后恢复),状态五分支、缺 voice 字段/default、响应断连与 503 恢复、录音释放、演示零请求",
"对照截图:同一 mock 跑 b09a907 原样构建(产物 index-C8UHROe1.js 与 HEL-900 作者构建同名)before 模式三张:外接服务、通话条、登记 500",
"需求检查:python -I scripts/check_requirements.py 与 --handoff docs/requirements/handoff-HEL-903.json 本机执行;本运行环境无 git,--base-ref origin/main 与 --base-ref b09a907 均无法执行,改以与 b09a907 源码包逐文件 SHA256 比对确认 docs/requirements 除新增本交接文件外零改动,--base-ref 四组检查交总管在仓库补跑",
"未验证如实声明:假麦克风是浏览器内置提示音;假 LiveKit 不是 WebRTC;试听是 mock 音调不是 TTS;未接真实 8090/9880/FunASR/LiveKit,未部署,未写共享音色,未访问 4080;Safari 与真机未测"
]
}
+13
View File
@@ -11,6 +11,7 @@ import { motionOff } from '../stage/roomScene';
import { LineStage, type PresentHow } from './lineStage';
import { anchorOf, cursorAt, groupTurns, pagesOf, ReadingClock, type Anchor, type Metrics, type ShownMessage } from './reading';
import { mergeTurns, OFF, recentRows, VoiceController, voiceLine, voiceStatus, WORDS, type VoiceView } from '../voice/session';
import { callVoiceName, listVoices } from '../voice/custom';
import { loadTransport } from '../voice/livekit';
import { newestId, serverId } from '../adapters/inbox';
@@ -89,6 +90,17 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation,
voiceStatus(c.signal).then(s => setVoiceOff(s.available ? '' : s.reason), () => {});
return () => c.abort();
}, [real]);
// The voice this call was opened with (HEL-903). Its name is looked up by that id only — never the choice settings
// hold now, which is the next call's — and an answer for another id (an older call) is never shown.
const pinned = real ? voiceView.voice : null, [named, setNamed] = useState<{ id: string; label: string } | null>(null);
useEffect(() => {
const id = pinned?.id; if (!id || id === 'default' || named?.id === id) return;
const c = new AbortController();
listVoices(c.signal).then(list => { const v = !c.signal.aborted && list.configured ? list.voices.find(x => x.id === id) : undefined; if (v) setNamed({ id, label: v.label }); }, () => { /* The id stays on show. */ });
return () => c.abort();
// eslint-disable-next-line react-hooks/exhaustive-deps -- one lookup per pinned id.
}, [pinned?.id]);
const pinnedName = pinned && callVoiceName(pinned.id, named);
const voiceOn = real ? voiceView.phase !== 'off' : voice;
const voiceLabel = { off: '', opening: '正在接通…', joining: `在等${name}接起…`, listening: '在听你说', thinking: `${name}在想`, speaking: `${name}在说 · 开口就能打断` }[voiceView.phase];
useEffect(() => {
@@ -334,6 +346,7 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation,
{real && voiceOn && <div className="voice-row real" data-phase={voiceView.phase} role="status" aria-live="polite">
<span className="voice-wave" aria-hidden="true"><i/><i/><i/><i/></span>
<span className="voice-state">{voiceLabel}</span>
{pinned && pinnedName && <span className={`voice-pin ${pinned.id ? '' : 'unknown'}`} title={pinned.id ? `本次通话用「${pinnedName}」,到这次通话结束都不变;设置里换的声音下一次通话才生效。` : '服务器这次没有说明用的是哪个声音,这里不猜。设置里换的声音下一次通话才生效。'}><small>本次<span className="vp-wide">通话</span></small>{pinnedName}</span>}
{voiceView.spoken ? <span className="voice-heard said" title={voiceView.spoken}>{name}:{voiceView.spoken}<em>未记入聊天</em></span> : voiceView.heard && <span className="voice-heard" title={voiceView.heard}>「{voiceView.heard}」</span>}
{voiceView.blocked && <button onClick={() => void voiceCtl.current?.resumeAudio()}>点一下听{name}的声音</button>}
<button className="voice-end" onClick={() => { void voiceCtl.current?.end(); setNotice('语音已结束,麦克风已关闭。'); }}>结束语音</button>
+19 -2
View File
@@ -5,12 +5,21 @@ import { delay } from '../adapters/demo';
import { exportDemo, parseState } from '../adapters/storage';
import { BaseColor, Button, Field, Icon, Modal, Status } from '../shared/ui';
import { VoiceSection, type VoiceCap } from './VoiceSection';
import { readVoiceStatus, type VoiceStatusKind } from '../voice/session';
const sections = [['her', 'user', '她'], ['relation', 'heart', '关系'], ['voice', 'mic', '她的声音'], ['city', 'pin', '城市'], ['appearance', 'image', '外观素材'], ['backup', 'box', '本地演示导出'], ['services', 'plug', '外接服务'], ['display', 'moon', '显示']];
// What each section can actually do here (HEL-815 capability tags): read-only, adjustable, demo only, not connected, this device.
type Cap = 'ro' | 'edit' | 'demo' | 'off' | 'live' | 'local';
const CAP: Record<Cap, [string, string]> = { ro: ['只读', ''], edit: ['可以调整', 'accent'], demo: ['演示数据', 'demo'], off: ['未接入', 'off'], live: ['文字已接通', 'ok'], local: ['本机生效', 'ok'] };
const capOf = (id: string, real: boolean, voice: VoiceCap = 'unknown'): Cap => id === 'her' ? 'ro' : id === 'relation' ? 'edit' : id === 'voice' ? real ? voice === 'off' ? 'off' : 'edit' : 'demo' : id === 'display' ? 'local' : id === 'services' && real ? 'live' : real ? 'off' : 'demo';
const NOTE: Record<string, string> = { her: '只读资料', relation: '可以调整', voice: '上传 · 录音 · 试听', display: '本机生效' };
// 外接服务 · voice rows (HEL-903): exactly what GET /api/voice/status can tell — no route, not configured, configured with
// the TTS health probe passing or failing, or unreadable. Recognition has no probe of its own there, so it never claims one.
type VoiceSvc = VoiceStatusKind | 'loading';
const VOICE_SVC: Record<'asr' | 'tts' | 'config', Record<VoiceSvc, string>> = {
tts: { loading: '正在读取语音状态…', none: '这台服务没有语音状态接口', off: '未配置 · 这台服务没有语音配置', ok: '已配置 · 合成服务健康检查可连接', down: '已配置 · 合成服务这会儿暂不可用', unknown: '状态未知 · 这次没读到语音状态' },
asr: { loading: '正在读取语音状态…', none: '这台服务没有语音状态接口', off: '未配置 · 这台服务没有语音配置', ok: '已配置 · 没有单独的健康检查,以实际通话为准', down: '已配置 · 没有单独的健康检查,以实际通话为准', unknown: '状态未知 · 这次没读到语音状态' },
config: { loading: '读取中', none: '服务尚未配置', off: '服务尚未配置', ok: '由服务器管理', down: '由服务器管理', unknown: '暂时读不到' },
};
function CapTag({ cap }: { cap: Cap }) { const [text, tone] = CAP[cap]; return <span className={`tag cap ${tone}`}>{text}</span>; }
// Honest placeholder for a capability that is not connected: what it will do once connected, and that nothing happens now.
function Off({ title, will, now }: { title: string; will: string; now: string }) { return <div className="off-card"><p className="off-t">{title}</p><p>{will}</p><p className="d-dim">{now}</p></div>; }
@@ -33,6 +42,14 @@ export function ManagePanel({ state, open, onClose, onSave, fail, theme, setThem
const approvedNavigation = useRef(false);
const pendingNavigation = useRef<(() => void) | null>(null);
const [serviceHealth, setServiceHealth] = useState<Health>();
// Read on opening 外接服务 and on an explicit re-read; real only (the demo never asks a server).
const [voiceSvc, setVoiceSvc] = useState<VoiceSvc>('loading'), [voiceSvcRead, setVoiceSvcRead] = useState(0);
useEffect(() => {
if (!real || !open || section !== 'services') return;
const c = new AbortController(); setVoiceSvc('loading');
readVoiceStatus(c.signal).then(kind => { if (!c.signal.aborted) setVoiceSvc(kind); }, () => { /* Aborted: left the section. */ });
return () => c.abort();
}, [real, open, section, voiceSvcRead]);
// 她的声音 (HEL-900): an unregistered reference counts as unsaved; the tag follows what the server reports.
const [voiceDirty, setVoiceDirty] = useState(false), [voiceCap, setVoiceCap] = useState<VoiceCap>('unknown');
const request = useRef<AbortController | null>(null), generation = useRef(0), feedback = useRef<HTMLDivElement>(null);
@@ -56,7 +73,7 @@ export function ManagePanel({ state, open, onClose, onSave, fail, theme, setThem
}, [open, anyDirty]);
async function checkService() {
setBusy(true); setError(false); setMessage('正在检查文字服务…');
try { const status = await api<Health>('health'); setServiceHealth(status); setMessage(status.llm.ok ? '文字服务可达。语音、视觉和长期记忆仍未接通。' : '文字模型暂时不可达,档案和历史仍可查看。'); setError(!status.llm.ok); }
try { const status = await api<Health>('health'); setServiceHealth(status); setMessage(status.llm.ok ? '文字服务可达。视觉观察和长期记忆仍未接通;语音状态以上面两行为准。' : '文字模型暂时不可达,档案和历史仍可查看。'); setError(!status.llm.ok); }
catch (e) { setMessage((e as Error).message); setError(true); }
finally { setBusy(false); }
}
@@ -187,7 +204,7 @@ export function ManagePanel({ state, open, onClose, onSave, fail, theme, setThem
{real && section === 'city' && <><Off title="城市与天气还没有接上。" will="接上以后,你选一个真实城市,她房间里的天气、白天黑夜和作息都跟着那里走;换城市不需要每次重新定位。" now="现在窗外的雨是界面氛围,不是真实天气。尚未设置可核实的城市,天气来源尚未确定。"/><Field label="搜索城市" hint="可浏览候选目录;当前无法保存,不会更改她的城市。"><input className="input" value={search} placeholder="输入城市名称" onChange={e => setSearch(e.target.value)}/></Field><ul className="city-list">{['杭州', '上海', '北京', '成都', '广州', '深圳'].filter(c => c.includes(search.trim())).map(c => <li key={c}><button disabled>{c}<small>候选城市 · 保存待接入</small></button></li>)}</ul>{!['杭州', '上海', '北京', '成都', '广州', '深圳'].some(c => c.includes(search.trim())) && <div className="empty">当前目录没有匹配的城市。</div>}<div className="actions"><Button disabled>保存城市 · 待接入</Button></div></>}
{real && section === 'appearance' && <><dl className="kv"><dt>现在的样子</dt><dd>工程外观 v2<small>参考静帧 · 尚无成套日常与交谈视频</small></dd></dl><Off title="换外观还没有接上。" will="接上以后:选择素材包 → 检查素材 → 看新旧对比 → 确认「她还是她」。只换样子,名字、底色、记忆和关系都不变。" now="素材包规范与导入检查尚未定稿。这里不会导入、预览或替换任何素材。"/><Field label="选择外观素材包" hint="素材包格式与媒体检查将在后续接入。当前不上传、不读取文件。"><input type="file" disabled/></Field><ul className="phases">{['清单结构与版本', '媒体格式与时长', '人物一致性', '必要场景覆盖', '可用性与兼容性'].map(label => <li key={label}>待检查 · {label}</li>)}</ul><div className="compare"><figure><img src="/assets/her-room.jpg" alt="当前内置参考静帧"/><figcaption>当前参考</figcaption></figure><figure><div className="empty">导入并通过检查后显示新外观</div><figcaption>新旧对比 · 待接入</figcaption></figure></div><Button disabled>确认应用外观 · 待接入</Button></>}
{real && section === 'backup' && <><Off title="备份还没有接上。" will="接上以后:服务器每天自动备份;你可以随时把她完整导出到自己的电脑(含底色、性格资料、对话、记忆、关系、生活进度和所用素材),也能从备份恢复同一个她。" now="现在没有任何备份在运行,这里也不会假装导出或恢复。"/><div className="card"><h4>每日自动备份</h4><p>暂无可核实的备份记录。自动备份、保留份数与运行结果将在接入后显示。</p><div className="empty">尚未接通备份服务</div></div><div className="card"><h4>完整导出</h4><p>将包含服务器档案、聊天及届时接通的记录与素材。当前不生成伪备份文件。</p><Button disabled>导出完整备份 · 待接入</Button></div><Field label="恢复同一位她的备份" hint="恢复前会检查完整性、版本和同一档案,要求输入“恢复”,并先备份当前资料。当前不接收文件或执行覆盖。"><input type="file" disabled/></Field><ul className="phases">{['检查完整性与版本', '核对是否同一位她', '先备份当前资料', '输入确认并恢复'].map(label => <li key={label}>待接入 · {label}</li>)}</ul><button className="btn-danger" disabled>检查备份并继续 · 待接入</button></>}
{real && section === 'services' && <>{[['文字对话', serviceHealth ? serviceHealth.llm.ok ? '连接可达' : '暂时不可达' : '已接通 · 可重新检查'], ['语音识别', '尚未接通'], ['语音合成', '尚未接通'], ['视觉观察', '尚未接通']].map(([label, status], index) => <div className="svc" key={label}><div className="nm">{label}<small>{status}</small></div><Button disabled={index !== 0 || busy} onClick={() => void checkService()}>{index === 0 ? busy ? '检查中…' : '重新检查连接' : '测试连接 · 待接入'}</Button><input className="input" aria-label={`${label}配置`} value={index === 0 ? '由服务器管理' : '服务尚未配置'} readOnly/></div>)}<Off title="语音与视觉观察还没有接上。" will="接上以后:语音由你主动开启,结束后停止收音,文字聊天一直保留;视觉观察只看人在不在、明显物品和房间变化,过期的画面不再当作眼前。" now="这里不申请麦克风或摄像头,不采集声音与影像。"/><div className="card"><h4>其余能力</h4><p>{life?.wired ? `生活:${life.label}。${life.note}长期记忆、主动消息和天气尚未接通。` : '生活、长期记忆、主动消息和天气尚未接通。'}文字模型暂时不可达时,已保存的档案和聊天记录仍可查看。</p></div></>}
{real && section === 'services' && <>{([['text', '文字对话', serviceHealth ? serviceHealth.llm.ok ? '连接可达' : '暂时不可达' : '已接通 · 可重新检查'], ['voice', '语音识别', VOICE_SVC.asr[voiceSvc]], ['voice', '语音合成', VOICE_SVC.tts[voiceSvc]], ['off', '视觉观察', '尚未接通']] as const).map(([kind, label, status]) => <div className="svc" key={label} data-state={kind === 'voice' ? voiceSvc : undefined}><div className="nm">{label}<small>{status}</small></div>{kind === 'text' ? <Button disabled={busy} onClick={() => void checkService()}>{busy ? '检查中…' : '重新检查连接'}</Button> : kind === 'voice' ? <Button disabled={voiceSvc === 'loading'} onClick={() => setVoiceSvcRead(n => n + 1)}>{voiceSvc === 'loading' ? '读取中…' : '重新读取状态'}</Button> : <Button disabled>测试连接 · 待接入</Button>}<input className="input" aria-label={`${label}配置`} value={kind === 'text' ? '由服务器管理' : kind === 'voice' ? VOICE_SVC.config[voiceSvc] : '服务尚未配置'} readOnly/></div>)}<p className="d-dim">语音两项读自服务器的语音状态:「可连接」只说明她的声音(合成)这会儿通过了健康检查,不代表整套语音通话已经验收;语音识别这里查不到单独的状态。这里只读状态,不申请麦克风。</p><Off title="视觉观察还没有接上。" will="接上以后:视觉观察只看人在不在、明显物品和房间变化,过期的画面不再当作眼前。语音由你在交谈里主动开启,结束后停止收音,文字聊天一直保留。" now="这里不申请摄像头,不采集影像。"/><div className="card"><h4>其余能力</h4><p>{life?.wired ? `生活:${life.label}。${life.note}长期记忆、主动消息和天气尚未接通。` : '生活、长期记忆、主动消息和天气尚未接通。'}文字模型暂时不可达时,已保存的档案和聊天记录仍可查看。</p></div></>}
<div ref={feedback}><Status text={message} error={error}/></div>{(real ? ['relation', 'display'] : ['relation', 'city', 'services', 'display']).includes(section) && <div className="actions"><span className={`grow ${dirty ? 'dirty' : ''}`}>{dirty ? '有未保存的修改' : '尚未修改'}</span><Button disabled={!dirty || busy} onClick={reset}>取消修改</Button><Button primary disabled={!dirty || busy} onClick={() => void save()}>{busy ? '正在保存…' : error ? '重试保存' : '保存'}</Button></div>}</div></div></div>
{confirm && <Modal title={confirm === 'leave' ? '有未保存的修改' : confirm === 'relation' ? `把关系从「${state.identity.relation}」改成「${draft.identity.relation}」?` : confirm === 'restore' ? '恢复本地演示资料?' : '她还是她'} danger={confirm === 'restore'} onClose={() => setConfirm('')} disabled={confirm === 'restore' && confirmText !== '恢复'} onConfirm={() => { const which = confirm; setConfirm(''); if (which === 'leave') { request.current?.abort(); setBusy(false); setDraft({ identity: structuredClone(state.identity), settings: structuredClone(state.settings) }); const action = pendingNavigation.current; pendingNavigation.current = null; approvedNavigation.current = true; try { action?.(); } finally { approvedNavigation.current = false; } } if (which === 'relation')
void save(true); if (which === 'appearance') {
+27 -6
View File
@@ -4,7 +4,7 @@
// (request aborted, audio stopped, object URL revoked), so an old sample can never pass for the new one.
import { useEffect, useRef, useState, type RefObject } from 'react';
import { clockOf } from '../adapters/real';
import { applyVoice, LABEL_MAX, listVoices, PREVIEW_DEFAULT, PREVIEW_MAX, previewVoice, Recorder, registerVoice, SAY, SUGGEST_MAX, SUGGEST_MIN, TEXT_MAX, toReference, VoiceError, type CustomVoice, type RecView, type VoiceSelection } from '../voice/custom';
import { applyVoice, idOnly, LABEL_MAX, listVoices, PREVIEW_DEFAULT, PREVIEW_MAX, previewVoice, Recorder, registerVoice, SAY, SUGGEST_MAX, SUGGEST_MIN, TEXT_MAX, toReference, VoiceError, type CustomVoice, type RecView, type VoiceSelection } from '../voice/custom';
import { Button, Field, Icon, Status } from '../shared/ui';
export type VoiceCap = 'edit' | 'off' | 'unknown';
@@ -37,6 +37,11 @@ export function VoiceSection({ name, active, onDirty, onCap }: { name: string; a
// One controller per kind of request; a new one aborts the old. `takeGen` guards decoding a file or a recording.
const ctl = useRef<Record<'list' | 'register' | 'preview' | 'apply', AbortController | null>>({ list: null, register: null, preview: null, apply: null });
const takeGen = useRef(0), urls = useRef(new Set<string>()), listed = useRef(false);
// HEL-903: a registration that failed without a clear answer may still have been filed. `unsure` holds the custom ids
// listed when it failed, so an explicit refresh can say which entry is new (never a silent second registration).
const [unsure, setUnsure] = useState(false), unsureBefore = useRef<Set<string> | null>(null);
const settleUnsure = () => { unsureBefore.current = null; setUnsure(false); };
const loadNow = useRef(load); loadNow.current = load;
const fresh = (kind: keyof typeof ctl.current) => { ctl.current[kind]?.abort(); const c = new AbortController(); ctl.current[kind] = c; return c; };
const own = (blob: Blob) => { const url = URL.createObjectURL(blob); urls.current.add(url); return url; };
const drop = (url?: string) => { if (url && urls.current.delete(url)) URL.revokeObjectURL(url); };
@@ -69,6 +74,12 @@ export function VoiceSection({ name, active, onDirty, onCap }: { name: string; a
if (c.signal.aborted) return;
if (!list.configured) { setLoad({ state: 'off', text: SAY.notConfigured }); onCap('off'); return; }
listed.current = true; setNote(n => n.error ? { text: '', error: false } : n); setLoad({ state: 'ok', voices: list.voices, selection: list.selection }); onCap('edit');
const before = unsureBefore.current;
if (before) {
const fresh = list.voices.filter(v => v.source === 'custom' && !before.has(v.id));
if (fresh.length) { settleUnsure(); setRegNote({ text: `列表里多了${fresh.map(v => `「${v.label}」`).join('、')},${SAY.refreshFound}`, error: false }); }
else setRegNote({ text: SAY.refreshNone, error: false });
}
}, e => {
if (c.signal.aborted) return;
const status = e instanceof VoiceError ? e.status : 0;
@@ -86,6 +97,7 @@ export function VoiceSection({ name, active, onDirty, onCap }: { name: string; a
try {
const ref = await toReference(input);
if (gen !== takeGen.current) return;
settleUnsure();
const url = own(ref.wav);
setTake(old => { drop(old?.url); return { wav: ref.wav, url, seconds: ref.seconds, from: kind, name: label }; });
setRegNote({ text: '', error: false });
@@ -99,7 +111,7 @@ export function VoiceSection({ name, active, onDirty, onCap }: { name: string; a
if (!file || registering) return;
void accept(file, 'file', file.name.slice(0, 60));
}
function discardTake() { takeGen.current++; recorder.current?.cancel(); setTake(old => { drop(old?.url); return null; }); setRegNote({ text: '', error: false }); }
function discardTake() { takeGen.current++; recorder.current?.cancel(); settleUnsure(); setTake(old => { drop(old?.url); return null; }); setRegNote({ text: '', error: false }); }
function switchFrom(next: 'file' | 'record') { if (next === from) return; recorder.current?.cancel(); setFrom(next); setRegNote({ text: '', error: false }); }
const text = form.text.trim(), needText = !text && !form.allowAsr;
@@ -111,6 +123,7 @@ export function VoiceSection({ name, active, onDirty, onCap }: { name: string; a
try {
const done = await registerVoice(sent.wav, form, c.signal);
if (c.signal.aborted) return;
settleUnsure();
setTake(old => { if (old === sent) { drop(old.url); return null; } return old; });
setForm({ label: '', text: '', allowAsr: false });
setRegNote({ text: done.prompt_text_source === 'asr' ? `登记好了:「${done.label}」。服务器转写的参考文字是:${done.prompt_text || '(空)'}` : `登记好了:「${done.label}」。`, error: false });
@@ -119,8 +132,15 @@ export function VoiceSection({ name, active, onDirty, onCap }: { name: string; a
setReads(n => n + 1);
} catch (e) {
if (c.signal.aborted) return;
// Nothing is lost on failure: the take, the label and the text all stay for another try.
setRegNote({ text: message(e), error: true });
// Nothing is lost on failure: the take, the label and the text all stay for another try. When the server may
// have filed it anyway, say so and offer a look at the list first; the list is not re-read behind your back.
if (e instanceof VoiceError && e.uncertain) {
const l = loadNow.current;
if (l.state === 'ok' && !unsureBefore.current) unsureBefore.current = new Set(l.voices.filter(v => v.source === 'custom').map(v => v.id));
setUnsure(true);
// The server's own words when it gave any; a bare "try again later" would contradict "look first".
setRegNote({ text: e.message === SAY.failed || e.message === SAY.odd ? SAY.maybeRegistered : `${e.message}${SAY.maybeRegistered}`, error: true });
} else setRegNote({ text: message(e), error: true });
} finally { if (ctl.current.register === c) { ctl.current.register = null; setRegistering(false); } }
}
@@ -174,7 +194,7 @@ export function VoiceSection({ name, active, onDirty, onCap }: { name: string; a
const mine = voices.filter(v => v.source === 'custom'), builtin = voices.filter(v => v.source === 'builtin');
const chosen = voices.find(v => v.id === candidate), ready = sample?.voice === candidate && sample.state === 'ready';
const recording = rec.phase === 'asking' || rec.phase === 'recording';
const row = (v: CustomVoice) => <li key={v.id}><button aria-pressed={candidate === v.id} onClick={() => pick(v.id)} disabled={applying}><span>{v.label}{v.selected && <span className="tag accent">正在用</span>}</span><small>{v.source === 'custom' ? `自定义${v.registered_at ? ` · ${clockOf(v.registered_at).date}` : ''}` : '服务自带'}{v.previewed ? ' · 试听过' : ''}</small></button></li>;
const row = (v: CustomVoice) => <li key={v.id}><button aria-pressed={candidate === v.id} onClick={() => pick(v.id)} disabled={applying}><span>{v.label}{v.selected && <span className="tag accent">正在用</span>}</span><small>{idOnly(v) ? '自定义 · 只有编号,缺本地资料' : v.source === 'custom' ? `自定义${v.registered_at ? ` · ${clockOf(v.registered_at).date}` : ''}` : '服务自带'}{v.previewed ? ' · 试听过' : ''}</small></button></li>;
return <div className="voice-sec">
<dl className="kv"><dt>现在的声音</dt><dd>{selection.voice ? <>{current?.label || selection.label || selection.voice}<small>{current?.source === 'custom' ? '自定义' : current ? '服务自带' : '这个声音已不在列表里,通话时会用默认声音'}{selection.updated_at ? ` · ${clockOf(selection.updated_at).date} ${clockOf(selection.updated_at).time} 换上` : ''}</small></> : <>还没有为她选过<small>通话时用服务默认的声音</small></>}</dd>
@@ -197,7 +217,7 @@ export function VoiceSection({ name, active, onDirty, onCap }: { name: string; a
<Field label="给这个声音起个名字" hint={`最多 ${LABEL_MAX} 个字,只用来在这里区分;不填就用编号。`}><input className="input" value={form.label} maxLength={LABEL_MAX} placeholder="比如:轻一点的她" disabled={registering} onChange={e => setForm(f => ({ ...f, label: e.target.value.replace(/[<>\p{Cc}]/gu, '') }))}/></Field>
<Field label="参考文字" hint="这段声音里念的原话。写得越准,克隆越像。"><textarea className="textarea" value={form.text} maxLength={TEXT_MAX} rows={2} disabled={registering} placeholder="照着录音里念的内容写下来" onChange={e => setForm(f => ({ ...f, text: e.target.value }))}/></Field>
<label className="switch-row"><span>参考文字留空时,允许服务器转写<small>{text ? '已经填写了参考文字,不会转写。' : '会用这台服务器上的语音识别听一遍这段声音;不打开就必须填写参考文字。'}</small></span><input type="checkbox" className="switch" checked={form.allowAsr && !text} disabled={!!text || registering} onChange={e => setForm(f => ({ ...f, allowAsr: e.target.checked }))}/></label>
<div className="take-acts"><Button primary disabled={registering || needText} onClick={() => void register()}>{registering ? '正在登记…' : '登记这段声音'}</Button><Button disabled={registering} onClick={discardTake}>换一段</Button></div>
<div className="take-acts">{unsure && <Button primary disabled={registering} onClick={() => setReads(n => n + 1)}><Icon name="list"/>刷新列表</Button>}<Button primary={!unsure} disabled={registering || needText} onClick={() => void register()}>{registering ? '正在登记…' : unsure ? '确认没有,再登记' : '登记这段声音'}</Button><Button disabled={registering} onClick={discardTake}>换一段</Button></div>
{needText && !registering && <p className="d-dim">填写参考文字,或打开上面的转写开关,才能登记。</p>}
</div>}
<Status text={regNote.text} error={regNote.error}/>
@@ -205,6 +225,7 @@ export function VoiceSection({ name, active, onDirty, onCap }: { name: string; a
<h4 className="sub-h">可以给她用的声音<small>先试听,再给她用</small></h4>
<p className="voice-group">你给的声音</p>
{mine.length ? <ul className="city-list voice-list">{mine.map(row)}</ul> : <div className="empty sm">还没有登记过自己的声音。</div>}
{mine.some(idOnly) && <p className="d-dim voice-idonly">{SAY.idOnly}</p>}
{builtin.length > 0 && <details className="voice-builtin" open={!!chosen && chosen.source === 'builtin'}><summary>服务自带的声音 · {builtin.length} 个</summary><ul className="city-list voice-list">{builtin.map(row)}</ul></details>}
{chosen && <div className="card audition" aria-live="polite">
+12
View File
@@ -1034,4 +1034,16 @@ body.creating .now-chip{display:none}
@media (max-width:720px){
.voice-sec .take-acts .btn-primary,.voice-sec .take-acts .btn-ghost,.voice-sec .rec-acts button{flex:1}
.voice-sec .rec .btn-ghost{width:100%}
}
/* ── HEL-903: the call's own voice on the call line, the voice status rows, id-only entries. Tokens only. ── */
.voice-row.real .voice-pin{display:inline-block;flex:0 1 auto;min-width:0;max-width:12em;padding-left:10px;border-left:1px solid var(--glass-line);color:var(--text-1);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.voice-row.real .voice-pin small{margin-right:6px;font-size:11px;color:var(--text-3);letter-spacing:.04em}
.voice-row.real .voice-pin.unknown{color:var(--text-3)}
.svc[data-state="ok"] .nm small{color:var(--ok)}
.svc[data-state="down"] .nm small{color:var(--warn)}
.voice-idonly{margin:8px 0 0;font-size:12.5px}
@media (max-width:720px){
.voice-sec .take-acts .btn-primary,.voice-sec .take-acts .btn-ghost{flex:1 1 auto;white-space:nowrap}
.voice-row.real .voice-pin{max-width:9.5em;padding-left:8px}
.voice-row.real .voice-pin .vp-wide{display:none}
}
+23 -4
View File
@@ -37,9 +37,16 @@ export const SAY = {
noRecorder: '这个浏览器不支持在网页里录音。可以改用上传文件。',
micGone: '麦克风被关掉了,这段没有录完,已经丢弃。',
recordFailed: '这次录音没有成功,已经丢弃,可以再录一次。',
// HEL-903: the upload may have reached the shared voice panel even though no answer came back (server error, a
// dropped connection, local metadata not saved). Look before registering again, so one voice is not filed twice.
maybeRegistered: '这段声音可能已经登记上了,只是这边没收到结果。先点「刷新列表」,看看「你给的声音」里有没有新出现的编号条目(cg- 开头):有就直接选它试听、使用,不用再登记一次。',
refreshFound: '很可能就是刚才那段:直接在下面选它试听、使用就好,不用再登记一次。',
refreshNone: '列表里暂时没有新出现的条目。服务器可能还在处理,稍等几秒再刷新一次;确认没有再登记。',
idOnly: '只有编号的条目:声音已经登记在共享面板上,但这台服务器没存下它的名字和参考文字,多半来自一次没收到结果的登记。它仍可试听、使用;这里暂时不能删除。',
} as const;
export class VoiceError extends Error { constructor(message: string, readonly status = 0) { super(message); } }
// `uncertain`: the request may have been carried out even though it failed here (sent, then no usable answer).
export class VoiceError extends Error { constructor(message: string, readonly status = 0, readonly uncertain = false) { super(message); } }
// ── WAV ──
export function encodeWav(samples: Float32Array, rate = RATE): ArrayBuffer {
@@ -111,7 +118,8 @@ const online = () => typeof navigator === 'undefined' || navigator.onLine !== fa
async function call(f: Fetch, path: string, init: RequestInit): Promise<Response> {
if (!online()) throw new VoiceError(SAY.offline);
try { return await f(`/api/voice/${path}`, init); }
catch (e) { if (init.signal?.aborted) throw e; throw new VoiceError(online() ? SAY.unreachable : SAY.offline); }
// Gone after sending started: whether the server acted on it is unknown (an offline check above sent nothing).
catch (e) { if (init.signal?.aborted) throw e; throw new VoiceError(online() ? SAY.unreachable : SAY.offline, 0, true); }
}
async function refusal(r: Response, fallback: string = SAY.failed): Promise<VoiceError> {
const data = await r.json().catch(() => null) as { detail?: unknown } | null;
@@ -136,11 +144,22 @@ export async function listVoices(signal: AbortSignal, f: Fetch = fetch): Promise
export async function registerVoice(wav: Blob, form: { label: string; text: string; allowAsr: boolean }, signal: AbortSignal, f: Fetch = fetch): Promise<Registered> {
const q = new URLSearchParams({ label: form.label.trim(), prompt_text: form.text.trim(), allow_asr: form.allowAsr && !form.text.trim() ? 'true' : 'false' });
const r = await call(f, `voices?${q}`, { method: 'POST', headers: { 'Content-Type': 'audio/wav' }, body: wav, signal });
if (!r.ok) throw await refusal(r);
// A 4xx is a clear refusal (nothing filed). A 5xx, or a success whose body never arrived whole, may sit after the
// panel already took the voice (HEL-902 §4: metadata write failing → 500, panel down → 503).
if (!r.ok) { const e = await refusal(r); throw r.status >= 500 ? new VoiceError(e.message, r.status, true) : e; }
const data = await r.json().catch(() => null) as Partial<Registered> | null;
if (!data || typeof data.id !== 'string' || !data.id) throw new VoiceError(SAY.odd);
if (!data || typeof data.id !== 'string' || !data.id) throw new VoiceError(SAY.odd, r.status, true);
return { id: data.id, label: typeof data.label === 'string' && data.label ? data.label : data.id, prompt_text: typeof data.prompt_text === 'string' ? data.prompt_text : '', prompt_text_source: data.prompt_text_source === 'asr' ? 'asr' : 'user' };
}
// A voice the server lists with no local metadata (label falls back to the id, no registration time): the panel has it,
// this server never recorded it — typically a registration whose answer was lost.
export const idOnly = (v: CustomVoice) => v.source === 'custom' && v.registered_at === null && v.label === v.id;
// The name a call's pinned voice is shown by: its own label if the list knows that id, else the id itself; never a guess.
export function callVoiceName(id: string | null, named: { id: string; label: string } | null): string {
if (!id) return '声音未说明';
if (id === 'default') return '默认声音';
return named?.id === id ? named.label : id;
}
// A fresh synthesis every time. Only a real WAV comes back as playable; anything else is refused, never played.
export async function previewVoice(voice: string, text: string, signal: AbortSignal, f: Fetch = fetch): Promise<Blob> {
const r = await call(f, 'preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(text.trim() ? { voice, text: text.trim() } : { voice }), signal });
+26 -11
View File
@@ -24,12 +24,16 @@ export interface VoiceView {
notice: string;
// The browser held her audio back until a tap (autoplay rules); `resume` lets it out.
blocked: boolean;
// The voice the server pinned for THIS call (HEL-903, session response `voice`): null until a room is granted;
// `id: null` when the server did not say. It stays for the whole call; a choice made in settings is the next call's.
voice: CallVoice | null;
}
export interface CallVoice { id: string | null }
// One "she speaks" (an SDK speech): her reply to your voice turn, or a stored reach-out read aloud. `open` grows by
// replacement (each interim is the whole text so far); `final` is the stored reply's exact text; `done` is closed and
// stays only until the record (or the next turn) takes its place.
export interface HerLine { speech: string; text: string; state: 'open' | 'final' | 'done'; turnId?: string; messageId?: number; proactive?: boolean }
export const OFF: VoiceView = { phase: 'off', heard: '', spoken: '', her: null, notice: '', blocked: false };
export const OFF: VoiceView = { phase: 'off', heard: '', spoken: '', her: null, notice: '', blocked: false, voice: null };
// ── cg.subtitle contract v2 (docs/voice-subtitle-contract-v2.md). Anything that is not exactly v2 is dropped. ──
const USER_KINDS = ['interim', 'user_final'] as const;
@@ -69,7 +73,9 @@ export interface VoiceRoom { publish(mic: MicTrack): Promise<void>; hush(on: boo
export interface VoiceTransport { capture(): Promise<MicTrack>; join(url: string, token: string, on: RoomEvents, signal: AbortSignal): Promise<VoiceRoom> }
export interface VoiceAvailability { available: boolean; reason: string }
export interface SessionGrant { url: string; token: string; room: string; session_id: string; epoch: number; expires_at: number }
export interface SessionGrant { url: string; token: string; room: string; session_id: string; epoch: number; expires_at: number; voice?: unknown }
// Only an id the voice list could hold is taken as the call's voice; anything else is "not said", never guessed.
export const callVoiceOf = (grant: { voice?: unknown }): CallVoice => ({ id: typeof grant.voice === 'string' && /^[\w-]{1,40}$/u.test(grant.voice) ? grant.voice : null });
export const WORDS = {
notConfigured: '语音还没有在这台服务上配置,文字仍可使用。',
@@ -109,16 +115,24 @@ interface Live { gen: number; abort: AbortController; mic?: MicTrack; room?: Voi
const AGENT_PHASE: Record<string, VoicePhase> = { initializing: 'joining', listening: 'listening', thinking: 'thinking', speaking: 'speaking' };
export async function voiceStatus(signal: AbortSignal, f: typeof fetch = fetch): Promise<VoiceAvailability> {
// What GET /api/voice/status can actually tell (HEL-903): no route on this server, not configured, configured with the
// TTS health probe passing ('ok') or failing ('down'), or unreadable. It says nothing about recognition on its own and
// never that a whole call works end to end (`voice` stays false until real-device acceptance).
export type VoiceStatusKind = 'none' | 'off' | 'ok' | 'down' | 'unknown';
export async function readVoiceStatus(signal: AbortSignal, f: typeof fetch = fetch): Promise<VoiceStatusKind> {
let response: Response;
try { response = await f('/api/voice/status', { signal }); } catch (e) { if (signal.aborted) throw e; return { available: false, reason: WORDS.unknown }; }
try { response = await f('/api/voice/status', { signal }); } catch (e) { if (signal.aborted) throw e; return 'unknown'; }
// A server from before R5 has no such route: that is "not wired", not a failure.
if (response.status === 404 || response.status === 405) return { available: false, reason: WORDS.noRoute };
if (response.status === 404 || response.status === 405) return 'none';
const data = await response.json().catch(() => null) as { configured?: unknown; tts_ok?: unknown } | null;
if (!response.ok || !data) return { available: false, reason: WORDS.unknown };
if (data.configured !== true) return { available: false, reason: WORDS.notConfigured };
if (data.tts_ok !== true) return { available: false, reason: WORDS.ttsDown };
return { available: true, reason: '' };
if (!response.ok || !data) return 'unknown';
if (data.configured !== true) return 'off';
return data.tts_ok === true ? 'ok' : 'down';
}
const REASON: Record<VoiceStatusKind, string> = { none: WORDS.noRoute, off: WORDS.notConfigured, ok: '', down: WORDS.ttsDown, unknown: WORDS.unknown };
export async function voiceStatus(signal: AbortSignal, f: typeof fetch = fetch): Promise<VoiceAvailability> {
const kind = await readVoiceStatus(signal, f);
return { available: kind === 'ok', reason: REASON[kind] };
}
function micFailure(e: unknown, secure: boolean) {
@@ -202,8 +216,9 @@ export class VoiceController {
if (!response.ok || !data?.token || !data.url || !live.session) return fail(typeof data?.detail === 'string' && data.detail ? data.detail : WORDS.unreachable);
const grant = data;
// 4. Join and speak.
this.show({ phase: 'joining' }, gen);
// 4. Join and speak. The voice this room was opened with is shown from here to the end of this call; a late grant
// of an older call never gets here (stale), and every end or failure resets the view.
this.show({ phase: 'joining', voice: callVoiceOf(grant) }, gen);
const events: RoomEvents = {
agentJoined: () => { if (stale() || live.agent) return; live.agent = true; clearTimeout(live.timer); if (!live.stated) this.show({ phase: 'listening' }, gen); },
// Her worker gone with the room still open: no one would ever answer, so the call ends honestly, exactly as a
+1 -1
View File
@@ -62,7 +62,7 @@ describe('her worker leaves while the room stays open (real adapter, SDK event n
room.say({ v: 2, seq: 3, kind: 'assistant_interim', role: 'assistant', text: '在听呢。', speech_id: 'sp' });
expect(r.view()).toMatchObject({ phase: 'thinking', her: { speech: 'sp', text: '在听呢。', state: 'open' } });
room.leave(worker.identity); await flush();
expect(r.view()).toEqual({ phase: 'off', heard: '', spoken: '', her: null, notice: WORDS.agentLeft, blocked: false });
expect(r.view()).toEqual({ phase: 'off', heard: '', spoken: '', her: null, notice: WORDS.agentLeft, blocked: false, voice: null });
expect(mics[0].stopped).toBeGreaterThan(0);
expect(room.disconnects).toBe(1);
expect(deletes(r.calls)).toEqual(['DELETE /api/voice/session/s1']);
+130
View File
@@ -0,0 +1,130 @@
// HEL-903 isolated contract mock: the HEL-878 voice session (fake LiveKit via tests/fake-livekit.js) and the HEL-900
// custom-voice endpoints in one process, answering as src/cybergril/voice/router.py does at b09a907 — the session
// answer carries `voice` (selection if still listed, else "default"), /api/voice/status carries configured/tts_ok.
// Adds the failures this card handles: a registration the panel filed while the answer was lost (500 / dropped).
// Synthetic data only: no voice panel, ASR, TTS, LiveKit or network beyond 127.0.0.1.
// Usage: node tests/hel903_mock.mjs <port>. Control GETs: /__status?mode=ok|down|off|none|500|net,
// /__session-voice?v=<id>|omit|(empty: as the server), /__fail?register=503|500|net|commit500|commitnet,
// /__select?voice=<id> (another device applies a voice), /__reset, /__log (drains), /__uploads, /__state, /__pid.
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { extname, join, normalize } from 'node:path';
import { fileURLToPath } from 'node:url';
const dist = fileURLToPath(new URL('../dist/', import.meta.url)), fake = fileURLToPath(new URL('./fake-livekit.js', import.meta.url));
const port = Number(process.argv[2] || 8903);
const T0 = Date.UTC(2026, 9, 27, 12, 0);
const identity = { id: '1', name: '栖', birth: '2000-03-14', hour: '子', relation: '朋友', nickname: '', boundary: '', color: { pillars: { year: '庚辰', month: '己卯', day: '丙午', hour: '戊子' }, traits: '合成测试档案' } };
const BUILTIN = ['bailu', 'default', 'female_soft', 'male_warm', 'rain', 'xiaoyun'];
const log = [], uploads = [], sessions = new Map();
let statusMode = 'ok', sessionVoice = '', fail = {}, custom = new Map(), orphans = new Set(), previewed = new Set(), selection = { voice: null, label: '', updated_at: null }, made = 0, rooms = 0;
const reset = () => { statusMode = 'ok'; sessionVoice = ''; fail = {}; custom = new Map(); orphans = new Set(); previewed = new Set(); selection = { voice: null, label: '', updated_at: null }; made = 0; log.splice(0); uploads.splice(0); sessions.clear(); };
const send = (res, status, body, type = 'application/json; charset=utf-8') => { res.writeHead(status, { 'Content-Type': type, 'Cache-Control': 'no-store' }); res.end(typeof body === 'string' || Buffer.isBuffer(body) ? body : JSON.stringify(body)); };
const sleep = ms => new Promise(r => setTimeout(r, ms));
const body = async req => { const parts = []; for await (const c of req) parts.push(c); return Buffer.concat(parts); };
function wavInfo(b) {
if (b.length < 44 || b.toString('ascii', 0, 4) !== 'RIFF' || b.toString('ascii', 8, 12) !== 'WAVE') return null;
let at = 12, fmt = null;
while (at + 8 <= b.length) {
const id = b.toString('ascii', at, at + 4), size = b.readUInt32LE(at + 4);
if (id === 'fmt ') fmt = { channels: b.readUInt16LE(at + 10), rate: b.readUInt32LE(at + 12), bits: b.readUInt16LE(at + 22) };
if (id === 'data') return fmt && { ...fmt, seconds: Math.min(size, b.length - at - 8) / (fmt.rate * fmt.channels * fmt.bits / 8) };
at += 8 + size + (size & 1);
}
return null;
}
function tone(seconds = 1.6, rate = 24000) {
const n = Math.round(seconds * rate), b = Buffer.alloc(44 + n * 2);
b.write('RIFF', 0); b.writeUInt32LE(36 + n * 2, 4); b.write('WAVE', 8); b.write('fmt ', 12); b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22);
b.writeUInt32LE(rate, 24); b.writeUInt32LE(rate * 2, 28); b.writeUInt16LE(2, 32); b.writeUInt16LE(16, 34); b.write('data', 36); b.writeUInt32LE(n * 2, 40);
for (let i = 0; i < n; i++) b.writeInt16LE(Math.round(Math.sin(i / 9) * 6000 * Math.min(1, i / 2000, (n - i) / 2000)), 44 + i * 2);
return b;
}
const listed = () => [...BUILTIN, ...custom.keys(), ...orphans];
const known = id => listed().includes(id);
// An orphan is in the shared panel only: no CyberGril metadata, so the router lists it with label = id.
const item = id => { const r = custom.get(id); return { id, label: r ? r.label : id, source: id.startsWith('cg-') ? 'custom' : 'builtin', selected: selection.voice === id, previewed: previewed.has(id), registered_at: r ? r.registered_at : null, prompt_text: r ? r.prompt_text : null, prompt_wav: r ? r.prompt_wav : null }; };
const take = kind => { const f = fail[kind]; delete fail[kind]; return f; };
const newId = () => `cg-${(0x5e7a0000 + ++made).toString(16)}`;
const handle = async (req, res) => {
const url = new URL(req.url, 'http://x'), p = url.pathname;
if (p.startsWith('/api/')) log.push(`${req.method} ${p}${url.search}`);
if (p === '/__pid') return send(res, 200, { pid: process.pid, mock: 'hel903' });
if (p === '/__log') return send(res, 200, log.splice(0));
if (p === '/__uploads') return send(res, 200, uploads);
if (p === '/__reset') { reset(); return send(res, 200, { ok: true }); }
if (p === '/__status') { statusMode = url.searchParams.get('mode') || 'ok'; return send(res, 200, { statusMode }); }
if (p === '/__session-voice') { sessionVoice = url.searchParams.get('v') || ''; return send(res, 200, { sessionVoice }); }
if (p === '/__fail') { fail = Object.fromEntries(url.searchParams); return send(res, 200, fail); }
if (p === '/__select') { const v = url.searchParams.get('voice'); selection = { voice: v, label: custom.get(v)?.label || v, updated_at: Date.now() }; return send(res, 200, selection); }
if (p === '/__state') return send(res, 200, { selection, custom: [...custom.keys()], orphans: [...orphans], open: [...sessions.keys()], rooms });
if (p.startsWith('/vendor/livekit-client')) return send(res, 200, await readFile(fake), 'text/javascript');
if (p === '/api/health') return send(res, 200, { ok: true, epoch: 3, llm: { ok: true, model: 'mock' }, backend: { life: true, persona: true, receipt: true, web_connected: true }, life_runtime: { state: 'running', timezone: 'Asia/Shanghai', budget_warning: false, budget_stopped: false, last: null }, capabilities: { text: true, proactive: true, voice: false, memory: true } });
if (p === '/api/companion') return send(res, 200, identity);
if (p === '/api/messages') return send(res, 200, { messages: [{ id: 1, ts: T0, role: 'user', text: '今天下雨了吗?', turn_id: 't1', status: 'sent', lifeIds: [] }, { id: 2, ts: T0 + 6e4, role: 'companion', text: '下了一点,窗台上还湿着。', turn_id: 't1', status: 'sent', lifeIds: [] }], next_before: null, epoch: 3 });
if (p === '/api/persona') return send(res, 200, { state: 'ready', interpretation: null, seeds: [], growth: [] });
if (p === '/api/life' || p === '/api/memories') return send(res, 200, { events: [], memories: [], next_before: null, epoch: 3 });
if (p === '/api/voice/status') {
if (statusMode === 'none') return send(res, 404, { detail: 'Not Found' });
if (statusMode === '500') return send(res, 500, 'Internal Server Error', 'text/plain');
if (statusMode === 'net') { res.destroy(); return; }
if (statusMode === 'off') return send(res, 200, { configured: false, tts_ok: false, agent_name: null, voice: false });
return send(res, 200, { configured: true, tts_ok: statusMode === 'ok', agent_name: 'cybergril-voice-agent', voice: false, livekit_url: 'wss://lk.invalid' });
}
if (p === '/api/voice/session' && req.method === 'POST') {
if (statusMode !== 'ok') return send(res, 503, { detail: '她的声音服务这会儿不在,稍后再试试。' });
await sleep(120);
// As _capture_voice: a still-listed selection, else "default"; captured once, at opening.
const captured = selection.voice && known(selection.voice) ? selection.voice : 'default';
const id = `sess-${++rooms}`; sessions.set(id, captured);
const grant = { url: 'wss://lk.invalid', token: `token-${id}`, room: `cg-voice-${rooms}`, session_id: id, agent_name: 'cybergril-voice-agent', epoch: 3, expires_at: Date.now() + 2 * 3600e3, voice: sessionVoice && sessionVoice !== 'omit' ? sessionVoice : captured };
if (sessionVoice === 'omit') delete grant.voice;
return send(res, 201, grant);
}
if (p.startsWith('/api/voice/session/') && req.method === 'DELETE') { const id = decodeURIComponent(p.slice(19)); return sessions.delete(id) ? send(res, 200, { ended: true }) : send(res, 404, { detail: '这次通话已经不在了。' }); }
if (p === '/api/voice/voices' && req.method === 'GET') return send(res, 200, { configured: true, voices: listed().map(item), selection: { ...selection } });
if (p === '/api/voice/voices' && req.method === 'POST') {
const data = await body(req), q = url.searchParams, info = wavInfo(data), f = take('register');
uploads.push({ type: req.headers['content-type'], bytes: data.length, info, label: q.get('label'), prompt_text: q.get('prompt_text'), allow_asr: q.get('allow_asr'), fail: f || '' });
// The panel took it, then the local metadata write failed (HEL-902 §4: OSError → 500) or the answer was lost.
// `commitnet` cuts the answer mid-body (a reset before any byte would let Chromium silently resend the POST on a
// reused socket, which is not the case this card covers).
if (f === 'commit500' || f === 'commitnet') { const lost = newId(); orphans.add(lost); if (f === 'commitnet') { res.writeHead(201, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': '400' }); res.write(`{"id":"${lost}","la`); setTimeout(() => res.destroy(), 50); return; } return send(res, 500, 'Internal Server Error', 'text/plain'); }
if (f === 'net') { res.destroy(); return; }
if (f === '503') return send(res, 503, { detail: '音色登记服务这会儿不在,稍后再试试。' });
if (f === '500') return send(res, 500, 'Internal Server Error', 'text/plain');
if (!info) return send(res, 422, { detail: '只支持 WAV 格式的参考音频。' });
const text = (q.get('prompt_text') || '').trim(), asr = q.get('allow_asr') === 'true';
if (!text && !asr) return send(res, 422, { detail: '请填写这段音频的参考文字,或明确选择让系统转写(会用到语音识别)。' });
const id = newId(), label = (q.get('label') || '').trim() || id;
const record = { id, label, prompt_text: text || '(模拟转写)今天天气很好。', prompt_text_source: text ? 'user' : 'asr', prompt_wav: `/opt/voice-agent/cosyvoice/voice_samples/${id}.wav`, registered_at: Date.now() };
custom.set(id, record);
return send(res, 201, record);
}
if (p === '/api/voice/preview' && req.method === 'POST') {
const { voice } = JSON.parse((await body(req)).toString() || '{}');
if (!known(voice)) return send(res, 404, { detail: '这个声音现在不在列表里。' });
previewed.add(voice);
return send(res, 200, tone(), 'audio/wav');
}
if (p === '/api/voice/selection' && req.method === 'GET') return send(res, 200, { ...selection });
if (p === '/api/voice/selection' && req.method === 'POST') {
const { voice } = JSON.parse((await body(req)).toString() || '{}');
if (!known(voice)) return send(res, 404, { detail: '这个声音现在不在列表里。' });
if (voice.startsWith('cg-') && !previewed.has(voice)) return send(res, 409, { detail: '这个声音还没有试听过,先试听再给她用。' });
selection = { voice, label: custom.get(voice)?.label || voice, updated_at: Date.now() };
return send(res, 200, { ...selection });
}
if (p.startsWith('/api/')) return send(res, 404, { detail: 'Not Found' });
const file = normalize(join(dist, p === '/' ? 'index.html' : p));
if (!file.startsWith(normalize(dist))) return send(res, 403, 'no', 'text/plain');
try { send(res, 200, await readFile(file), { '.js': 'text/javascript', '.css': 'text/css', '.html': 'text/html; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.webp': 'image/webp' }[extname(file)] || 'application/octet-stream'); }
catch { send(res, 200, await readFile(join(dist, 'index.html')), 'text/html; charset=utf-8'); }
};
createServer((req, res) => { handle(req, res).catch(e => { console.log(`client gave up: ${req.method} ${req.url} (${e.code || e.message})`); try { res.destroy(); } catch { /* gone */ } }); })
.on('error', e => { console.error(`hel903 mock could not listen on ${port}: ${e.code || e.message}`); process.exit(2); })
.listen(port, '127.0.0.1', () => console.log(`hel903 mock on ${port} pid ${process.pid}`));
+226
View File
@@ -0,0 +1,226 @@
// HEL-903 browser check: 外接服务 voice rows from /api/voice/status, 本次通话声音 on the call line, and recovery after a
// registration with no clear answer. Drives headless Edge over CDP against tests/hel903_mock.mjs, with
// tests/fake-livekit.js in place of the LiveKit client and Chromium's fake audio device in place of a microphone (a beep,
// not a person). No voice panel, ASR, TTS, LiveKit or real clone is involved.
// Usage: node tests/hel903_shots.mjs <edge.exe> <outDir> [port] [before]. `before` only takes the comparison shots
// (run it against a build of the code before this card; it asserts nothing about the new behaviour).
import { spawn } from 'node:child_process';
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
const [edge, out, portArg, mode] = process.argv.slice(2);
const port = Number(portArg || 8903), base = `http://127.0.0.1:${port}`, cdpPort = 9503;
mkdirSync(out, { recursive: true });
const profile = join(tmpdir(), `hel903-edge-${Date.now()}`);
const browser = spawn(edge, ['--headless=new', `--remote-debugging-port=${cdpPort}`, `--user-data-dir=${profile}`, '--no-first-run', '--hide-scrollbars', '--force-color-profile=srgb', '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required', 'about:blank'], { stdio: 'ignore' });
const sleep = ms => new Promise(r => setTimeout(r, ms));
let targets;
for (let i = 0; i < 60 && !targets; i++) { try { targets = await (await fetch(`http://127.0.0.1:${cdpPort}/json`)).json(); } catch { await sleep(250); } }
const ws = new WebSocket(targets.find(t => t.type === 'page').webSocketDebuggerUrl);
await new Promise(r => ws.addEventListener('open', r, { once: true }));
let id = 0; const waiting = new Map();
ws.addEventListener('message', ({ data }) => { const m = JSON.parse(data); if (m.id && waiting.has(m.id)) { const { ok, ko } = waiting.get(m.id); waiting.delete(m.id); m.error ? ko(new Error(m.error.message)) : ok(m.result); } });
const cdp = (method, params = {}) => new Promise((ok, ko) => { const n = ++id; waiting.set(n, { ok, ko }); ws.send(JSON.stringify({ id: n, method, params })); });
const js = async expr => { const r = await cdp('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || 'js error'); return r.result.value; };
const results = [];
const check = (name, ok, detail = '') => { results.push(ok); console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail && !ok ? ` — ${String(detail).slice(0, 300)}` : ''}`); };
const ctl = async path => (await fetch(`${base}${path}`)).json();
const shot = async name => { const { data } = await cdp('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false }); writeFileSync(join(out, `${name}.png`), Buffer.from(data, 'base64')); };
const click = async (selector, label = '') => js(`(() => { const el = [...document.querySelectorAll(${JSON.stringify(selector)})].find(e => e.offsetParent !== null && e.textContent.includes(${JSON.stringify(label)})); if (!el) return false; el.scrollIntoView({ block: 'center' }); el.click(); return true; })()`);
const exists = sel => js(`(() => { const e = document.querySelector(${JSON.stringify(sel)}); return !!e && e.offsetParent !== null; })()`);
const text = sel => js(`[...document.querySelectorAll(${JSON.stringify(sel)})].filter(e => e.offsetParent !== null).map(e => e.innerText).join('\\n')`);
const scrollTo = sel => js(`(() => { const e = document.querySelector(${JSON.stringify(sel)}); if (e) e.scrollIntoView({ block: 'center' }); return !!e; })()`);
const until = async (fn, ms = 6000) => { const end = Date.now() + ms; while (Date.now() < end) { if (await fn()) return true; await sleep(100); } return false; };
const fill = (sel, value) => js(`(() => { const el = [...document.querySelectorAll(${JSON.stringify(sel)})].find(e => e.offsetParent !== null); if (!el) return false; const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, ${JSON.stringify(value)}); el.dispatchEvent(new Event('input', { bubbles: true })); return true; })()`);
const views = { desktop: { width: 1440, height: 900, deviceScaleFactor: 1, mobile: false }, phone: { width: 390, height: 844, deviceScaleFactor: 2, mobile: true } };
async function view(v) { await cdp('Emulation.setDeviceMetricsOverride', views[v]); await cdp('Emulation.setTouchEmulationEnabled', { enabled: views[v].mobile, maxTouchPoints: 5 }); }
const PROBE = `(() => { const md = navigator.mediaDevices; if (!md) return; const orig = md.getUserMedia.bind(md); window.__tracks = [];
md.getUserMedia = async c => { const s = await orig(c); s.getTracks().forEach(t => window.__tracks.push(t)); return s; };
window.__live = () => window.__tracks.filter(t => t.readyState === 'live').length; })();`;
const live = () => js('window.__live ? window.__live() : 0');
async function boot(theme, path = '/') {
await ctl('/__reset');
await cdp('Page.navigate', { url: `${base}${path}` }); await sleep(500);
await js(`localStorage.setItem('cg.theme', ${JSON.stringify(theme)}); localStorage.removeItem('cg.voice.owed'); sessionStorage.clear(); true`);
await cdp('Page.navigate', { url: `${base}${path}` }); await sleep(1500);
}
async function settings(section) { if (!(await exists('.pane-manage.open'))) { await click('.nav-item', '设置'); await sleep(700); } await click('.snav button', section); await sleep(400); }
async function closeSettings() { await click('.pane-manage button[aria-label="关闭设置"]'); await sleep(500); if (await exists('.modal.open')) { await click('.modal.open button', '放弃修改并离开'); await sleep(400); } }
const svc = label => js(`(() => { const r = [...document.querySelectorAll('.svc')].find(e => e.offsetParent !== null && e.querySelector('.nm')?.firstChild?.textContent === ${JSON.stringify(label)}); return r ? r.querySelector('.nm small').innerText : null; })()`);
const wav = (seconds, rate = 22050) => {
const n = Math.round(seconds * rate), b = Buffer.alloc(44 + n * 2);
b.write('RIFF', 0); b.writeUInt32LE(36 + n * 2, 4); b.write('WAVE', 8); b.write('fmt ', 12); b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22);
b.writeUInt32LE(rate, 24); b.writeUInt32LE(rate * 2, 28); b.writeUInt16LE(2, 32); b.writeUInt16LE(16, 34); b.write('data', 36); b.writeUInt32LE(n * 2, 40);
for (let i = 0; i < n; i++) b.writeInt16LE(Math.round(Math.sin(i / 11) * 5000), 44 + i * 2);
return b;
};
const files = join(tmpdir(), `hel903-files-${Date.now()}`); mkdirSync(files, { recursive: true });
writeFileSync(join(files, 'reference.wav'), wav(6));
async function upload() {
const { result } = await cdp('Runtime.evaluate', { expression: `[...document.querySelectorAll('.voice-sec input[type=file]')].find(e => e.offsetParent !== null)` });
await cdp('DOM.setFileInputFiles', { files: [join(files, 'reference.wav')], objectId: result.objectId });
return until(() => exists('.voice-sec .take .pill'));
}
async function takeReady(label) { await upload(); await fill('.voice-sec .take input.input', label); await fill('.voice-sec .take textarea', '今天天气很好,我们出去走走吧。'); }
async function applyVoice(label) {
await takeReady(label);
await click('.voice-sec .take button', '登记这段声音');
await until(async () => (await text('.voice-sec .voice-list')).includes(label));
await click('.voice-sec .audition button', '生成试听'); await until(() => exists('.voice-sec .sample.ready'));
await click('.voice-sec .audition button', '给栖用这个声音');
return until(async () => (await text('.voice-sec .kv')).includes(label));
}
const phase = () => js(`document.querySelector('.voice-row.real')?.dataset.phase || 'off'`);
const pin = () => js(`document.querySelector('.voice-row.real .voice-pin')?.innerText || ''`);
async function startCall() {
await click('.voice-button'); await until(() => exists('.modal.open'));
await click('.modal.open .btn-primary', '开始语音');
await until(async () => (await phase()) === 'joining');
await until(() => js(`window.__lk?.rooms.at(-1)?.state === 'connected'`));
await js('window.__lk.join()'); await sleep(300);
}
async function endCall() { await click('.voice-row.real .voice-end', '结束语音'); await until(async () => (await phase()) === 'off'); await sleep(300); }
async function talk() { await click('.nav-item', '交谈'); await sleep(1400); }
await cdp('Page.enable'); await cdp('Runtime.enable'); await cdp('Network.enable'); await cdp('DOM.enable');
await cdp('Page.addScriptToEvaluateOnNewDocument', { source: PROBE });
check('mock is this hel903 mock', (await ctl('/__pid')).mock === 'hel903');
if (mode === 'before') {
// The same three places before this card: services rows, the call line, a registration that failed after filing.
await view('desktop'); await boot('moon');
await settings('外接服务'); await sleep(600); await shot('before-01-services');
await settings('她的声音'); await until(() => exists('.voice-sec .voice-list')); await applyVoice('轻一点的她');
await closeSettings(); await talk(); await startCall(); await shot('before-02-call'); await endCall();
await settings('她的声音'); await until(() => exists('.voice-sec .voice-list'));
await takeReady('第二个'); await ctl('/__fail?register=commit500'); await click('.voice-sec .take button', '登记这段声音'); await sleep(1200); await scrollTo('.voice-sec .take'); await shot('before-03-register-500');
browser.kill(); process.exit(0);
}
// ── Main path, both themes × desktop/phone ──
for (const [theme, v] of [['moon', 'desktop'], ['fog', 'desktop'], ['moon', 'phone'], ['fog', 'phone']]) {
const tag = `${theme}-${v}`;
await view(v); await boot(theme);
// 1. 外接服务: read from /api/voice/status, never "尚未接通" and never "已接通" for voice.
await settings('外接服务');
await until(async () => (await svc('语音合成')) && !(await svc('语音合成')).includes('正在读取'));
const tts = await svc('语音合成'), asr = await svc('语音识别');
check(`${tag}: services — TTS row says configured + health probe reachable`, tts === '已配置 · 合成服务健康检查可连接', tts);
check(`${tag}: services — ASR row claims no probe it does not have`, asr.includes('没有单独的健康检查'), asr);
check(`${tag}: services — no voice row says "尚未接通" or "已接通"`, !/尚未接通|已接通/u.test(`${tts}${asr}`));
check(`${tag}: services — reading status opens no microphone`, (await live()) === 0 && (await js('window.__tracks.length')) === 0);
await scrollTo('.svc'); await shot(`${tag}-01-services`);
// 2. Apply a voice, then call: the call line shows the voice the session answer pinned.
await settings('她的声音'); await until(() => exists('.voice-sec .voice-list'));
check(`${tag}: a voice is applied through the existing flow`, await applyVoice('轻一点的她'));
check(`${tag}: settings still says a change takes effect at the next call`, (await text('.voice-sec .kv')).includes('下一次通话'));
await closeSettings(); await talk();
await startCall();
check(`${tag}: call line shows 本次通话 voice from the session answer`, (await pin()).includes('轻一点的她'), await pin());
// 3. Another device applies a different voice mid-call: this call keeps its own.
await ctl('/__select?voice=rain');
await js(`window.__lk.say({ kind: 'interim', role: 'user', text: '你听得到吗' })`); await sleep(300);
check(`${tag}: subtitles still show beside it`, (await text('.voice-row.real')).includes('你听得到吗'));
check(`${tag}: a choice made during the call does not change this call`, (await pin()).includes('轻一点的她'), await pin());
await shot(`${tag}-02-call-pinned`);
await endCall();
check(`${tag}: ending the call releases the microphone and drops the pin`, (await live()) === 0 && !(await exists('.voice-row.real .voice-pin')));
// 4. The next call takes the new choice.
await startCall();
check(`${tag}: the next call shows the new voice`, (await pin()).includes('rain'), await pin());
await shot(`${tag}-03-next-call`);
await endCall();
// 5. A registration filed by the panel whose answer was lost: say so, offer the list, find the numbered entry.
await settings('她的声音'); await until(() => exists('.voice-sec .voice-list'));
await takeReady('第二个');
await ctl('/__fail?register=commit500');
await click('.voice-sec .take button', '登记这段声音');
check(`${tag}: 500 after filing — says it may be registered and to refresh first`, await until(async () => (await text('.voice-sec .status')).includes('可能已经登记上了')) && (await text('.voice-sec .status')).includes('刷新列表'), await text('.voice-sec .status'));
check(`${tag}: offers 刷新列表 and words a second registration as a checked choice`, (await exists('.voice-sec .take')) && (await text('.voice-sec .take .take-acts')).includes('刷新列表') && (await text('.voice-sec .take .take-acts')).includes('确认没有,再登记'));
check(`${tag}: the list is not re-read or re-registered behind your back`, (await ctl('/__uploads')).length === 2);
await scrollTo('.voice-sec .take .take-acts'); await shot(`${tag}-04-register-unsure`);
await click('.voice-sec .take button', '刷新列表');
check(`${tag}: refreshing names the new numbered entry`, await until(async () => /列表里多了「cg-/u.test(await text('.voice-sec .status'))), await text('.voice-sec .status'));
check(`${tag}: the numbered entry is marked as missing local metadata`, (await text('.voice-sec .voice-list')).includes('只有编号,缺本地资料') && (await exists('.voice-sec .voice-idonly')));
check(`${tag}: after it is found the cautious button goes away`, !(await text('.voice-sec .take .take-acts')).includes('刷新列表'));
await scrollTo('.voice-sec .voice-idonly'); await shot(`${tag}-05-found-numbered`);
await closeSettings();
}
// ── Branches, desktop moon unless noted ──
await view('desktop'); await boot('moon');
for (const [m, tts, asr, name] of [
['down', '已配置 · 合成服务这会儿暂不可用', '没有单独的健康检查', 'down'],
['off', '未配置 · 这台服务没有语音配置', '未配置', 'off'],
['none', '这台服务没有语音状态接口', '这台服务没有语音状态接口', 'no-route'],
['500', '状态未知 · 这次没读到语音状态', '状态未知', 'unknown-500'],
['net', '状态未知 · 这次没读到语音状态', '状态未知', 'unknown-net'],
]) {
await ctl(`/__status?mode=${m}`);
await settings('外接服务'); await click('.svc button', '重新读取状态');
await until(async () => !((await svc('语音合成')) || '').includes('正在读取'));
const t = await svc('语音合成'), a = await svc('语音识别');
check(`status ${name}: TTS "${tts}"`, t === tts, t);
check(`status ${name}: ASR row honest`, a.includes(asr), a);
if (m === 'down' || m === 'off') { await scrollTo('.svc'); await shot(`branch-services-${name}`); }
}
await ctl('/__status?mode=ok');
await view('phone'); await boot('fog'); await ctl('/__status?mode=down'); await settings('外接服务'); await until(async () => (await svc('语音合成')) === '已配置 · 合成服务这会儿暂不可用'); await scrollTo('.svc'); await shot('branch-services-down-fog-phone'); await ctl('/__status?mode=ok');
await view('desktop'); await boot('moon');
// The session answer without a voice field (an older server): shown as not said, never guessed from settings.
await settings('她的声音'); await until(() => exists('.voice-sec .voice-list')); await applyVoice('轻一点的她');
await ctl('/__session-voice?v=omit'); await closeSettings(); await talk(); await startCall();
check('session without voice: "声音未说明", not the settings choice', (await pin()).includes('声音未说明') && !(await pin()).includes('轻一点的她'), await pin());
await shot('branch-call-voice-unknown');
await endCall();
await ctl('/__session-voice?v=default'); await startCall();
check('session voice "default": shown as 默认声音', (await pin()).includes('默认声音'), await pin());
await endCall(); await ctl('/__session-voice');
// A dropped answer after filing; a 503 before filing (refresh finds nothing; a checked retry then succeeds).
await settings('她的声音'); await until(() => exists('.voice-sec .voice-list'));
await takeReady('断线那次'); await ctl('/__fail?register=commitnet');
await click('.voice-sec .take button', '登记这段声音');
check('dropped answer: may be registered, refresh first', await until(async () => (await text('.voice-sec .status')).includes('可能已经登记上了')), await text('.voice-sec .status'));
await click('.voice-sec .take button', '刷新列表');
check('dropped answer: refresh finds the numbered entry', await until(async () => /列表里多了「cg-/u.test(await text('.voice-sec .status'))));
await click('.voice-sec .take button', '换一段');
await takeReady('面板不在'); await ctl('/__fail?register=503');
await click('.voice-sec .take button', '登记这段声音');
check('503: keeps the server words and adds the refresh advice', await until(async () => (await text('.voice-sec .status')).includes('音色登记服务')) && (await text('.voice-sec .status')).includes('刷新列表'), await text('.voice-sec .status'));
await click('.voice-sec .take button', '刷新列表');
check('503: refresh finds nothing new and says to wait before registering again', await until(async () => (await text('.voice-sec .status')).includes('暂时没有新出现的条目')), await text('.voice-sec .status'));
await shot('branch-register-503-none');
const before = (await ctl('/__uploads')).length;
await click('.voice-sec .take button', '确认没有,再登记');
check('503: the checked retry registers once and the take is gone', await until(async () => (await text('.voice-sec .voice-list')).includes('面板不在')) && (await ctl('/__uploads')).length === before + 1 && !(await exists('.voice-sec .take')));
// Recording regression: the microphone opens on a tap and closes on stop / cancel.
await click('.voice-sec .seg button', '现在录一段');
await click('.voice-sec .rec button', '开始录音');
await until(async () => (await text('.voice-sec .rec')).includes('正在录音'));
await sleep(2600);
check('recording: microphone live while recording', (await live()) === 1);
await click('.voice-sec .rec button', '停止');
check('recording: stop keeps the take and releases the microphone', await until(() => exists('.voice-sec .take .pill'), 8000) && (await live()) === 0);
await click('.voice-sec .take button', '换一段');
await click('.voice-sec .rec button', '开始录音'); await until(async () => (await text('.voice-sec .rec')).includes('正在录音'));
await click('.voice-sec .rec button', '取消');
check('recording: cancel releases the microphone', (await live()) === 0);
await closeSettings();
// Demo entry: nothing goes to any server from settings (services, her voice) or the conversation.
await boot('moon', '/?demo=1'); await ctl('/__log');
await settings('外接服务'); await sleep(500); await settings('她的声音'); await sleep(500); await closeSettings();
await click('.nav-item', '交谈'); await sleep(1200);
const demoLog = await ctl('/__log');
check('demo: zero /api requests from settings and the conversation', demoLog.length === 0, demoLog.join(' | '));
await settings('外接服务'); await shot('demo-services');
const passed = results.filter(Boolean).length;
console.log(`\n${passed}/${results.length} passed`);
browser.kill();
process.exit(passed === results.length ? 0 : 1);
+122
View File
@@ -0,0 +1,122 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { callVoiceOf, readVoiceStatus, VoiceController, type MicTrack, type RoomEvents, type VoiceRoom, type VoiceView } from '../src/voice/session';
import { callVoiceName, idOnly, registerVoice, SAY, VoiceError, type CustomVoice } from '../src/voice/custom';
// HEL-903 limited rework, offline only: fake fetch, fake room and microphone. No LiveKit, voice panel, ASR or TTS.
const gate = () => { let open!: () => void; const promise = new Promise<void>(r => { open = r; }); return { promise, open }; };
const flush = async (n = 8) => { for (let i = 0; i < n; i++) await Promise.resolve(); await new Promise(r => setTimeout(r, 0)); };
const signal = () => new AbortController().signal;
const reply = (body: unknown, status = 200) => new Response(typeof body === 'string' ? body : JSON.stringify(body), { status });
afterEach(() => { vi.unstubAllGlobals(); });
describe('外接服务 reads only what /api/voice/status can tell', () => {
const kind = (answer: () => Response | Promise<Response>) => readVoiceStatus(signal(), (async () => answer()) as unknown as typeof fetch);
it('tells not configured, reachable, down, no route and unreadable apart', async () => {
expect(await kind(() => reply({ configured: false, tts_ok: false, agent_name: null, voice: false }))).toBe('off');
expect(await kind(() => reply({ configured: true, tts_ok: true, voice: false }))).toBe('ok');
expect(await kind(() => reply({ configured: true, tts_ok: false, voice: false }))).toBe('down');
expect(await kind(() => reply({ detail: 'Not Found' }, 404))).toBe('none');
expect(await kind(() => reply('oops', 500))).toBe('unknown');
expect(await kind(() => reply('not json'))).toBe('unknown');
expect(await kind(() => { throw new TypeError('Failed to fetch'); })).toBe('unknown');
});
it('a configured flag alone is never "reachable"', async () => {
expect(await kind(() => reply({ configured: true }))).toBe('down');
});
});
// ── The call's own voice ──
class Mic implements MicTrack { stopped = 0; stop() { this.stopped++; } onEnded() {} }
class Room implements VoiceRoom { closed = 0; on!: RoomEvents; async publish() {} hush() {} async resumeAudio() {} async close() { this.closed++; } }
function rig() {
const views: VoiceView[] = [], rooms: Room[] = [], voices: unknown[] = [], gates: (ReturnType<typeof gate> | undefined)[] = [];
let made = 0;
const fetch = vi.fn(async (url: string, init: RequestInit = {}) => {
if (url === '/api/voice/status') return reply({ configured: true, tts_ok: true, voice: false });
if (url === '/api/voice/session' && init.method === 'POST') {
const n = made++, g = gates[n]; if (g) await g.promise;
const grant: Record<string, unknown> = { url: 'wss://lk.test', token: `t${n}`, room: `r${n}`, session_id: `s${n}`, epoch: 3, expires_at: 0 };
if (voices[n] !== undefined) grant.voice = voices[n];
return reply(grant, 201);
}
return reply({ ended: true });
});
const transport = { capture: async () => new Mic(), join: async (_u: string, _t: string, on: RoomEvents) => { const r = new Room(); r.on = on; rooms.push(r); return r; } };
const c = new VoiceController({ transport: async () => transport, onView: v => views.push(v), onRecord: () => {}, onSpoke: () => {}, fetch: fetch as unknown as typeof fetch, secure: () => true, agentWaitMs: 1000 });
return { c, views, rooms, voices, gates, view: () => views.at(-1)! };
}
describe('本次通话声音 comes from the session answer and stays for the call', () => {
it('shown from the granted room, unchanged through the call, gone when it ends', async () => {
const r = rig(); r.voices[0] = 'cg-1a2b3c4d';
await r.c.start();
expect(r.views.find(v => v.phase === 'opening')?.voice).toBeNull();
expect(r.view()).toMatchObject({ phase: 'joining', voice: { id: 'cg-1a2b3c4d' } });
r.rooms[0].on.agentJoined(); r.rooms[0].on.agentState('speaking'); r.rooms[0].on.agentState('listening');
expect(r.views.filter(v => v.phase !== 'opening').every(v => v.voice?.id === 'cg-1a2b3c4d')).toBe(true);
await r.c.end();
expect(r.view()).toMatchObject({ phase: 'off', voice: null });
});
it('a missing or odd field is "not said", never a guess', () => {
expect(callVoiceOf({})).toEqual({ id: null });
expect(callVoiceOf({ voice: '' })).toEqual({ id: null });
expect(callVoiceOf({ voice: 42 })).toEqual({ id: null });
expect(callVoiceOf({ voice: '<b>x</b>' })).toEqual({ id: null });
expect(callVoiceOf({ voice: 'default' })).toEqual({ id: 'default' });
});
it('an older call answering late never overwrites the newer call', async () => {
const r = rig(); r.voices[0] = 'old-voice'; r.voices[1] = 'cg-00000002'; r.gates[0] = gate();
const first = r.c.start(); await flush();
await r.c.start();
expect(r.view()).toMatchObject({ phase: 'joining', voice: { id: 'cg-00000002' } });
r.gates[0]!.open(); await first; await flush();
expect(r.view()).toMatchObject({ phase: 'joining', voice: { id: 'cg-00000002' } });
expect(r.views.some(v => v.voice?.id === 'old-voice')).toBe(false);
});
it('a failure after the room clears the voice with the call', async () => {
const r = rig(); r.voices[0] = 'rain';
await r.c.start(); r.rooms[0].on.agentJoined();
r.rooms[0].on.dropped(); await flush();
expect(r.view()).toMatchObject({ phase: 'off', voice: null });
});
it('names: its own label for that id only, the id otherwise, default and unknown in words', () => {
expect(callVoiceName('cg-1', { id: 'cg-1', label: '轻一点的她' })).toBe('轻一点的她');
expect(callVoiceName('cg-2', { id: 'cg-1', label: '轻一点的她' })).toBe('cg-2');
expect(callVoiceName('default', null)).toBe('默认声音');
expect(callVoiceName(null, null)).toBe('声音未说明');
});
});
describe('a registration with no clear answer says to look before registering again', () => {
const wav = new Blob([new Uint8Array(64)], { type: 'audio/wav' }), form = { label: 'x', text: '你好', allowAsr: false };
const attempt = async (answer: () => Response | Promise<Response>) => {
try { await registerVoice(wav, form, signal(), (async () => answer()) as unknown as typeof fetch); } catch (e) { return e as VoiceError; }
throw new Error('expected a failure');
};
it('500 / 503 / a dropped answer / an unreadable success may have been filed', async () => {
for (const e of [await attempt(() => reply({ detail: 'Internal Server Error' }, 500)), await attempt(() => reply({ detail: '音色登记服务这会儿不在,稍后再试试。' }, 503)), await attempt(() => { throw new TypeError('network'); }), await attempt(() => reply('', 201))]) {
expect(e).toBeInstanceOf(VoiceError);
expect(e.uncertain).toBe(true);
}
expect((await attempt(() => reply({ detail: '音色登记服务这会儿不在,稍后再试试。' }, 503))).message).toContain('音色登记服务');
});
it('a clear refusal (4xx) or a request never sent is not "maybe filed"', async () => {
expect((await attempt(() => reply({ detail: '参考音频太短(至少 2 秒),请录 5–20 秒。' }, 422))).uncertain).toBe(false);
expect((await attempt(() => reply({ detail: '还没有创建真实伴侣,语音也不会开始。' }, 409))).uncertain).toBe(false);
vi.stubGlobal('navigator', { onLine: false });
const offline = await attempt(() => reply({}, 201));
expect(offline.message).toBe(SAY.offline);
expect(offline.uncertain).toBe(false);
});
it('the advice points at refreshing the list and the numbered entries', () => {
expect(SAY.maybeRegistered).toContain('刷新列表');
expect(SAY.maybeRegistered).toContain('cg-');
expect(SAY.maybeRegistered).toContain('不用再登记');
});
it('an entry without local metadata is recognised as id-only', () => {
const v = (x: Partial<CustomVoice>): CustomVoice => ({ id: 'cg-9', label: 'cg-9', source: 'custom', selected: false, previewed: false, registered_at: null, ...x });
expect(idOnly(v({}))).toBe(true);
expect(idOnly(v({ label: '轻一点的她', registered_at: 1 }))).toBe(false);
expect(idOnly(v({ id: 'rain', label: 'rain', source: 'builtin' }))).toBe(false);
});
});