主人 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。
115 lines
4.4 KiB
Go
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)
|
|
}
|
|
}
|