Files
Cyrene_v2/internal/runtime/voice/tts/service_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

335 lines
13 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 tts
// 本文件锁定 `Service`(emotionTTS + 既有链的门面)的口径。**断言的落点是
// 「发往上游的请求体里的 voice」**,不是"调了合成" —— 前者才能证明"换了人格就换了
// 声音",后者只能证明"发生过一次 HTTP"。
//
// 覆盖:
// 1. 发音人跟着 persona 走(两个 persona ⇒ 两次请求体里的 voice 不同);
// 2. 🔴 同一进程先后用两个 persona,第二次绝不沿用第一次的 voice(回归锁,
// 直接对着主人担心的事);
// 3. persona 无映射且没配 default ⇒ **不合成**,且**上游请求数为 0**;
// 4. persona 解析不到且配了 default ⇒ 用 default;
// 5. 有映射但上游拒绝 ⇒ 沿用既有降级(回落既有链,口径不变);
// 6. 未启用 ⇒ 完全透传既有链;STT/克隆永远走既有链。
import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"sync"
"testing"
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
)
// discardLogger 让测试不往 stderr 刷日志(WARN 的存在由行为断言,不靠肉眼)。
func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// recorder 是一台假 emotionTTS:**记下每次请求体里的 voice**,并按它回一段音频
// (音频里嵌着 voice,便于端到端断言"帧里的声音是谁的")。
type recorder struct {
mu sync.Mutex
voices []string
status int
wav bool
}
// handler 是 recorder 的 HTTP 处理器。
func (r *recorder) handler(t *testing.T) http.HandlerFunc {
t.Helper()
return func(w http.ResponseWriter, req *http.Request) {
var payload speechPayload
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
t.Errorf("请求体不是合法 JSON: %v", err)
}
r.mu.Lock()
r.voices = append(r.voices, payload.Voice)
status, wantWAV := r.status, r.wav
r.mu.Unlock()
if status != 0 && status != http.StatusOK {
w.WriteHeader(status)
_, _ = w.Write([]byte(`{"detail":"upstream says no"}`))
return
}
if !wantWAV {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"success"}`))
return
}
_, _ = w.Write(wavBytes("voice=" + payload.Voice))
}
}
// VoiceLog 返回**按顺序**记下的 voice 列表。
func (r *recorder) VoiceLog() []string {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]string, len(r.voices))
copy(out, r.voices)
return out
}
// Calls 返回上游被调用次数。
func (r *recorder) Calls() int {
r.mu.Lock()
defer r.mu.Unlock()
return len(r.voices)
}
// newRecordingService 造一个指向 recorder 的 Service。
func newRecordingService(t *testing.T, cfg Config, base Chain) (*Service, *recorder) {
t.Helper()
rec := &recorder{wav: true}
srv := httptest.NewServer(rec.handler(t))
t.Cleanup(srv.Close)
cfg.Enabled = true
cfg.BaseURL = srv.URL
client := New(cfg, WithLogger(discardLogger()))
return NewService(client, base, WithServiceLogger(discardLogger())), rec
}
// personaCfg 是两份 persona ⇒ 两个**不同** character 的配置。
func personaCfg() Config {
return Config{Personas: map[string]string{
"cyrene": "char_84fb88a3",
"furina": "char_5314489e",
}}
}
// fakeChain 是一个可编程的底链替身,记录自己被调了几次。
type fakeChain struct {
ttsCalls int
sttCalls int
cloneCall int
audio mvoice.Audio
err error
}
func (f *fakeChain) STT(context.Context, mvoice.Audio) (string, error) {
f.sttCalls++
return "转写文本", nil
}
func (f *fakeChain) STTWithProvider(context.Context, mvoice.Audio) (string, string, error) {
f.sttCalls++
return "转写文本", "local", nil
}
func (f *fakeChain) TTS(context.Context, string) (mvoice.Audio, error) {
f.ttsCalls++
return f.audio, f.err
}
func (f *fakeChain) TTSWithProvider(context.Context, string) (mvoice.Audio, string, error) {
f.ttsCalls++
return f.audio, "silent", f.err
}
func (f *fakeChain) CloneVoice(context.Context, string, string) (string, error) {
f.cloneCall++
return "voice-id", nil
}
// TestServiceVoiceFollowsPersona 分支①:两次请求、两个 persona ⇒ 请求体里的
// voice 分别是两个不同的 character。
func TestServiceVoiceFollowsPersona(t *testing.T) {
base := &fakeChain{audio: mvoice.Audio{Format: "mp3", Data: []byte("fallback")}}
svc, rec := newRecordingService(t, personaCfg(), base)
for _, persona := range []string{"cyrene", "furina"} {
ctx := WithPersonaID(context.Background(), persona)
if _, rung, err := svc.TTSWithProvider(ctx, chineseSpeech); err != nil || rung != ProviderName {
t.Fatalf("%s: rung=%q err=%v", persona, rung, err)
}
}
want := []string{"char_84fb88a3", "char_5314489e"}
got := rec.VoiceLog()
if len(got) != 2 || got[0] != want[0] || got[1] != want[1] {
t.Fatalf("请求体里的 voice = %v, want %v", got, want)
}
if base.ttsCalls != 0 {
t.Errorf("主路健康却回落了底链 %d 次", base.ttsCalls)
}
}
// TestServiceSequentialPersonasDoNotReuseVoice 是主人担心那件事的**回归锁**:
// 同一进程先服务 cyrene 再服务 furina,第二次请求体里的 voice 必须是 furina 的,
// **绝不**沿用第一次。
func TestServiceSequentialPersonasDoNotReuseVoice(t *testing.T) {
svc, rec := newRecordingService(t, personaCfg(), &fakeChain{})
ctx := context.Background()
if _, _, err := svc.TTSWithProvider(WithPersonaID(ctx, "cyrene"), "第一句"); err != nil {
t.Fatalf("first: %v", err)
}
if _, _, err := svc.TTSWithProvider(WithPersonaID(ctx, "furina"), "第二句"); err != nil {
t.Fatalf("second: %v", err)
}
got := rec.VoiceLog()
if len(got) != 2 {
t.Fatalf("上游被调用 %d 次, want 2", len(got))
}
if got[1] != "char_5314489e" {
t.Fatalf("第二次请求体里的 voice = %q —— 沿用了第一次的角色(病害复发)", got[1])
}
}
// TestServiceUnmappedPersonaSkipsSynthesis 分支④(红线):persona 无映射、且**没配**
// default ⇒ 不合成:上游请求数为 0,底链也**不许**顶替(那会用另一个声音)。
func TestServiceUnmappedPersonaSkipsSynthesis(t *testing.T) {
base := &fakeChain{audio: mvoice.Audio{Format: "wav", Data: []byte("fallback")}}
svc, rec := newRecordingService(t, personaCfg(), base)
_, _, err := svc.TTSWithProvider(WithPersonaID(context.Background(), "stranger"), chineseSpeech)
if !errors.Is(err, ErrPersonaUnmapped) {
t.Fatalf("err = %v, want ErrPersonaUnmapped", err)
}
if rec.Calls() != 0 {
t.Fatalf("上游请求数 = %d, want 0(这条 persona 没有发音人,不该发任何合成请求)", rec.Calls())
}
if base.ttsCalls != 0 {
t.Fatalf("回落到了既有链 %d 次 —— 那会用另一个角色的声音说话", base.ttsCalls)
}
}
// TestServiceUnresolvedPersonaUsesDefault 分支②:解析不到 personaId(ctx 没设)且
// 配了 default ⇒ 请求体里的 voice 是 default。与上一条互为对照。
func TestServiceUnresolvedPersonaUsesDefault(t *testing.T) {
cfg := personaCfg()
cfg.Default = "char_default_voice"
svc, rec := newRecordingService(t, cfg, &fakeChain{})
if _, _, err := svc.TTSWithProvider(context.Background(), chineseSpeech); err != nil {
t.Fatalf("TTSWithProvider: %v", err)
}
got := rec.VoiceLog()
if len(got) != 1 || got[0] != "char_default_voice" {
t.Fatalf("请求体里的 voice = %v, want [char_default_voice]", got)
}
}
// TestServiceUnresolvedPersonaWithoutDefaultSkips 分支②的另一半:没配 default ⇒
// 不合成(请求数 0),与"用 default"严格区分。
func TestServiceUnresolvedPersonaWithoutDefaultSkips(t *testing.T) {
svc, rec := newRecordingService(t, personaCfg(), &fakeChain{})
_, _, err := svc.TTSWithProvider(context.Background(), chineseSpeech)
if !errors.Is(err, ErrNoCharacter) {
t.Fatalf("err = %v, want ErrNoCharacter", err)
}
if rec.Calls() != 0 {
t.Fatalf("上游请求数 = %d, want 0", rec.Calls())
}
}
// TestServiceFallsBackWhenUpstreamRejects 分支③:有映射但上游拒绝(500)⇒ 沿用既有
// 降级(回落既有链 + WARN),且**先按正确角色试过**(请求体里的 voice 是映射到的那个)。
func TestServiceFallsBackWhenUpstreamRejects(t *testing.T) {
base := &fakeChain{audio: mvoice.Audio{Format: "mp3", Data: []byte("fallback")}}
svc, rec := newRecordingService(t, personaCfg(), base)
rec.mu.Lock()
rec.status = http.StatusInternalServerError
rec.mu.Unlock()
audio, rung, err := svc.TTSWithProvider(WithPersonaID(context.Background(), "furina"), chineseSpeech)
if err != nil {
t.Fatalf("主路失败不该让合成整体失败(文本优先): %v", err)
}
if rung != "silent" || string(audio.Data) != "fallback" || base.ttsCalls != 1 {
t.Fatalf("没有按既有口径回落: rung=%q audio=%q calls=%d", rung, audio.Data, base.ttsCalls)
}
if got := rec.VoiceLog(); len(got) != 1 || got[0] != "char_5314489e" {
t.Fatalf("失败那次用的 voice = %v, want [char_5314489e](应先按正确角色试)", got)
}
}
// TestServiceDisabledIsTransparent 钉住"未启用 = 行为逐字不变":底链原样服务,
// 一次都不碰 emotionTTS 的 HTTP。
func TestServiceDisabledIsTransparent(t *testing.T) {
client := New(Config{Enabled: false, BaseURL: "http://127.0.0.1:1"}, WithLogger(discardLogger()))
if client.Enabled() {
t.Fatal("未启用的客户端不该报告 enabled")
}
base := &fakeChain{audio: mvoice.Audio{Format: "ogg", Data: []byte("chain")}}
svc := NewService(client, base, WithServiceLogger(discardLogger()))
if svc.PrimaryActive() {
t.Fatal("PrimaryActive = true, want false")
}
audio, _, err := svc.TTSWithProvider(context.Background(), chineseSpeech)
if err != nil {
t.Fatalf("TTSWithProvider: %v", err)
}
if audio.Format != "ogg" || base.ttsCalls != 1 {
t.Errorf("未启用时没有原样透传: audio=%+v calls=%d", audio, base.ttsCalls)
}
}
// TestServiceWithoutBase 钉住"没有底链"时的诚实失败(不 panic、可分类)。
func TestServiceWithoutBase(t *testing.T) {
svc := NewService(nil, nil, WithServiceLogger(discardLogger()))
if _, _, err := svc.TTSWithProvider(context.Background(), "hi"); !errors.Is(err, mvoice.ErrProviderFailed) {
t.Fatalf("err = %v, want ErrProviderFailed", err)
}
if _, err := svc.STT(context.Background(), mvoice.Audio{}); !errors.Is(err, mvoice.ErrProviderFailed) {
t.Fatalf("STT err = %v, want ErrProviderFailed", err)
}
if _, err := svc.CloneVoice(context.Background(), "http://x", "p"); !errors.Is(err, mvoice.ErrProviderFailed) {
t.Fatalf("CloneVoice err = %v, want ErrProviderFailed", err)
}
}
// TestServiceDelegatesSTTAndClone 钉住"只抢合成,不抢转写/复刻"。
func TestServiceDelegatesSTTAndClone(t *testing.T) {
client := New(Config{Enabled: true, BaseURL: "http://127.0.0.1:1"}, WithLogger(discardLogger()))
base := &fakeChain{}
svc := NewService(client, base, WithServiceLogger(discardLogger()))
if text, rung, err := svc.STTWithProvider(context.Background(), mvoice.Audio{}); err != nil || text != "转写文本" || rung != "local" {
t.Fatalf("STT 没有原样委托: text=%q rung=%q err=%v", text, rung, err)
}
if id, err := svc.CloneVoice(context.Background(), "http://x", "p"); err != nil || id != "voice-id" {
t.Fatalf("CloneVoice 没有原样委托: id=%q err=%v", id, err)
}
if base.sttCalls != 1 || base.cloneCall != 1 {
t.Errorf("委托次数不对: stt=%d clone=%d", base.sttCalls, base.cloneCall)
}
}
// TestProviderAdapterSelectsByPersona 钉住"作为链的一级"这条路:选角同样按 ctx 的
// personaId 现算(没有"这一级的固定发音人")。
func TestProviderAdapterSelectsByPersona(t *testing.T) {
rec := &recorder{wav: true}
srv := httptest.NewServer(rec.handler(t))
t.Cleanup(srv.Close)
cfg := personaCfg()
cfg.Enabled = true
cfg.BaseURL = srv.URL
p := NewProvider(New(cfg, WithLogger(discardLogger())))
if p.Name() != ProviderName {
t.Errorf("Name = %q, want %q", p.Name(), ProviderName)
}
if p.STT() != nil {
t.Error("emotionTTS 不提供转写,STT 应为 nil")
}
if _, err := p.TTS().TTS(WithPersonaID(context.Background(), "furina"), "你好"); err != nil {
t.Fatalf("TTS: %v", err)
}
if got := rec.VoiceLog(); len(got) != 1 || got[0] != "char_5314489e" {
t.Fatalf("请求体里的 voice = %v, want [char_5314489e]", got)
}
// 未映射的 persona ⇒ 明确失败,绝不用默认角色凑一个。
if _, err := p.TTS().TTS(WithPersonaID(context.Background(), "stranger"), "你好"); !errors.Is(err, ErrPersonaUnmapped) {
t.Fatalf("err = %v, want ErrPersonaUnmapped", err)
}
if _, err := NewProvider(nil).TTS().TTS(context.Background(), "hi"); !errors.Is(err, mvoice.ErrNotConfigured) {
t.Errorf("未接线 err = %v, want ErrNotConfigured", err)
}
}