主人 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。
96 lines
3.6 KiB
Go
96 lines
3.6 KiB
Go
package runtimeconfig
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
// TestLoadVoiceTTSTuning 锁定 voice-tts.json 的四条读取口径:
|
|
// 缺失 = 没配过(不是错误)、全空白 = 没配过、合法 = 逐键读出、坏文件 = 可分类错误。
|
|
func TestLoadVoiceTTSTuning(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "voice-tts.json")
|
|
|
|
// ① 缺失
|
|
if tuning, err := LoadVoiceTTSTuning(path); err != nil || tuning.EnabledOn() {
|
|
t.Fatalf("缺失: tuning=%+v err=%v, want 零值 + nil", tuning, err)
|
|
}
|
|
|
|
// ② 全空白
|
|
if err := os.WriteFile(path, []byte(" \n\t "), 0o600); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
if tuning, err := LoadVoiceTTSTuning(path); err != nil || tuning.EnabledOn() {
|
|
t.Fatalf("空白: tuning=%+v err=%v, want 零值 + nil", tuning, err)
|
|
}
|
|
|
|
// ③ 合法(新形状:personas 映射 + default;旧的全局 character 不再参与)
|
|
body := `{"enabled":true,"baseUrl":"http://127.0.0.1:9880",
|
|
"personas":{"cyrene":"char_84fb88a3","furina":"char_5314489e"},
|
|
"default":"char_84fb88a3","format":"wav","timeoutSec":45}`
|
|
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
tuning, err := LoadVoiceTTSTuning(path)
|
|
if err != nil {
|
|
t.Fatalf("合法文件: %v", err)
|
|
}
|
|
if !tuning.EnabledOn() || tuning.Format != "wav" || tuning.TimeoutSec != 45 {
|
|
t.Fatalf("读取结果不对: %+v", tuning)
|
|
}
|
|
if len(tuning.Personas) != 2 || tuning.Personas["furina"] != "char_5314489e" {
|
|
t.Fatalf("personas 没有被读出: %+v", tuning.Personas)
|
|
}
|
|
if tuning.Default != "char_84fb88a3" {
|
|
t.Fatalf("default = %q", tuning.Default)
|
|
}
|
|
|
|
// ③b 旧的全局 character 仍能读出来(供启动日志提示"已废弃"),但它**不是**
|
|
// personas/default 的一部分 —— 组合根绝不把它当发音人。
|
|
if err := os.WriteFile(path, []byte(`{"enabled":true,"character":"char_old"}`), 0o600); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
if tuning, err = LoadVoiceTTSTuning(path); err != nil {
|
|
t.Fatalf("旧键: %v", err)
|
|
}
|
|
if tuning.LegacyGlobalCharacter() != "char_old" {
|
|
t.Fatalf("LegacyGlobalCharacter = %q", tuning.LegacyGlobalCharacter())
|
|
}
|
|
if len(tuning.Personas) != 0 || tuning.Default != "" {
|
|
t.Fatalf("旧全局 character 混进了 personas/default: %+v", tuning)
|
|
}
|
|
|
|
// ④ 显式 false 与"没配"同效,但指针让它在读取侧可区分。
|
|
if err := os.WriteFile(path, []byte(`{"enabled":false}`), 0o600); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
if tuning, err = LoadVoiceTTSTuning(path); err != nil || tuning.EnabledOn() || tuning.Enabled == nil {
|
|
t.Fatalf("显式 false: tuning=%+v err=%v", tuning, err)
|
|
}
|
|
|
|
// ⑤ 坏文件 ⇒ 可分类错误(调用方据此回落"不启用",且绝不覆盖原文件)
|
|
if err := os.WriteFile(path, []byte(`{"enabled":`), 0o600); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
if _, err := LoadVoiceTTSTuning(path); !errors.Is(err, ErrInvalidVoiceTTS) {
|
|
t.Fatalf("坏文件 err = %v, want ErrInvalidVoiceTTS", err)
|
|
}
|
|
if raw, err := os.ReadFile(path); err != nil || string(raw) != `{"enabled":` {
|
|
t.Fatalf("坏文件被覆盖了: %q err=%v", string(raw), err)
|
|
}
|
|
}
|
|
|
|
// TestDefaultVoiceTTSPath 钉住落点:`$DATA_DIR/config/voice-tts.json`(零新 .env 键)。
|
|
func TestDefaultVoiceTTSPath(t *testing.T) {
|
|
got := DefaultVoiceTTSPath(filepath.Join("data", "root"))
|
|
want := filepath.Join("data", "root", "config", "voice-tts.json")
|
|
if got != want {
|
|
t.Fatalf("path = %q, want %q", got, want)
|
|
}
|
|
if VoiceTTSConfigRelPath != "config/voice-tts.json" {
|
|
t.Fatalf("RelPath = %q", VoiceTTSConfigRelPath)
|
|
}
|
|
}
|