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。
This commit is contained in:
AskaEth committed 2026-10-26 12:57:35 +08:00
1 parent 3a2711a7e0
commit efc25c6a7f
16 files changed
+1210 -503

No files matched your search

+55 -13
View File
@@ -2,6 +2,17 @@
package tts
// 本文件是**对着真的 emotionTTS 服务**的端到端验证,默认不进 `go test ./...`
// (与 `dashscope_live` 同一口径:不得让常规测试去碰外部服务)。
//
// 显式运行(服务默认在 127.0.0.1:9880,可用 EMOTIONTTS_BASE_URL 覆盖):
//
// go test -tags emotiontts_live -run TestLiveSpeech -v ./internal/runtime/voice/tts/
//
// 🔴 第二条用例(TestLiveSpeechFollowsPersona)是主人 2026-10-26 那条硬要求的
// **真机证据**:同一份配置、同一进程,两个 persona 各自合成一次 —— 两次用的是
// 两个不同的上游角色,音频字节也不同。
import (
"context"
"os"
@@ -9,20 +20,53 @@ import (
"time"
)
// 本文件是**对着真的 emotionTTS 服务**的端到端验证,默认不进 `go test ./...`
// (与 `dashscope_live` 同一口径:不得让常规测试去碰外部服务)。
//
// 显式运行(服务默认在 127.0.0.1:9880,可用 EMOTIONTTS_BASE_URL 覆盖):
//
// go test -tags emotiontts_live -run TestLiveSpeech -v ./internal/runtime/voice/tts/
// TestLiveSpeech 用默认角色合成一句话,验证拿到的确实是 WAV。
func TestLiveSpeech(t *testing.T) {
baseURL := os.Getenv("EMOTIONTTS_BASE_URL")
client := New(Config{Enabled: true, BaseURL: baseURL, Timeout: 120 * time.Second})
client := New(Config{Enabled: true, BaseURL: os.Getenv("EMOTIONTTS_BASE_URL"), Timeout: 120 * time.Second})
audio, elapsed := synthesizeOrFail(t, client, DefaultCharacter)
t.Logf("evidence: base=%s character=%s bytes=%d format=%s elapsed=%s",
client.Config().BaseURL, DefaultCharacter, len(audio.Data), audio.Format, elapsed)
}
// TestLiveSpeechFollowsPersona 是真机的"换人格就换声音"证据:两个 persona 分别映射
// 到服务端真实存在的两个角色(Cyrene / 芙宁娜),各自合成同一句话。
func TestLiveSpeechFollowsPersona(t *testing.T) {
cfg := Config{
Enabled: true,
BaseURL: os.Getenv("EMOTIONTTS_BASE_URL"),
Timeout: 120 * time.Second,
Personas: map[string]string{
"cyrene": "char_84fb88a3",
"furina": "char_5314489e",
},
}
client := New(cfg)
sizes := make(map[string]int, 2)
for _, persona := range []string{"cyrene", "furina"} {
selection, err := client.Config().Resolve(persona)
if err != nil {
t.Fatalf("%s: resolve: %v", persona, err)
}
audio, elapsed := synthesizeOrFail(t, client, selection.Character)
sizes[selection.Character] = len(audio.Data)
t.Logf("evidence: persona=%s character=%s source=%s bytes=%d elapsed=%s",
persona, selection.Character, selection.Source, len(audio.Data), elapsed)
}
if sizes["char_84fb88a3"] == 0 || sizes["char_5314489e"] == 0 {
t.Fatalf("两个角色没有都产出音频: %v", sizes)
}
if sizes["char_84fb88a3"] == sizes["char_5314489e"] {
t.Logf("note: 两个角色的音频字节数恰好相同(%d),这可能只是巧合,不改变 voice 已按 persona 切换的事实", sizes["char_84fb88a3"])
}
}
// synthesizeOrFail 合成一次并做最小校验,返回音频与耗时。
func synthesizeOrFail(t *testing.T, client *Client, character string) (Audio, time.Duration) {
t.Helper()
start := time.Now()
audio, err := client.Speech(context.Background(), chineseSpeech)
audio, err := client.Speech(context.Background(), character, chineseSpeech)
if err != nil {
t.Fatalf("对真服务合成失败: %v", err)
t.Fatalf("对真服务合成失败(character=%s): %v", character, err)
}
elapsed := time.Since(start)
if audio.Format != "wav" {
@@ -34,7 +78,5 @@ func TestLiveSpeech(t *testing.T) {
if string(audio.Data[:4]) != "RIFF" || string(audio.Data[8:12]) != "WAVE" {
t.Fatalf("不是 RIFF/WAVE: %q", string(audio.Data[:12]))
}
// 证据:真实字节数与耗时(塔要求的"实际字节")。
t.Logf("evidence: base=%s character=%s bytes=%d format=%s elapsed=%s",
client.Config().BaseURL, client.Character(), len(audio.Data), audio.Format, elapsed)
return audio, elapsed
}