Files
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

83 lines
3.2 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.
//go:build emotiontts_live
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"
"testing"
"time"
)
// TestLiveSpeech 用默认角色合成一句话,验证拿到的确实是 WAV。
func TestLiveSpeech(t *testing.T) {
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(), character, chineseSpeech)
if err != nil {
t.Fatalf("对真服务合成失败(character=%s): %v", character, err)
}
elapsed := time.Since(start)
if audio.Format != "wav" {
t.Errorf("format = %q, want wav(产物恒为 WAV)", audio.Format)
}
if len(audio.Data) == 0 {
t.Fatal("拿到的音频为空")
}
if string(audio.Data[:4]) != "RIFF" || string(audio.Data[8:12]) != "WAVE" {
t.Fatalf("不是 RIFF/WAVE: %q", string(audio.Data[:12]))
}
return audio, elapsed
}