Files
Cyrene_v2/internal/services/gateway/voice_persona_inherit_test.go
AskaEth e684061e56 fix(voice): 主/子系统会话的发音人跟「生效人格」走(r2 P2-1)+ 错误码可区分
P2-1(实质):主会话(web)的 persona_id 恒为空且无重绑路径,而"空"的真实语义是
「继承全局默认人格」(loop/persona.go 回退 Options.PersonaID;前端 lib/persona.ts 的
bound ?? defaultPersona(personas) 是同一口径)。此前只有 TTS 把空读成"没有生效人格"
⇒ 落到 voice-tts.json 的 default:主会话聊天是 furina、声音却是 default 那个人,
换全局人格后也不跟声 —— 正是主人抱怨的那件事,且发生在唯一能给普通聊天出声的路径。

修法:
- turnEnvResolver 新增 resolvePersona/effectivePersona(绑定 → 全局默认人格 → ""),
  默认人格扫描抽成 defaultPersonaIDFrom 一处(*Server.defaultPersonaID 转调它);
- ownerWithPersona 复用**同一次**会话读取同时给出 loop 用的 binding 与呈现用的
  effective —— WS 语音回合为选音色不新增任何 I/O;
- REST:现有闸内改用 effectivePersona;WS:voice.go 那次 env 解析处取一次传给 sink。
因此 `default` 只剩"连默认人格都没有"这一种用途。
- service.go 那句"换人格后用老角色声音不可能发生"收回为限定说法:本包无缓存、无兜底
  角色、未映射即拒绝,但**调用方必须传生效人格**(网关两处已修)。

回归锁:voice_persona_inherit_test.go 复现复审用例(名字不同、两条口各一条)——
未绑定会话 + 全局默认 furina + default 指向 cyrene ⇒ 必须用 furina 的声音;
变异(effectivePersona 退回「空 = 无」)实测两条都红,报文与复审一致。

建议 3(顺手做):persona 未映射时状态码仍 500(服务端配置缺失),但错误码换成
tts_persona_unmapped / tts_no_character,不再与一般合成失败混成一个码;
用例 TestSpeechEndpointPersonaUnmappedErrorCode 钉住。

P2-2(nit):voicetts.go 开头删掉「格式、」两字(该键已删除)。

go build ./... + go vet ./... + go test(含 -race)全绿。
2026-10-26 13:21:44 +08:00

143 lines
5.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"}
}