From 1c2944fcf4948bfbdba4bd981417c82c99ed4fdb Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 26 Oct 2026 13:32:04 +0800 Subject: [PATCH] =?UTF-8?q?docs(voice):=20=E6=94=B6=E5=9B=9E=20r2=20?= =?UTF-8?q?=E4=BF=AE=E6=8E=89=E7=9A=84=E6=97=A7=E8=AF=AD=E4=B9=89=E6=B3=A8?= =?UTF-8?q?=E9=87=8A=20+=20=E8=AE=A9=E5=85=B3=E9=97=AD=E6=80=81=E5=8F=98?= =?UTF-8?q?=E5=BC=82=E7=9C=9F=E8=83=BD=E6=89=93=E7=BA=A2=EF=BC=88r3=20P2-1?= =?UTF-8?q?/P2-2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2-1(两处注释在描述已被推翻的旧语义,会把 r2 的修复写回去): - voice.go 的 voiceReplySink 文档:原写「personaID = env.PersonaID,会话无绑定时为空」 ⇒ 实际传的是 turnPersona.Effective,**未绑定 ≠ 空**(有全局默认人格时非空)。 改为写明「生效人格(会话绑定 → 全局默认人格;两者都没有才是空)」,并显式指出 它不是 env.PersonaID、把空当"没有角色"会让主会话落到 default(正是 r2 修掉的)。 - voice.go 的 synthesizeVoiceReply:原写「空 personaID = 会话无绑定 ⇒ 走 default」 ⇒ 反了。改为「空 = 连全局默认人格都没有 ⇒ 才走 voice-tts.json 的 default」。 - 同族 format 残留措辞:client_test.go 的「非法格式的回落」(该行为已不存在)、 cmd/cyrene/wire_voice_tts_test.go 的「角色/格式/超时按文件来」都改写。 - nit:voice.go 的 `env, turnPersona := ...` 遮蔽同包类型名,改成 `tp`。 P2-2(我上轮那条「摘掉 PrimaryActive 闸 ⇒ 用例红」不成立,复审实测仍绿): 根因是 audioStubVoice 不实现 PrimaryActive,拦住 persona 读取的只有类型断言。 按塔选的第二种收口(把测试做强): - audioStubVoice 补 `PrimaryActive() bool { return true }`(它是"生效中的 persona voice"替身),并在注释里说明它存在的理由; - 关闭态用例改用新替身 inactivePersonaVoice(实现 personaVoice 但报 false), 于是 PrimaryActive 成为关闭态唯一的闸; - 重跑变异(摘掉 `&& pv.PrimaryActive()`)**实测打红**: 「未生效的 personaVoice 却查了 1 次会话人格(关闭态行为变了)」。 未动:功能面、错误码、500 状态码、白名单、session 可读、隐私/format/Stats/redactURL。 go build ./... + go vet ./... + go test -count=1 -race 覆盖 tts/runtimeconfig/gateway/ cmd/cyrene 全绿。 --- cmd/cyrene/wire_voice_tts_test.go | 3 ++- internal/runtime/voice/tts/client_test.go | 3 ++- internal/services/gateway/audio_test.go | 10 ++++++++ internal/services/gateway/voice.go | 22 ++++++++++------ .../services/gateway/voice_emotiontts_test.go | 25 ++++++++++++++----- 5 files changed, 47 insertions(+), 16 deletions(-) diff --git a/cmd/cyrene/wire_voice_tts_test.go b/cmd/cyrene/wire_voice_tts_test.go index c1d04d8..79ab913 100644 --- a/cmd/cyrene/wire_voice_tts_test.go +++ b/cmd/cyrene/wire_voice_tts_test.go @@ -3,7 +3,8 @@ package main // 本文件锁定切片 M35 在**组合根**上的三条口径(都是"改坏就静默不工作/静默开错"的那种): // // 1. 面板文件缺失 ⇒ **这一级不启用**,返回的仍是既有链本身(行为逐字不变); -// 2. 文件里 `enabled:true` ⇒ 返回 emotionTTS 门面,角色/格式/超时按文件来; +// 2. 文件里 `enabled:true` ⇒ 返回 emotionTTS 门面,`personas`/`default`/`timeoutSec` +// 按文件来(没有"角色"或"格式"这种全局键:发音人只能来自 persona 映射); // 3. 文件坏掉 ⇒ **不启用**(宁可没有这一级,也不拿读不懂的配置去驱动外部服务)。 // // 它用 `DefaultVoiceTTSPath` 的真实落点($DATA_DIR/config/voice-tts.json)跑, diff --git a/internal/runtime/voice/tts/client_test.go b/internal/runtime/voice/tts/client_test.go index f577208..1582748 100644 --- a/internal/runtime/voice/tts/client_test.go +++ b/internal/runtime/voice/tts/client_test.go @@ -212,7 +212,8 @@ func TestSpeechGuards(t *testing.T) { } } -// TestConfigNormalized 钉住默认值与非法格式的回落(不让一个写错的格式关掉语音面)。 +// TestConfigNormalized 钉住默认值的补齐与空白/键的归一化(**没有**格式项了:那个 +// 假开关已删除,请求体里的 `response_format` 恒为 wav,见 requestFormat)。 // // 🔴 它同时钉住一条**反向**口径:归一化**不得**给任何角色兜底 —— 代码里写死一个 // 默认发音人,正是主人点名的病害(换人格仍用老声音)。 diff --git a/internal/services/gateway/audio_test.go b/internal/services/gateway/audio_test.go index 44f01ab..2bae8ca 100644 --- a/internal/services/gateway/audio_test.go +++ b/internal/services/gateway/audio_test.go @@ -51,6 +51,16 @@ func (s *audioStubVoice) CloneVoice(ctx context.Context, referenceURL, prefix st return s.cloneVoiceID, s.cloneErr } +// PrimaryActive 让本替身**实现**网关的 `personaVoice` 扩展并报告"按 persona 选发音 +// 的那一级正在生效"(复审 r3 P2-2)。 +// +// 🔴 它存在是为了让"关闭态不查库"那条用例的变异**真能打红**:此前 `audioStubVoice` +// 不实现 `PrimaryActive`,于是拦下 persona 读取的只有 `s.voice.(personaVoice)` 这个 +// **类型断言**,把 `&& pv.PrimaryActive()` 摘掉用例照样绿 —— 等于那条变异没被覆盖。 +// 现在类型断言会通过,拦住它的只剩这个布尔值。要表达"未生效"的替身请用 +// `inactivePersonaVoice`(`voice_emotiontts_test.go`)。 +func (s *audioStubVoice) PrimaryActive() bool { return true } + func TestHandleTranscribe(t *testing.T) { env := newEnv(t, nil, false) sv := &audioStubVoice{sttText: "你好世界"} diff --git a/internal/services/gateway/voice.go b/internal/services/gateway/voice.go index 6aee011..c05d0ac 100644 --- a/internal/services/gateway/voice.go +++ b/internal/services/gateway/voice.go @@ -176,17 +176,17 @@ func (h *hub) runVoiceTurn(c *wsConn, req voiceInputRequest) { // 角色来自该会话的绑定(design/22 §4.5)。 // 语音收尾要用**生效人格**选音色(会话绑定 → 全局默认人格),而 env 只带绑定 // (loop 自己回退)。这里**复用同一次会话读取**,不为选音色多查一次库。 - env, turnPersona := h.envResolver.ownerWithPersona(ctx, req.SessionID) + env, tp := h.envResolver.ownerWithPersona(ctx, req.SessionID) msgs := []model.Message{model.TextMessage(model.RoleUser, transcript)} // 两条「怎么回到这条连接」的通路都由本连接给出:收尾回调(音频回复,C1)与 // 定向拒绝回调(队列满,G5)。编排器只把它们当成本回合的传输线索。 - ctx = withVoiceReplySink(ctx, h.voiceReplySink(c, req.SessionID, req.DeviceID, turnPersona.Effective, transcript)) + ctx = withVoiceReplySink(ctx, h.voiceReplySink(c, req.SessionID, req.DeviceID, tp.Effective, transcript)) ctx = withPendingFullNotifier(ctx, c.notifyPendingFull) if h.orchestrator != nil { h.orchestrator.Submit(ctx, req.SessionID, msgs, env) return } - h.runVoiceTurnDirect(ctx, c, req.SessionID, req.DeviceID, turnPersona.Effective, transcript, env) + h.runVoiceTurnDirect(ctx, c, req.SessionID, req.DeviceID, tp.Effective, transcript, env) } // runVoiceTurnDirect 是未接编排器时的旧直跑路径(未接线的构建 / 裸 hub 单元 @@ -206,8 +206,13 @@ func (h *hub) runVoiceTurnDirect(ctx context.Context, c *wsConn, sessionID, devi // **定向**回发起它的那根连接。它是 C1 的核心接线点 —— 回合异步完成,只有结算处 // 知道助手正文。 // -// personaID 是**本回合**解析出的角色(`env.PersonaID`,会话无绑定时为空)。它随 -// 回调一起传下去,使合成按这一回合的角色选发音人 —— 而不是进程启动时定死一个。 +// personaID 是**本回合的生效人格**(会话绑定 → 全局默认人格;两者都没有才是空, +// 见 `turnEnvResolver.resolvePersona`)。🔴 它**不是** `env.PersonaID` +// (那是"绑定本身",空表示继承,由 loop 自己回退)—— 未绑定 ≠ 空:主会话永远未绑定, +// 但只要有全局默认人格,这里就非空。把空当成"没有角色"会让主会话的声音落到 +// `voice-tts.json` 的 `default`(复审 r2 P2-1 修掉的正是这个)。 +// +// 它随回调一起传下去,使合成按这一回合的角色选发音人 —— 而不是进程启动时定死一个。 // 它不是身份输入(身份仍由 actor 元数据决定),只决定用哪个上游音色。 // // 连接已断(客户端走了)时记一条带来源的 WARN,绝不静默丢:那正是「静默成功但 @@ -230,7 +235,7 @@ func (h *hub) voiceReplySink(c *wsConn, sessionID, deviceID, personaID, transcri // 能提示「没生成声音」(绝不是只回一条错误帧就完事); // - 没有助手正文(被取消 / 空回复)时不打一次无谓的 TTS,直接回降级帧 —— // 客户端总能看到这条语音回合的终点,而不是永远等一个不会来的 voice_output; -// - **发音人按本回合的 personaID 现解析**(`voicetts.WithPersonaID`):换人格后 +// - **发音人按本回合的生效人格现解析**(`voicetts.WithPersonaID`):换人格后 // 绝不会继续用老角色的声音(主人 2026-10-26 的硬要求); // - 日志只带会话与设备 id,**绝不记正文/音频**。 func (h *hub) synthesizeVoiceReply(ctx context.Context, c *wsConn, sessionID, deviceID, personaID, transcript, reply string) { @@ -239,8 +244,9 @@ func (h *hub) synthesizeVoiceReply(ctx context.Context, c *wsConn, sessionID, de emitVoiceOutput(c, sessionID, deviceID, transcript, "", voice.Audio{}, providerSignal("", true)) return } - // 把**本回合**的角色交给语音面选发音人;空 personaID = 会话无绑定 ⇒ 语音面 - // 走它的 default 分支(没配 default 就不合成,文本已发出)。 + // 把**本回合的生效人格**交给语音面选发音人。空 = **连全局默认人格都没有** + // (不是"会话无绑定"——未绑定但存在全局默认人格时这里非空)⇒ 语音面才走 + // `voice-tts.json` 的 `default`(没配则不合成,文本已发出)。 ctx = voicetts.WithPersonaID(ctx, personaID) audi, rung, err := ttsWithProvider(ctx, h.voice, reply) if err != nil { diff --git a/internal/services/gateway/voice_emotiontts_test.go b/internal/services/gateway/voice_emotiontts_test.go index aac1329..257eeff 100644 --- a/internal/services/gateway/voice_emotiontts_test.go +++ b/internal/services/gateway/voice_emotiontts_test.go @@ -232,14 +232,19 @@ func TestVoiceOutputFrameVoiceFollowsTurnPersona(t *testing.T) { // TestSpeechEndpointDisabledVoiceDoesNotReadPersona 是**关闭态零行为变化**的自证 // (复审要求在当前 tip 上重跑的那一条,而我这一版动了 `gateway/audio.go`): // -// 注入的语音服务不是"按 persona 选发音"的那一类(既有链 / stub)时,语音合成 -// **一次都不查会话人格** —— 与引入本切片之前逐字同行为;反之(真 emotionTTS 门面) -// 才会查,说明这个闸不是"永远不查"。 +// 注入的语音服务**实现了 personaVoice 却报告未生效**(`inactivePersonaVoice`, +// 模拟"门面在、这一级关着")时,语音合成**一次都不查会话人格** —— 与引入本切片 +// 之前逐字同行为;反之(真 emotionTTS 门面,PrimaryActive=true)才会查,说明这个闸 +// 不是"永远不查"。 // -// 变异:去掉 handleSpeech 里的 `pv.PrimaryActive()` 闸 ⇒ 本用例第一段红。 +// 变异:去掉 handleSpeech 里的 `&& pv.PrimaryActive()`(只留类型断言)⇒ 本用例 +// 第一段红。🔴 这条变异此前**打不红**,因为替身不实现 `PrimaryActive`,拦住读库的 +// 只有类型断言(复审 r3 P2-2);现在替身实现了它,布尔值才是唯一的闸。 func TestSpeechEndpointDisabledVoiceDoesNotReadPersona(t *testing.T) { env := newEnv(t, nil, false) - stub := &audioStubVoice{ttsAudio: mvoice.Audio{Format: "wav", Data: fakeWAV("chain")}} + stub := &inactivePersonaVoice{audioStubVoice: audioStubVoice{ + ttsAudio: mvoice.Audio{Format: "wav", Data: fakeWAV("chain")}, + }} env.srv.SetVoice(stub) counter := &countingHistory{fakeHistory: &fakeHistory{}} env.srv.envResolver = turnEnvResolver{history: counter, log: env.srv.log} @@ -252,7 +257,7 @@ func TestSpeechEndpointDisabledVoiceDoesNotReadPersona(t *testing.T) { t.Fatalf("status = %d, body = %s", resp.StatusCode, body) } if got := counter.Calls(); got != 0 { - t.Fatalf("非 persona 语音服务却查了 %d 次会话人格(关闭态行为变了)", got) + t.Fatalf("未生效的 personaVoice 却查了 %d 次会话人格(关闭态行为变了)", got) } if string(body) != string(fakeWAV("chain")) { t.Fatalf("关闭态的音频不是既有链产物: %q", string(body)) @@ -295,6 +300,14 @@ func (h *countingHistory) Calls() int { return h.calls } +// inactivePersonaVoice 是"**实现了** personaVoice 但报告这一级未生效"的替身 +// (模拟 emotionTTS 门面存在、`enabled` 关着的情形)。它让 `PrimaryActive()` 成为 +// 关闭态唯一的闸 —— 这样把 `&& pv.PrimaryActive()` 摘掉的变异才会打红。 +type inactivePersonaVoice struct{ audioStubVoice } + +// PrimaryActive 报告"按 persona 选发音"的那一级**未**生效。 +func (i *inactivePersonaVoice) PrimaryActive() bool { return false } + // asString 安全地取一个 string 值(帧载荷是 map[string]any)。 func asString(v any) string { s, _ := v.(string)