feat(voice): emotionTTS 发音人绑定会话人格(不再全局定死一个 character)

主人 2026-10-26 逐字:「TTS 的人物选择是要绑定后端的角色的,不然会出现以后换了
别的角色(人格配置),还是用老角色的 TTS 角色」——上一版把 character 写在面板文件
里当全局值,换人格后声音不变,是真缺陷。

配置形状(单一真源 = $DATA_DIR/config/voice-tts.json):
  {"enabled":true,
   "personas":{"<personaId>":"<characterId>"},
   "default":"<characterId>",   // 只服务「解析不到 personaId」
   "format":"wav","timeoutSec":60}
- 旧的全局 `character` 键**废弃并被忽略**(启动 WARN 提示改用 personas);
  Config.Normalized 不给任何角色兜底(代码里写死默认角色=病害本身);
- configs/personas/*.yaml 不参与(塔裁定:避免启动期静态配置与面板两处漂移);
- 零新 .env 键。

选角四条分支(Config.Resolve,纯函数、无缓存):
  ① persona 有映射 ⇒ 用它;
  ② personaId 为空/解析不到 + 配了 default ⇒ 用 default;
  ③ personaId 为空 + 没配 default ⇒ 不合成(ErrNoCharacter,上游 0 请求);
  ④ persona 无映射(含映射值为空)⇒ 拒绝(ErrPersonaUnmapped),
     **绝不**拿 default 或别的角色顶 —— 主人担心的病害本身。

两条既有出站口各自按当次请求解析 persona(不在 SetVoice 时算一次缓存成全局):
- WS voice_output:用本回合的 env.PersonaID(runVoiceTurn/runVoiceTurnDirect),
  经 voiceReplySink 传给 synthesizeVoiceReply,再 WithPersonaID 交给语音面;
- POST /api/v1/audio/speech:请求体新增**可选** sessionId(纯增量),
  经 turnEnvResolver.sessionPersona 解析;省略 ⇒ 走 default 分支。

Client.Speech 改为**每次传入 character**(内部不缓存发音人);Provider 适配同样按
ctx 的 personaId 现算。选不到发音人时**不回落既有链**(回落=用另一个声音说话),
上游拒绝(404/500)仍按既有口径回落既有链+WARN。

测试:四条分支各自可变异打红,断言打在「上游请求体里的 voice」与帧/响应字节上;
含「同一进程先后两个 persona,第二次绝不沿用第一次 voice」的回归锁,
以及两条出站口的端到端 persona 切换。四条变异(未映射回落 default / 客户端缓存
voice / REST 不看会话 / 选不到时回落底链)均已实测打红后还原。
go build ./... / go vet ./... / go test -race 全绿;真机 live 证据:
cyrene(char_84fb88a3)=254630 B、furina(char_5314489e)=213392 B。
This commit is contained in:
AskaEth committed 2026-10-26 12:57:35 +08:00
1 parent 3a2711a7e0
commit efc25c6a7f
16 files changed
+1210 -503

No files matched your search

+200 -44
View File
@@ -1,17 +1,27 @@
package tts
// 本文件锁定 `Service`(emotionTTS + 既有链的门面)的三条口径:
// 本文件锁定 `Service`(emotionTTS + 既有链的门面)的口径。**断言的落点是
// 「发往上游的请求体里的 voice」**,不是"调了合成" —— 前者才能证明"换了人格就换了
// 声音",后者只能证明"发生过一次 HTTP"。
//
// 1. **健康时用 emotionTTS**,并如实报告级别名 `emotiontts`;
// 2. **失败时回落既有链**,且**不把失败上抛**(文本优先:合成挂掉不影响回复);
// 3. **未启用时完全透传**(行为与引入本切片之前逐字相同),STT/克隆永远走既有链。
// 覆盖:
// 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"
"sync"
"testing"
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
@@ -22,6 +32,77 @@ 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
@@ -56,54 +137,115 @@ func (f *fakeChain) CloneVoice(context.Context, string, string) (string, error)
return "voice-id", nil
}
// TestServiceUsesPrimaryWhenHealthy 钉住主路:健康时走 emotionTTS 且报告级别名。
func TestServiceUsesPrimaryWhenHealthy(t *testing.T) {
client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(wavBytes("her voice"))
})
// TestServiceVoiceFollowsPersona 分支①:两次请求、两个 persona ⇒ 请求体里的
// voice 分别是两个不同的 character。
func TestServiceVoiceFollowsPersona(t *testing.T) {
base := &fakeChain{audio: mvoice.Audio{Format: "mp3", Data: []byte("fallback")}}
svc := NewService(client, base, WithServiceLogger(discardLogger()))
svc, rec := newRecordingService(t, personaCfg(), base)
audio, rung, err := svc.TTSWithProvider(context.Background(), chineseSpeech)
if err != nil {
t.Fatalf("TTSWithProvider: %v", err)
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)
}
}
if rung != ProviderName {
t.Errorf("rung = %q, want %q", rung, ProviderName)
}
if audio.Format != "wav" || string(audio.Data) == "fallback" {
t.Errorf("audio = %+v, want the emotionTTS bytes", audio)
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)
}
}
// TestServiceFallsBackWhenPrimaryFails 钉住降级:主路失败 ⇒ 底链出声音,
// **错误不上抛**(文本优先)。
func TestServiceFallsBackWhenPrimaryFails(t *testing.T) {
client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"detail":"indexTTS2 down"}`))
})
base := &fakeChain{audio: mvoice.Audio{Format: "mp3", Data: []byte("fallback")}}
svc := NewService(client, base, WithServiceLogger(discardLogger()))
// TestServiceSequentialPersonasDoNotReuseVoice 是主人担心那件事的**回归锁**:
// 同一进程先服务 cyrene 再服务 furina,第二次请求体里的 voice 必须是 furina 的,
// **绝不**沿用第一次。
func TestServiceSequentialPersonasDoNotReuseVoice(t *testing.T) {
svc, rec := newRecordingService(t, personaCfg(), &fakeChain{})
ctx := context.Background()
audio, rung, err := svc.TTSWithProvider(context.Background(), chineseSpeech)
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" {
t.Errorf("rung = %q, want 底链报告的那一级", rung)
if rung != "silent" || string(audio.Data) != "fallback" || base.ttsCalls != 1 {
t.Fatalf("没有按既有口径回落: rung=%q audio=%q calls=%d", rung, audio.Data, base.ttsCalls)
}
if string(audio.Data) != "fallback" {
t.Errorf("audio = %+v, want 底链产物", audio)
}
if base.ttsCalls != 1 {
t.Errorf("底链被调用 %d 次, want 1", base.ttsCalls)
}
if got := svc.Stats(); got.Failures != 1 {
t.Errorf("降级没有计数: %+v", got)
if got := rec.VoiceLog(); len(got) != 1 || got[0] != "char_5314489e" {
t.Fatalf("失败那次用的 voice = %v, want [char_5314489e](应先按正确角色试)", got)
}
}
@@ -159,18 +301,32 @@ func TestServiceDelegatesSTTAndClone(t *testing.T) {
}
}
// TestProviderAdapter 钉住"作为链的一级"这条路现在就是通的(STT 方向为 nil)。
func TestProviderAdapter(t *testing.T) {
client := New(Config{Enabled: true, BaseURL: "http://127.0.0.1:1"}, WithLogger(discardLogger()))
p := NewProvider(client)
// 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(context.Background(), " "); !errors.Is(err, ErrEmptyText) {
t.Errorf("err = %v, want ErrEmptyText(穿透到 Client 的守卫)", err)
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)