Files
Cyrene_v2/internal/services/gateway/voice_emotiontts_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

261 lines
10 KiB
Go
Raw 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.
package gateway
// 本文件是切片 M35 的**两条既有出站口实测**,并且钉住主人 2026-10-26 的硬要求:
// **发音人跟着会话的人格走**(绝不定死一个)。
//
// 1. `POST /api/v1/audio/speech`:回的是音频字节(不是 JSON 信封/空体),且
// 请求体里的 `sessionId` 解析到的人格决定上游 `voice`;
// 2. WS `voice_output`:帧里带的是那段音频的 base64,且发音人取**本回合**的
// `env.PersonaID`(不是进程启动时算一次)。
//
// 断言落在「上游请求体里的 voice」与「帧里音频的内容」上 —— 它们才能证明
// "换了人格就换了声音"。
import (
"context"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
voicetts "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/tts"
)
// personaCharacters 是两个角色各自的上游 character id(与生产配置同形)。
var personaCharacters = map[string]string{
"cyrene": "char_84fb88a3",
"furina": "char_5314489e",
}
// fakeWAV 造一段魔数合法、且**内容里带着 voice 名**的假 WAV —— 这样端到端断言
// 帧/响应里的字节就能直接看出"这段声音是谁的"。
func fakeWAV(voice string) []byte {
b := []byte("RIFF\x00\x00\x00\x00WAVEfmt ")
b = append(b, make([]byte, 16)...)
b = append(b, []byte("data\x00\x00\x00\x00")...)
return append(b, []byte("voice="+voice)...)
}
// vocalRecorder 是一台假 emotionTTS:记下每次请求体里的 voice。
type vocalRecorder struct {
mu sync.Mutex
voices []string
}
// handler 解码请求体、记录 voice、回一段内容里带 voice 的 WAV。
func (r *vocalRecorder) handler(t *testing.T) http.HandlerFunc {
t.Helper()
return func(w http.ResponseWriter, req *http.Request) {
if req.URL.Path != "/v1/audio/speech" {
t.Errorf("path = %q, want /v1/audio/speech", req.URL.Path)
}
var payload struct {
Model string `json:"model"`
Input string `json:"input"`
Voice string `json:"voice"`
}
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
t.Errorf("请求体不是合法 UTF-8 JSON: %v", err)
}
if payload.Model != "emotionTTS" {
t.Errorf("model = %q, want emotionTTS", payload.Model)
}
r.mu.Lock()
r.voices = append(r.voices, payload.Voice)
r.mu.Unlock()
w.Header().Set("Content-Type", "audio/wav")
_, _ = w.Write(fakeWAV(payload.Voice))
}
}
// Voices 返回**按顺序**记下的 voice。
func (r *vocalRecorder) Voices() []string {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]string, len(r.voices))
copy(out, r.voices)
return out
}
// emotionTTSService 用假上游构造一个**真的** tts.Service(不是 stub)。
//
// cfg 由调用方覆盖(personas / default),本函数只补齐 Enabled 与 BaseURL。
func emotionTTSService(t *testing.T, rec *vocalRecorder, cfg voicetts.Config, base voicetts.Chain) *voicetts.Service {
t.Helper()
srv := httptest.NewServer(rec.handler(t))
t.Cleanup(srv.Close)
cfg.Enabled = true
cfg.BaseURL = srv.URL
return voicetts.NewService(voicetts.New(cfg), base)
}
// personaHistory 是一个**只报一个 persona** 的 history 替身(满足
// `sessionPersonaReader`),用来驱动两条口的人格解析。它复用测试包里的
// fakeHistory 以满足 HistoryStore 的其余方法。
type personaHistory struct {
*fakeHistory
persona string
}
// SessionPersona 报该 persona(未知会话与读错误对上层都退化成 "",这里只测成功路径)。
func (h personaHistory) SessionPersona(context.Context, string) (string, error) {
return h.persona, nil
}
// personaResolver 造一个把 sessionID 解析成给定 persona 的解析器。
func personaResolver(persona string) turnEnvResolver {
return turnEnvResolver{history: personaHistory{fakeHistory: &fakeHistory{}, persona: persona}, log: sourcedLog(nil)}
}
// TestSpeechEndpointVoiceFollowsSessionPersona 是 REST 口的实测:同一个进程、同一台
// 服务,**先**用 cyrene 会话、**后**用 furina 会话 ⇒ 两次上游请求体里的 voice 分别是
// 两个不同的角色(第二次绝不沿用第一次)。
func TestSpeechEndpointVoiceFollowsSessionPersona(t *testing.T) {
env := newEnv(t, nil, false)
rec := &vocalRecorder{}
base := &silentBase{}
env.srv.SetVoice(emotionTTSService(t, rec, voicetts.Config{Personas: personaCharacters}, base))
token := env.token(t, auth.RoleOwner)
for _, persona := range []string{"cyrene", "furina"} {
env.srv.envResolver = personaResolver(persona)
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/speech", map[string]any{
"text": "你好,我是苏梦。", "sessionId": "web:" + persona,
}, token)
if resp.StatusCode != http.StatusOK {
t.Fatalf("%s: status = %d, body = %s", persona, resp.StatusCode, body)
}
if ct := resp.Header.Get("Content-Type"); ct != "audio/wav" {
t.Errorf("%s: Content-Type = %q, want audio/wav", persona, ct)
}
if string(body) != string(fakeWAV(personaCharacters[persona])) {
t.Fatalf("%s: 响应体不是该角色的音频: %d bytes", persona, len(body))
}
}
want := []string{personaCharacters["cyrene"], personaCharacters["furina"]}
got := rec.Voices()
if len(got) != 2 || got[0] != want[0] || got[1] != want[1] {
t.Fatalf("请求体里的 voice = %v, want %v", got, want)
}
}
// TestSpeechEndpointUnmappedPersonaRefuses 是 REST 口的红线:会话绑的 persona 没有
// 映射 ⇒ **不合成**(上游 0 次请求),接口如实报错,绝不借用别的角色。
func TestSpeechEndpointUnmappedPersonaRefuses(t *testing.T) {
env := newEnv(t, nil, false)
rec := &vocalRecorder{}
env.srv.SetVoice(emotionTTSService(t, rec, voicetts.Config{
Personas: personaCharacters,
Default: personaCharacters["cyrene"], // 配了 default 也**不许**顶未映射的 persona
}, &silentBase{}))
env.srv.envResolver = personaResolver("stranger")
token := env.token(t, auth.RoleOwner)
resp, _ := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/speech", map[string]any{
"text": "你好", "sessionId": "web:sub-9",
}, token)
if resp.StatusCode == http.StatusOK {
t.Fatal("未映射 persona 却回了 200 —— 会让她用别人的声音说话")
}
if got := rec.Voices(); len(got) != 0 {
t.Fatalf("上游请求数 = %d, want 0(voice=%v)", len(got), got)
}
}
// TestSpeechEndpointNoSessionUsesDefault 钉住"这条口没有会话时"的诚实处置:
// 省略 sessionId ⇒ persona 解析不到 ⇒ 用配置的 default(没配则同样不合成)。
func TestSpeechEndpointNoSessionUsesDefault(t *testing.T) {
env := newEnv(t, nil, false)
rec := &vocalRecorder{}
defaultVoice := "char_default_voice"
env.srv.SetVoice(emotionTTSService(t, rec, voicetts.Config{
Personas: personaCharacters,
Default: defaultVoice,
}, &silentBase{}))
token := env.token(t, auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/speech", map[string]any{
"text": "你好",
}, token)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, body = %s", resp.StatusCode, body)
}
if got := rec.Voices(); len(got) != 1 || got[0] != defaultVoice {
t.Fatalf("请求体里的 voice = %v, want [%s]", got, defaultVoice)
}
}
// TestVoiceOutputFrameVoiceFollowsTurnPersona 是 WS 口的实测:发音人取**本回合**的
// env.PersonaID。两次回合(两个 persona)⇒ 两次帧里的音频内容分别属于两个角色。
func TestVoiceOutputFrameVoiceFollowsTurnPersona(t *testing.T) {
for _, persona := range []string{"cyrene", "furina"} {
t.Run(persona, func(t *testing.T) {
hb := newTestHub(&stubRunner{res: &loop.Result{Text: "你好,我是苏梦。", Steps: 1, StopReason: "stop"}}, nil)
hb.envResolver = personaResolver(persona)
rec := &vocalRecorder{}
hb.voice = emotionTTSService(t, rec, voicetts.Config{Personas: personaCharacters}, &silentBase{})
c := newOwnerConn(hb, 32)
hb.register(c)
c.route(wsInbound{Type: "voice_input", Data: json.RawMessage(`{"text":"你好","format":"wav"}`)})
ev := readFakeFrame(t, c)
if ev.Type != "voice_output" {
t.Fatalf("frame = %q, want voice_output (%+v)", ev.Type, ev)
}
data, ok := ev.Data.(map[string]any)
if !ok {
t.Fatalf("frame data = %T", ev.Data)
}
raw, err := base64.StdEncoding.DecodeString(asString(data["audio"]))
if err != nil || len(raw) == 0 {
t.Fatalf("帧里没有可解码的音频(err=%v)", err)
}
want := fakeWAV(personaCharacters[persona])
if string(raw) != string(want) {
t.Fatalf("帧里的声音不是 %s 的: %q", persona, string(raw))
}
if data["reply"] != "你好,我是苏梦。" {
t.Errorf("reply = %v, want 助手正文", data["reply"])
}
if got := rec.Voices(); len(got) != 1 || got[0] != personaCharacters[persona] {
t.Fatalf("请求体里的 voice = %v, want [%s]", got, personaCharacters[persona])
}
})
}
}
// asString 安全地取一个 string 值(帧载荷是 map[string]any)。
func asString(v any) string {
s, _ := v.(string)
return s
}
// silentBase 是最小的底链替身:只在测"主路健康"时用作类型占位,
// 一旦被调用就说明主路降级了(测试会因此失败)。
type silentBase struct{}
func (s *silentBase) STT(_ context.Context, _ mvoice.Audio) (string, error) {
return "", mvoice.ErrProviderFailed
}
func (s *silentBase) STTWithProvider(_ context.Context, _ mvoice.Audio) (string, string, error) {
return "", "", mvoice.ErrProviderFailed
}
func (s *silentBase) TTS(_ context.Context, _ string) (mvoice.Audio, error) {
return mvoice.Audio{}, mvoice.ErrProviderFailed
}
func (s *silentBase) TTSWithProvider(_ context.Context, _ string) (mvoice.Audio, string, error) {
return mvoice.Audio{}, "", mvoice.ErrProviderFailed
}
func (s *silentBase) CloneVoice(_ context.Context, _, _ string) (string, error) {
return "", mvoice.ErrProviderFailed
}