package tts // 本文件锁定 `Service`(emotionTTS + 既有链的门面)的口径。**断言的落点是 // 「发往上游的请求体里的 voice」**,不是"调了合成" —— 前者才能证明"换了人格就换了 // 声音",后者只能证明"发生过一次 HTTP"。 // // 覆盖: // 1. 发音人跟着 persona 走(两个 persona ⇒ 两次请求体里的 voice 不同); // 2. 🔴 同一进程先后用两个 persona,第二次绝不沿用第一次的 voice(回归锁, // 直接对着主人担心的事); // 3. persona 无映射且没配 default ⇒ **不合成**,且**上游请求数为 0**; // 4. 连生效人格都没有(传空串)且配了 default ⇒ 用 default; // 5. 有映射但上游拒绝 ⇒ 沿用既有降级(回落既有链,口径不变); // 6. 未启用 ⇒ 完全透传既有链;STT/克隆永远走既有链。 import ( "context" "encoding/json" "errors" "io" "log/slog" "net/http" "net/http/httptest" "strings" "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) } } // TestServiceDegradeLogsCounters 钉住 P2-3:降级那条 WARN 必须带上 attempts/failures —— // 否则 `Stats()` 在生产日志里没有任何消费者,等于没做。 // // 变异:从 degrade 的 WARN 里去掉这两个字段 ⇒ 本用例红。 func TestServiceDegradeLogsCounters(t *testing.T) { rec := &recorder{wav: false, status: http.StatusInternalServerError} srv := httptest.NewServer(rec.handler(t)) t.Cleanup(srv.Close) log, buf := captureLogger() cfg := personaCfg() cfg.Enabled = true cfg.BaseURL = srv.URL svc := NewService(New(cfg, WithLogger(discardLogger())), &fakeChain{}, WithServiceLogger(log)) if _, _, err := svc.TTSWithProvider(WithPersonaID(context.Background(), "cyrene"), chineseSpeech); err != nil { t.Fatalf("主路失败应回落既有链,不该整体失败: %v", err) } logs := buf.String() if !strings.Contains(logs, "failures=1") { t.Fatalf("降级 WARN 里没有失败计数:\n%s", logs) } if !strings.Contains(logs, "attempts=1") { t.Fatalf("降级 WARN 里没有尝试计数:\n%s", logs) } // 计数与正文同屏的风险同样要守住:这条 WARN 也不许带正文。 if strings.Contains(logs, "苏梦") { t.Fatalf("降级 WARN 里出现了正文:\n%s", logs) } } // 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) } }