上一版把 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。
366 lines
14 KiB
Go
366 lines
14 KiB
Go
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)
|
||
}
|
||
}
|