Files
Cyrene_v2/internal/runtime/voice/tts/service_test.go
T
AskaEth 8820b0b316 fix(voice): 修 r1 复审四条(正文进日志的隐私泄漏 / format 假开关 / Stats 无消费者 / redactURL 兜底)
上一版把 r1 的三条 finding 静默漏了(我在交付前没读 inbox),本提交逐条补齐:

1. 🔴 隐私:upstreamStatus 不再读也不记响应体
   FastAPI 的 422 会把请求体里的 input 整段回显,原来「错误体前 512 字节进 WARN」
   等于把她的原话写进日志。现在只记 status / content_type / response_bytes
   (字节数优先取 Content-Length,缺失时有界排空取上界,读到即丢)。
   新用例 TestSpeechRejectLogOmitsRequestText 用**回显正文**的假上游抓它;
   变异(把 body 塞回 WARN)实测打红。

2. format 键彻底删除(假开关)
   上游只拿 response_format 改自报 media_type,产物恒 .wav 无转码,我方只按字节
   嗅探裁决 ⇒ wav/mp3/ogg 交付字节与响应头完全相同。删 tts.Config.Format 与
   VoiceTTSTuning.Format,encodeSpeech 硬编码 requestFormat="wav"(不可配置)。
   用例钉住请求体恒为 wav;变异(改成 mp3)打红。旧文件里残留的 format 键被忽略。

3. Stats 接进 degrade 的 WARN(attempts/failures)
   否则统计在生产日志里没有消费者。TestServiceDegradeLogsCounters 钉住;
   变异(去掉两个字段)打红。

4. redactURL 兜底含 "@" 整体打码
   解析失败/无 host 时原来原样返回,user:pass@ 会漏凭据。TestRedactURLCredentials
   覆盖合法 URL 去 userinfo、非法含 @ 打码;变异(退回原样返回)打红。

另:关闭态零行为变化的**当前 tip 重跑**——
- handleSpeech 的 persona 解析改为只在语音服务实现 personaVoice 且 PrimaryActive
  时才查库;未接 emotionTTS(既有链/stub)时零额外 I/O、ctx 不改、音频字节相同。
  用例 TestSpeechEndpointDisabledVoiceDoesNotReadPersona(含对照组证明闸不是恒关);
  变异(去掉闸)打红。
- voice.go 在 dev 基线里本就有 env.PersonaID 解析(无人格解析新增 I/O),本次未改。

Config.Normalized 去掉未使用的 logger 参数。go build/vet/test(含 -race)全绿;
真机 live 复测:cyrene 264104 B、furina 221194 B。
2026-10-26 13:06:39 +08:00

366 lines
14 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"
"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)
}
}