Files
Cyrene_v2/internal/runtime/voice/tts/selection_test.go
T
AskaEth efc25c6a7f feat(voice): emotionTTS 发音人绑定会话人格(不再全局定死一个 character)
主人 2026-10-26 逐字:「TTS 的人物选择是要绑定后端的角色的,不然会出现以后换了
别的角色(人格配置),还是用老角色的 TTS 角色」——上一版把 character 写在面板文件
里当全局值,换人格后声音不变,是真缺陷。

配置形状(单一真源 = $DATA_DIR/config/voice-tts.json):
  {"enabled":true,
   "personas":{"<personaId>":"<characterId>"},
   "default":"<characterId>",   // 只服务「解析不到 personaId」
   "format":"wav","timeoutSec":60}
- 旧的全局 `character` 键**废弃并被忽略**(启动 WARN 提示改用 personas);
  Config.Normalized 不给任何角色兜底(代码里写死默认角色=病害本身);
- configs/personas/*.yaml 不参与(塔裁定:避免启动期静态配置与面板两处漂移);
- 零新 .env 键。

选角四条分支(Config.Resolve,纯函数、无缓存):
  ① persona 有映射 ⇒ 用它;
  ② personaId 为空/解析不到 + 配了 default ⇒ 用 default;
  ③ personaId 为空 + 没配 default ⇒ 不合成(ErrNoCharacter,上游 0 请求);
  ④ persona 无映射(含映射值为空)⇒ 拒绝(ErrPersonaUnmapped),
     **绝不**拿 default 或别的角色顶 —— 主人担心的病害本身。

两条既有出站口各自按当次请求解析 persona(不在 SetVoice 时算一次缓存成全局):
- WS voice_output:用本回合的 env.PersonaID(runVoiceTurn/runVoiceTurnDirect),
  经 voiceReplySink 传给 synthesizeVoiceReply,再 WithPersonaID 交给语音面;
- POST /api/v1/audio/speech:请求体新增**可选** sessionId(纯增量),
  经 turnEnvResolver.sessionPersona 解析;省略 ⇒ 走 default 分支。

Client.Speech 改为**每次传入 character**(内部不缓存发音人);Provider 适配同样按
ctx 的 personaId 现算。选不到发音人时**不回落既有链**(回落=用另一个声音说话),
上游拒绝(404/500)仍按既有口径回落既有链+WARN。

测试:四条分支各自可变异打红,断言打在「上游请求体里的 voice」与帧/响应字节上;
含「同一进程先后两个 persona,第二次绝不沿用第一次 voice」的回归锁,
以及两条出站口的端到端 persona 切换。四条变异(未映射回落 default / 客户端缓存
voice / REST 不看会话 / 选不到时回落底链)均已实测打红后还原。
go build ./... / go vet ./... / go test -race 全绿;真机 live 证据:
cyrene(char_84fb88a3)=254630 B、furina(char_5314489e)=213392 B。
2026-10-26 12:57:35 +08:00

115 lines
4.4 KiB
Go

package tts
// 本文件锁定**选角**的四条分支(主人 2026-10-26 的验收清单逐条对应)。
//
// 最要紧的是 `TestResolveUnmappedPersonaRefuses`:映射缺失时**绝不**拿 default
// 或别的角色去顶 —— 那正是主人担心的病害(「换了别的角色,还是用老角色的 TTS
// 角色」)。把这条改成"回落 default"必然打红。
import (
"errors"
"testing"
)
// selectionCfg 是两份 persona 各映射到**不同** character 的配置。
func selectionCfg() Config {
return Config{
Enabled: true,
Personas: map[string]string{
"cyrene": "char_84fb88a3",
"furina": "char_5314489e",
"nameless": "",
},
Default: "char_fallback",
}
}
// TestResolveMapsPersonaToCharacter 分支①:有映射 ⇒ 用映射到的角色(两个 persona
// 各自映射到不同角色,绝不互相串)。
func TestResolveMapsPersonaToCharacter(t *testing.T) {
cfg := selectionCfg().Normalized(discardLogger())
got, err := cfg.Resolve("cyrene")
if err != nil || got.Character != "char_84fb88a3" || got.Source != SourceMapped {
t.Fatalf("cyrene ⇒ %+v err=%v", got, err)
}
other, err := cfg.Resolve("furina")
if err != nil || other.Character != "char_5314489e" || other.Source != SourceMapped {
t.Fatalf("furina ⇒ %+v err=%v", other, err)
}
if got.Character == other.Character {
t.Fatal("两个 persona 拿到了同一个 character(映射被串了)")
}
}
// TestResolveUnmappedPersonaRefuses 分支④(红线):配了 personas 但**没有这个
// persona 的键** ⇒ 拒绝,**不看 default**、也不借用任何别的角色。
func TestResolveUnmappedPersonaRefuses(t *testing.T) {
cfg := selectionCfg().Normalized(discardLogger())
got, err := cfg.Resolve("stranger")
if !errors.Is(err, ErrPersonaUnmapped) {
t.Fatalf("未映射 persona err = %v, want ErrPersonaUnmapped", err)
}
if got.Character != "" {
t.Fatalf("未映射 persona 却选出了角色 %q —— 这条就是病害本身", got.Character)
}
// 键在但值为空:同样拒绝(不是"回落 default")。
if got, err := cfg.Resolve("nameless"); !errors.Is(err, ErrPersonaUnmapped) || got.Character != "" {
t.Fatalf("映射值为空 ⇒ %+v err=%v, want 拒绝", got, err)
}
}
// TestResolveUnresolvedPersonaUsesDefault 分支②:personaId 解析不到(空串)⇒ 用
// default(配了才用)。
func TestResolveUnresolvedPersonaUsesDefault(t *testing.T) {
cfg := selectionCfg().Normalized(discardLogger())
got, err := cfg.Resolve("")
if err != nil || got.Character != "char_fallback" || got.Source != SourceDefault {
t.Fatalf("空 persona ⇒ %+v err=%v, want default", got, err)
}
}
// TestResolveUnresolvedPersonaWithoutDefaultRefuses 分支②的另一半:没配 default ⇒
// 不合成(可分类错误),绝不自己猜一个角色。
func TestResolveUnresolvedPersonaWithoutDefaultRefuses(t *testing.T) {
cfg := Config{Enabled: true, Personas: map[string]string{"cyrene": "char_84fb88a3"}}.Normalized(discardLogger())
if got, err := cfg.Resolve(""); !errors.Is(err, ErrNoCharacter) || got.Character != "" {
t.Fatalf("空 persona + 无 default ⇒ %+v err=%v, want ErrNoCharacter", got, err)
}
}
// TestResolveIsNotCachedAcrossPersonas 是主人担心那件事的**回归锁**:选角是纯函数,
// 同一份配置先后解析两个 persona,第二次绝不沿用第一次的结果。
func TestResolveIsNotCachedAcrossPersonas(t *testing.T) {
cfg := selectionCfg().Normalized(discardLogger())
first, err := cfg.Resolve("cyrene")
if err != nil {
t.Fatalf("first: %v", err)
}
second, err := cfg.Resolve("furina")
if err != nil {
t.Fatalf("second: %v", err)
}
if second.Character != "char_5314489e" {
t.Fatalf("第二次沿用了第一次的 voice(%q)—— 正是主人担心的病害", second.Character)
}
if first.Character != "char_84fb88a3" {
t.Fatalf("第一次的结果被第二次改写了: %q", first.Character)
}
}
// TestPersonaContextRoundTrip 钉住 ctx 传递(语音面自己不查库,只读这一处)。
func TestPersonaContextRoundTrip(t *testing.T) {
if got := PersonaIDFromContext(WithPersonaID(t.Context(), " cyrene ")); got != "cyrene" {
t.Fatalf("ctx persona = %q, want cyrene", got)
}
if got := PersonaIDFromContext(t.Context()); got != "" {
t.Fatalf("未设置时应为空, got %q", got)
}
// nil ctx 不得 panic(防御性)。
if got := PersonaIDFromContext(nil); got != "" {
t.Fatalf("nil ctx = %q, want 空", got)
}
}