diff --git a/internal/config/runtimeconfig/voicetts.go b/internal/config/runtimeconfig/voicetts.go index 8f1018d..1f61e6a 100644 --- a/internal/config/runtimeconfig/voicetts.go +++ b/internal/config/runtimeconfig/voicetts.go @@ -3,7 +3,7 @@ // `$DATA_DIR/config/`;调优/门槛/上限/开关类参数走这里 + 代码默认值」)。 // // 为什么需要它:把半云端的 emotionTTS 接成她的一级发音人,需要几项运维旋钮 —— -// 开关、地址、**角色映射**、格式、超时;而 `.env` 已被主人冻结(**零新 env 键**, +// 开关、地址、**角色映射**、超时;而 `.env` 已被主人冻结(**零新 env 键**, // design/13 §2.1),所以它们落在这里,与 `memory-tidy.json` / `backthink.json` // 同一家族口径。 // diff --git a/internal/runtime/voice/tts/service.go b/internal/runtime/voice/tts/service.go index 81899a8..4d4aacc 100644 --- a/internal/runtime/voice/tts/service.go +++ b/internal/runtime/voice/tts/service.go @@ -16,9 +16,17 @@ package tts // 记一条带来源的 WARN + 计数(`Client.Stats`),然后回落既有链;既有链再失败才算 // 整体失败,而那时聊天正文早已发出/落库(TTS 是呈现层的增强,不是回复的前置条件)。 // -// 🔴 **发音人按当次请求的 persona 现算**(主人 2026-10-26 的硬要求):每次 +// 🔴 **发音人按当次请求的生效人格现算**(主人 2026-10-26 的硬要求):每次 // `TTSWithProvider` 都从 ctx 取 personaId、经 `Config.Resolve` 得出 character, -// **没有任何跨请求缓存**。因此"换人格后用老角色的声音"不可能发生。 +// **没有任何跨请求缓存**。 +// +// 补充限定(复审 r2 P2-1 要求收回原来的绝对说法):本包看到的 personaId 是**调用方 +// 解析出来的生效人格**(`chat_sessions.persona_id` → 全局默认人格)。"换人格后仍用 +// 老角色声音"在**本包内**不可能发生(无缓存 + 无兜底角色 + 未映射即拒绝);但若 +// 调用方把"会话未绑定"当成"没有角色"直接传空串,主会话就会落到配置里的 `default` +// —— 那不是本包能判断的,因此**调用方必须传生效人格**(网关两处已按此修, +// `turnEnvResolver.effectivePersona`)。`default` 的唯一合法用途只剩"连默认人格 +// 都没有"这一种情形。 import ( "context" diff --git a/internal/services/gateway/audio.go b/internal/services/gateway/audio.go index 3162d73..a22227b 100644 --- a/internal/services/gateway/audio.go +++ b/internal/services/gateway/audio.go @@ -2,6 +2,7 @@ package gateway import ( "encoding/json" + "errors" "net/http" "strconv" "strings" @@ -115,21 +116,27 @@ func (s *Server) handleSpeech(w http.ResponseWriter, r *http.Request) { if req.Instruction != "" { ctx = voice.WithTTSInstruction(ctx, req.Instruction) } - // 发音人按**本次请求的会话**所绑定的人格解析(切片 M35)。空 sessionId 或 - // 会话无绑定 ⇒ personaID 为空 ⇒ 语音面走 default 分支(没有 default 就不合成)。 + // 发音人按**本次请求的会话**的**生效人格**解析(切片 M35;复审 r2 P2-1): + // 会话绑定 → 全局默认人格 → 都没有才是空。空不等于"没有角色" —— 主会话 + // (`web`)的 persona_id 恒为空且没有重绑路径,把它当空会让主会话的声音不跟 + // 全局人格走(主人抱怨的正是这件事)。 + // // 解析只做一次、只服务本次请求,绝不在注入时算一次并缓存成全局。 // // 🔴 只有"真的会按 persona 选发音"的语音服务才做这次库读取:未启用 emotionTTS // 时注入的是既有链本身,此时本函数**不额外查库、不改 ctx** —— 关闭态与引入本 // 切片之前逐字同行为(复审要求自证的那一条)。 if pv, ok := s.voice.(personaVoice); ok && pv.PrimaryActive() { - ctx = voicetts.WithPersonaID(ctx, s.envResolver.sessionPersona(ctx, req.SessionID)) + ctx = voicetts.WithPersonaID(ctx, s.envResolver.effectivePersona(ctx, req.SessionID)) } audi, rung, err := ttsWithProvider(ctx, s.voice, req.Text) if err != nil { s.auditClaims(r, "audio.speech", "error") s.log.Warn("voice speech", "err", err) - writeError(w, apperr.Wrap(apperr.ErrInternal, "tts_failed", "speech synthesis failed")) + // 状态码统一 500(服务端配置缺失,不是客户端的错),但**错误码可区分**: + // "这个角色没有发音人"与"合成失败了"是两种运维动作(复审 r2 建议 3)。 + code, msg := speechErrorCode(err) + writeError(w, apperr.Wrap(apperr.ErrInternal, code, "%s", msg)) return } if len(audi.Data) == 0 { @@ -153,6 +160,25 @@ type voiceCloneRequest struct { Prefix string `json:"prefix,omitempty"` } +// speechErrorCode 把合成失败分类成**可区分**的错误码(状态码一律由调用方定 500)。 +// +// 两种"没有声音"必须能分开排查: +// - `tts_persona_unmapped`:这个角色在 `voice-tts.json` 的 `personas` 里没有映射 +// —— 运维要在面板里补一条(**绝不**用别的角色顶); +// - `tts_no_character`:连生效人格都解析不到、且没配 `default` —— 运维要配 default +// 或先选定人格; +// - `tts_failed`:真正的合成失败(上游非 200 / 非音频 / 超时等)。 +func speechErrorCode(err error) (code, msg string) { + switch { + case errors.Is(err, voicetts.ErrPersonaUnmapped): + return "tts_persona_unmapped", "this persona has no speech character configured" + case errors.Is(err, voicetts.ErrNoCharacter): + return "tts_no_character", "no speech character for this request" + default: + return "tts_failed", "speech synthesis failed" + } +} + // handleVoiceClone 经百炼声音注册 API(声音复刻 create_voice)从公开 // 参考音频 URL 创建自定义 TTS 音色,并返回新的 // voice_id。仅 owner + 带审计;参考 URL 绝不写入审计 diff --git a/internal/services/gateway/persona_env.go b/internal/services/gateway/persona_env.go index 485b728..77057d7 100644 --- a/internal/services/gateway/persona_env.go +++ b/internal/services/gateway/persona_env.go @@ -72,10 +72,20 @@ type turnEnvResolver struct { log *slog.Logger } +// defaultPersonaID 返回库中被标记为默认的人格 id(管理端记忆检索的回退用)。 +// 它转调解析器的同一实现,避免"两处各扫一遍 List()"而口径分叉。 +func (s *Server) defaultPersonaID() string { + return defaultPersonaIDFrom(s.personas) +} + // owner 返回 sessionID 一个面向 owner 回合的每回合环境: // 全访问 owner env(身份不变)加上该会话绑定的角色。 // 空白角色意味着「继承全局默认」;loop 执行那最后一次 -// 回退,因此网关永远不必知道默认角色的 id。 +// 回退(`Options.PersonaID`),因此 **loop 的输入**是绑定本身而不是生效人格。 +// +// 🔴 呈现层(TTS 音色)要的是**生效人格**,不是绑定 —— 主会话绑定恒为空,把它当成 +// 「没有角色」就会让主会话的声音不跟全局人格走。取生效人格请用 `effectivePersona` +// / `ownerWithPersona`(同一份数据、同一次会话读取)。 // // 构造走 loop.OwnerEnvFor —— 与 IM 的主人私聊车道**同一个入口** // (channelbridge/transcript.go 的 turnEnv):两条车道的 runEnv 因此逐字段同源, @@ -107,15 +117,66 @@ func (t turnEnvResolver) sessionPersona(ctx context.Context, sessionID string) s return personaID } -// defaultPersonaID 返回库中默认角色的人格 id,未接线人格 -// 存储或没有角色被标记为默认时返回 ""。它是 -// owner.yaml:defaultPersona 的网关侧视图,用于在没有会话时 -// 需要角色 id 的场合(管理记忆搜索回退,design/22 §4.5)。 -func (s *Server) defaultPersonaID() string { - if s.personas == nil { +// turnPersona 是"这一回合由哪个角色应答"的**单次解析**结果: +// +// - Binding 是会话的**显式**绑定(`chat_sessions.persona_id`;空 = 继承全局默认); +// - Effective 是**生效人格** —— 会话有绑定就取绑定,否则取全局默认人格。 +// +// 两者刻意分开:`loop.SessionEnv.PersonaID` 要的是**绑定**(空由 loop 自己回退到 +// `Options.PersonaID`,那是既有的唯一回退点,不许在这里替它决定);而**呈现层** +// (TTS 音色、前端头像/名字/心情)要的是**生效人格** —— 前端 `lib/persona.ts` 的 +// `bound ?? defaultPersona(personas)` 就是这个口径。 +// +// 🔴 为什么必须有 Effective:主会话(`web`)的 persona_id **恒为空且没有重绑路径** +// (`session_persona_test.go`),而"空"的真实语义是「继承全局默认人格」。若把空 +// 当成"没有生效人格",主会话换全局人格后声音就不会跟 —— 这正是主人抱怨的那件事, +// 而且发生在唯一能给普通聊天出声的路径上(复审 r2 P2-1)。 +type turnPersona struct { + // Binding 是会话显式绑定(可能为空)。 + Binding string + // Effective 是生效人格(会话绑定 → 全局默认人格 → 空)。 + Effective string +} + +// resolvePersona 一次解析出 Binding 与 Effective(**一次**会话读取,零重复 I/O)。 +func (t turnEnvResolver) resolvePersona(ctx context.Context, sessionID string) turnPersona { + binding := t.sessionPersona(ctx, sessionID) + if binding != "" { + return turnPersona{Binding: binding, Effective: binding} + } + return turnPersona{Effective: t.defaultPersonaID()} +} + +// effectivePersona 返回该会话的**生效人格**:有绑定取绑定,否则取全局默认人格。 +// 两者都没有时返回 ""(= 连默认人格都没配),由调用方按"没有角色"处置。 +func (t turnEnvResolver) effectivePersona(ctx context.Context, sessionID string) string { + return t.resolvePersona(ctx, sessionID).Effective +} + +// ownerWithPersona 与 owner 同义,但**顺带**返回这次解析出的 Binding/Effective —— +// 语音收尾需要生效人格选音色,而 owner 已经在读会话,因此这里复用同一次读取 +// (WS 语音回合不为选音色多查一次库)。 +func (t turnEnvResolver) ownerWithPersona(ctx context.Context, sessionID string) (loop.SessionEnv, turnPersona) { + tp := t.resolvePersona(ctx, sessionID) + return loop.OwnerEnvFor(tp.Binding), tp +} + +// defaultPersonaID 返回库中被标记为默认的人格 id,未接线人格存储或没有角色被标记为 +// 默认时返回 ""。它是 `owner.yaml:defaultPersona` 在网关侧的**唯一**视图 —— 与 +// `(*Server).defaultPersonaID` 同源(后者直接转调这里,避免两处各写一份扫描)。 +func (t turnEnvResolver) defaultPersonaID() string { + if t.personas == nil { return "" } - for _, c := range s.personas.List() { + return defaultPersonaIDFrom(t.personas) +} + +// defaultPersonaIDFrom 是上面那个口径的共享实现(`*Server` 与解析器共用)。 +func defaultPersonaIDFrom(personas PersonaStore) string { + if personas == nil { + return "" + } + for _, c := range personas.List() { if c.Default && c.PersonaID != "" { return c.PersonaID } diff --git a/internal/services/gateway/voice.go b/internal/services/gateway/voice.go index 6049e81..6aee011 100644 --- a/internal/services/gateway/voice.go +++ b/internal/services/gateway/voice.go @@ -174,30 +174,32 @@ func (h *hub) runVoiceTurn(c *wsConn, req voiceInputRequest) { } // Web 主会话是主人的私密对话,因此它绑定全访问 env:记忆读+写,行为不变。 // 角色来自该会话的绑定(design/22 §4.5)。 - env := h.envResolver.owner(ctx, req.SessionID) + // 语音收尾要用**生效人格**选音色(会话绑定 → 全局默认人格),而 env 只带绑定 + // (loop 自己回退)。这里**复用同一次会话读取**,不为选音色多查一次库。 + env, turnPersona := 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, env.PersonaID, transcript)) + ctx = withVoiceReplySink(ctx, h.voiceReplySink(c, req.SessionID, req.DeviceID, turnPersona.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, transcript, env) + h.runVoiceTurnDirect(ctx, c, req.SessionID, req.DeviceID, turnPersona.Effective, transcript, env) } // runVoiceTurnDirect 是未接编排器时的旧直跑路径(未接线的构建 / 裸 hub 单元 // 测试):转写之后的回合直接跑 loop,然后立即合成并回音频。它与编排器路径共用 // 同一个帧构造(emitVoiceOutput),因此两条路径的 voice_output 契约一致。 -func (h *hub) runVoiceTurnDirect(ctx context.Context, c *wsConn, sessionID, deviceID, transcript string, env loop.SessionEnv) { +func (h *hub) runVoiceTurnDirect(ctx context.Context, c *wsConn, sessionID, deviceID, personaID, transcript string, env loop.SessionEnv) { res, err := h.runner.Run(loop.WithSessionEnv(ctx, env), sessionID, []model.Message{model.TextMessage(model.RoleUser, transcript)}) if err != nil { h.voiceLog().Warn("voice chat run", "err", err, "session", sessionID, "device_id", deviceID) h.voiceError(c, "voice chat failed") return } - h.synthesizeVoiceReply(ctx, c, sessionID, deviceID, env.PersonaID, transcript, voiceReplyText(res)) + h.synthesizeVoiceReply(ctx, c, sessionID, deviceID, personaID, transcript, voiceReplyText(res)) } // voiceReplySink 返回本次语音请求的收尾回调:回合结算后取助手正文 → TTS → diff --git a/internal/services/gateway/voice_persona_inherit_test.go b/internal/services/gateway/voice_persona_inherit_test.go new file mode 100644 index 0000000..8666727 --- /dev/null +++ b/internal/services/gateway/voice_persona_inherit_test.go @@ -0,0 +1,142 @@ +package gateway + +// 本文件是复审 r2 P2-1 的**回归锁**:主会话(`web`)的 `persona_id` 恒为空且没有 +// 重绑路径,而"空"的真实语义是「**继承全局默认人格**」(`loop/persona.go` 回退到 +// `Options.PersonaID`;前端 `lib/persona.ts` 的 `bound ?? defaultPersona(personas)`)。 +// +// 若把空当成"没有生效人格",主会话聊天是 furina、声音却是 `voice-tts.json` 的 +// `default` 那个人 —— 主人逐字抱怨的那件事,而且发生在唯一能给普通聊天出声的路径上。 +// +// 两条用例分别钉住 REST 与 WS 两条既有出站口;变异(把 effectivePersona 退回 +// 「空 = 无」)必须打红。 + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/http" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" + personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" + voicetts "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/tts" +) + +// defaultPersonaRegistry 是一个"全局默认人格 = furina"的人格库。 +func defaultPersonaRegistry() PersonaStore { + return &stubPersonas{list: []personareg.Character{ + {ID: "Cyrene", Name: "Cyrene", PersonaID: "cyrene"}, + {ID: "芙宁娜", Name: "芙宁娜", PersonaID: "furina", Default: true}, + }} +} + +// unboundHistory 模拟**主会话**:SessionPersona 恒报空串(继承全局默认)。 +type unboundHistory struct{ *fakeHistory } + +func (h unboundHistory) SessionPersona(context.Context, string) (string, error) { return "", nil } + +// TestSpeechEndpointUnboundSessionInheritsGlobalPersona 是复审那条用例的复现: +// 主会话(未绑定)+ 全局默认人格 furina + `default` 配成 cyrene 的角色 ⇒ +// 必须用 **furina 的** 声音(不是 default)。 +func TestSpeechEndpointUnboundSessionInheritsGlobalPersona(t *testing.T) { + env := newEnv(t, nil, false) + rec := &vocalRecorder{} + // default 故意指向 cyrene 的角色:修好之前这里会被选中(复审报的红)。 + env.srv.SetVoice(emotionTTSService(t, rec, voicetts.Config{ + Personas: personaCharacters, + Default: personaCharacters["cyrene"], + }, &silentBase{})) + env.srv.envResolver = turnEnvResolver{ + history: unboundHistory{fakeHistory: &fakeHistory{}}, + personas: defaultPersonaRegistry(), + log: env.srv.log, + } + token := env.token(t, auth.RoleOwner) + + // 主会话就是 "web"(与 history.MainSessionID 同值;这里只声明"未绑定"这一事实)。 + resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/speech", map[string]any{ + "text": "你好,我是苏梦。", "sessionId": "web", + }, token) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, body = %s", resp.StatusCode, body) + } + got := rec.Voices() + want := personaCharacters["furina"] + if len(got) != 1 || got[0] != want { + t.Fatalf("主会话(继承全局人格 furina)用了 %v 的声音,want [%s] —— 这正是主人抱怨的那件事", got, want) + } + if string(body) != string(fakeWAV(want)) { + t.Fatalf("响应体不是 furina 的声音: %q", string(body)) + } +} + +// TestVoiceOutputUnboundSessionInheritsGlobalPersona 是同一条口径在 WS 口上的复现: +// 帧里的音频必须来自**全局默认人格**的角色。 +func TestVoiceOutputUnboundSessionInheritsGlobalPersona(t *testing.T) { + hb := newTestHub(&stubRunner{res: &loop.Result{Text: "你好,我是苏梦。", Steps: 1, StopReason: "stop"}}, nil) + hb.envResolver = turnEnvResolver{ + history: unboundHistory{fakeHistory: &fakeHistory{}}, + personas: defaultPersonaRegistry(), + log: sourcedLog(nil), + } + rec := &vocalRecorder{} + hb.voice = emotionTTSService(t, rec, voicetts.Config{ + Personas: personaCharacters, + Default: personaCharacters["cyrene"], + }, &silentBase{}) + c := newOwnerConn(hb, 32) + hb.register(c) + + c.route(wsInbound{Type: "voice_input", Data: json.RawMessage(`{"text":"你好","format":"wav"}`)}) + ev := readFakeFrame(t, c) + if ev.Type != "voice_output" { + t.Fatalf("frame = %q, want voice_output (%+v)", ev.Type, ev) + } + data, ok := ev.Data.(map[string]any) + if !ok { + t.Fatalf("frame data = %T", ev.Data) + } + raw, err := base64.StdEncoding.DecodeString(asString(data["audio"])) + if err != nil || len(raw) == 0 { + t.Fatalf("帧里没有可解码的音频(err=%v)", err) + } + want := personaCharacters["furina"] + if string(raw) != string(fakeWAV(want)) { + t.Fatalf("主会话的帧里不是 furina 的声音: %q", string(raw)) + } + if got := rec.Voices(); len(got) != 1 || got[0] != want { + t.Fatalf("请求体里的 voice = %v, want [%s]", got, want) + } +} + +// TestSpeechEndpointPersonaUnmappedErrorCode 钉住复审 r2 建议 3:状态码仍是 500 +// (服务端配置缺失,不是客户端的错),但**错误码可区分** —— 不许跟一般合成失败混成一个码。 +func TestSpeechEndpointPersonaUnmappedErrorCode(t *testing.T) { + env := newEnv(t, nil, false) + env.srv.SetVoice(emotionTTSService(t, &vocalRecorder{}, voicetts.Config{ + Personas: personaCharacters, + }, &silentBase{})) + env.srv.envResolver = turnEnvResolver{ + history: defaultPersonaRegistryHistory(), + personas: defaultPersonaRegistry(), + log: env.srv.log, + } + token := env.token(t, auth.RoleOwner) + + resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/speech", map[string]any{ + "text": "你好", "sessionId": "web:sub-7", + }, token) + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500(服务端配置缺失)", resp.StatusCode) + } + code := errorCodeOf(t, body) + if code != "tts_persona_unmapped" { + t.Fatalf("error.code = %q, want tts_persona_unmapped(body=%s)", code, body) + } +} + +// defaultPersonaRegistryHistory 是一个"会话已绑定到没有 TTS 映射的人格"的存储。 +func defaultPersonaRegistryHistory() HistoryStore { + return personaHistory{fakeHistory: &fakeHistory{}, persona: "stranger"} +}