唯一 finding:audio.go 的 speechRequest.SessionID 注释原本写「省略它时本口没有会话
概念 ⇒ 走 emotionTTS 的 default 分支」——**反了**。真实链路是
effectivePersona(ctx,"") → sessionPersona("") 为空**不是终点** → Effective =
defaultPersonaID() → 常规安装必有全局默认人格 ⇒ personaId 非空 ⇒ 走 personas 映射。
照那句话改的人会写出 `if req.SessionID == "" { personaID = "" }`,正是 r2 修掉的病。
改为:省略时按**全局默认人格**选发音人(会话绑定 → 全局默认人格 → 两者都没有才落到
default 字符),并显式写了"不要写成 if SessionID == \"\" { personaID = \"\" }"。
顺手同根三处(不留第四轮):
- tts/config.go 的 Config.Default 注释:成因表「会话无绑定 / 读取失败 / 该出口本身
没有会话」已过时 ⇒ 改为「空串的唯一合法成因是连全局默认人格都没有」,并写明不是
"会话未绑定"、也不是"出站口没有会话"。
- runtimeconfig/voicetts.go 的 Default 字段注释与文件头同族措辞同步;
- selection.go 的 WithPersonaID / Resolve 分支表、service.go 的分支小标把
「解析不到」统一改成「连全局默认人格都没有」;
- voice_emotiontts_test.go 的 TestSpeechEndpointNoSessionUsesDefault 注释同步(该用例
没接人格库,所以确实落到 default —— 但成因写对了);
- wire_voice_tts_test.go nit:改成「已废弃的全局 `character` 不生效」(墓碑键仍在)。
纯注释:`git diff` 里非注释改动行为空。go build/vet 通过;四包 go test -count=1 全绿。
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. 连生效人格都没有(传空串)且配了 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)
|
||
}
|
||
}
|