Files
AskaEth b90811fae4 docs(voice): 收口「没会话/没绑定就走 default」的镜像注释(r4)
唯一 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 全绿。
2026-10-26 13:40:00 +08:00

366 lines
14 KiB
Go
Raw Permalink 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. 连生效人格都没有(传空串)且配了 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)
}
}