唯一 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 全绿。
341 lines
14 KiB
Go
341 lines
14 KiB
Go
package gateway
|
||
|
||
// 本文件是切片 M35 的**两条既有出站口实测**,并且钉住主人 2026-10-26 的硬要求:
|
||
// **发音人跟着会话的人格走**(绝不定死一个)。
|
||
//
|
||
// 1. `POST /api/v1/audio/speech`:回的是音频字节(不是 JSON 信封/空体),且
|
||
// 请求体里的 `sessionId` 解析到的人格决定上游 `voice`;
|
||
// 2. WS `voice_output`:帧里带的是那段音频的 base64,且发音人取**本回合**的
|
||
// `env.PersonaID`(不是进程启动时算一次)。
|
||
//
|
||
// 断言落在「上游请求体里的 voice」与「帧里音频的内容」上 —— 它们才能证明
|
||
// "换了人格就换了声音"。
|
||
|
||
import (
|
||
"context"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"sync"
|
||
"testing"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
|
||
voicetts "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/tts"
|
||
)
|
||
|
||
// personaCharacters 是两个角色各自的上游 character id(与生产配置同形)。
|
||
var personaCharacters = map[string]string{
|
||
"cyrene": "char_84fb88a3",
|
||
"furina": "char_5314489e",
|
||
}
|
||
|
||
// fakeWAV 造一段魔数合法、且**内容里带着 voice 名**的假 WAV —— 这样端到端断言
|
||
// 帧/响应里的字节就能直接看出"这段声音是谁的"。
|
||
func fakeWAV(voice string) []byte {
|
||
b := []byte("RIFF\x00\x00\x00\x00WAVEfmt ")
|
||
b = append(b, make([]byte, 16)...)
|
||
b = append(b, []byte("data\x00\x00\x00\x00")...)
|
||
return append(b, []byte("voice="+voice)...)
|
||
}
|
||
|
||
// vocalRecorder 是一台假 emotionTTS:记下每次请求体里的 voice。
|
||
type vocalRecorder struct {
|
||
mu sync.Mutex
|
||
voices []string
|
||
}
|
||
|
||
// handler 解码请求体、记录 voice、回一段内容里带 voice 的 WAV。
|
||
func (r *vocalRecorder) handler(t *testing.T) http.HandlerFunc {
|
||
t.Helper()
|
||
return func(w http.ResponseWriter, req *http.Request) {
|
||
if req.URL.Path != "/v1/audio/speech" {
|
||
t.Errorf("path = %q, want /v1/audio/speech", req.URL.Path)
|
||
}
|
||
var payload struct {
|
||
Model string `json:"model"`
|
||
Input string `json:"input"`
|
||
Voice string `json:"voice"`
|
||
}
|
||
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
|
||
t.Errorf("请求体不是合法 UTF-8 JSON: %v", err)
|
||
}
|
||
if payload.Model != "emotionTTS" {
|
||
t.Errorf("model = %q, want emotionTTS", payload.Model)
|
||
}
|
||
r.mu.Lock()
|
||
r.voices = append(r.voices, payload.Voice)
|
||
r.mu.Unlock()
|
||
w.Header().Set("Content-Type", "audio/wav")
|
||
_, _ = w.Write(fakeWAV(payload.Voice))
|
||
}
|
||
}
|
||
|
||
// Voices 返回**按顺序**记下的 voice。
|
||
func (r *vocalRecorder) Voices() []string {
|
||
r.mu.Lock()
|
||
defer r.mu.Unlock()
|
||
out := make([]string, len(r.voices))
|
||
copy(out, r.voices)
|
||
return out
|
||
}
|
||
|
||
// emotionTTSService 用假上游构造一个**真的** tts.Service(不是 stub)。
|
||
//
|
||
// cfg 由调用方覆盖(personas / default),本函数只补齐 Enabled 与 BaseURL。
|
||
func emotionTTSService(t *testing.T, rec *vocalRecorder, cfg voicetts.Config, base voicetts.Chain) *voicetts.Service {
|
||
t.Helper()
|
||
srv := httptest.NewServer(rec.handler(t))
|
||
t.Cleanup(srv.Close)
|
||
cfg.Enabled = true
|
||
cfg.BaseURL = srv.URL
|
||
return voicetts.NewService(voicetts.New(cfg), base)
|
||
}
|
||
|
||
// personaHistory 是一个**只报一个 persona** 的 history 替身(满足
|
||
// `sessionPersonaReader`),用来驱动两条口的人格解析。它复用测试包里的
|
||
// fakeHistory 以满足 HistoryStore 的其余方法。
|
||
type personaHistory struct {
|
||
*fakeHistory
|
||
persona string
|
||
}
|
||
|
||
// SessionPersona 报该 persona(未知会话与读错误对上层都退化成 "",这里只测成功路径)。
|
||
func (h personaHistory) SessionPersona(context.Context, string) (string, error) {
|
||
return h.persona, nil
|
||
}
|
||
|
||
// personaResolver 造一个把 sessionID 解析成给定 persona 的解析器。
|
||
func personaResolver(persona string) turnEnvResolver {
|
||
return turnEnvResolver{history: personaHistory{fakeHistory: &fakeHistory{}, persona: persona}, log: sourcedLog(nil)}
|
||
}
|
||
|
||
// TestSpeechEndpointVoiceFollowsSessionPersona 是 REST 口的实测:同一个进程、同一台
|
||
// 服务,**先**用 cyrene 会话、**后**用 furina 会话 ⇒ 两次上游请求体里的 voice 分别是
|
||
// 两个不同的角色(第二次绝不沿用第一次)。
|
||
func TestSpeechEndpointVoiceFollowsSessionPersona(t *testing.T) {
|
||
env := newEnv(t, nil, false)
|
||
rec := &vocalRecorder{}
|
||
base := &silentBase{}
|
||
env.srv.SetVoice(emotionTTSService(t, rec, voicetts.Config{Personas: personaCharacters}, base))
|
||
token := env.token(t, auth.RoleOwner)
|
||
|
||
for _, persona := range []string{"cyrene", "furina"} {
|
||
env.srv.envResolver = personaResolver(persona)
|
||
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/speech", map[string]any{
|
||
"text": "你好,我是苏梦。", "sessionId": "web:" + persona,
|
||
}, token)
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("%s: status = %d, body = %s", persona, resp.StatusCode, body)
|
||
}
|
||
if ct := resp.Header.Get("Content-Type"); ct != "audio/wav" {
|
||
t.Errorf("%s: Content-Type = %q, want audio/wav", persona, ct)
|
||
}
|
||
if string(body) != string(fakeWAV(personaCharacters[persona])) {
|
||
t.Fatalf("%s: 响应体不是该角色的音频: %d bytes", persona, len(body))
|
||
}
|
||
}
|
||
want := []string{personaCharacters["cyrene"], personaCharacters["furina"]}
|
||
got := rec.Voices()
|
||
if len(got) != 2 || got[0] != want[0] || got[1] != want[1] {
|
||
t.Fatalf("请求体里的 voice = %v, want %v", got, want)
|
||
}
|
||
}
|
||
|
||
// TestSpeechEndpointUnmappedPersonaRefuses 是 REST 口的红线:会话绑的 persona 没有
|
||
// 映射 ⇒ **不合成**(上游 0 次请求),接口如实报错,绝不借用别的角色。
|
||
func TestSpeechEndpointUnmappedPersonaRefuses(t *testing.T) {
|
||
env := newEnv(t, nil, false)
|
||
rec := &vocalRecorder{}
|
||
env.srv.SetVoice(emotionTTSService(t, rec, voicetts.Config{
|
||
Personas: personaCharacters,
|
||
Default: personaCharacters["cyrene"], // 配了 default 也**不许**顶未映射的 persona
|
||
}, &silentBase{}))
|
||
env.srv.envResolver = personaResolver("stranger")
|
||
|
||
token := env.token(t, auth.RoleOwner)
|
||
resp, _ := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/speech", map[string]any{
|
||
"text": "你好", "sessionId": "web:sub-9",
|
||
}, token)
|
||
if resp.StatusCode == http.StatusOK {
|
||
t.Fatal("未映射 persona 却回了 200 —— 会让她用别人的声音说话")
|
||
}
|
||
if got := rec.Voices(); len(got) != 0 {
|
||
t.Fatalf("上游请求数 = %d, want 0(voice=%v)", len(got), got)
|
||
}
|
||
}
|
||
|
||
// TestSpeechEndpointNoSessionUsesDefault 钉住"这条口没有会话时"的诚实处置:
|
||
// 省略 sessionId ⇒ 生效人格只能来自**全局默认人格**;本用例没有接人格库,
|
||
// 因此连默认人格都没有 ⇒ 才落到配置的 default(没配则同样不合成)。
|
||
func TestSpeechEndpointNoSessionUsesDefault(t *testing.T) {
|
||
env := newEnv(t, nil, false)
|
||
rec := &vocalRecorder{}
|
||
defaultVoice := "char_default_voice"
|
||
env.srv.SetVoice(emotionTTSService(t, rec, voicetts.Config{
|
||
Personas: personaCharacters,
|
||
Default: defaultVoice,
|
||
}, &silentBase{}))
|
||
token := env.token(t, auth.RoleOwner)
|
||
|
||
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/speech", map[string]any{
|
||
"text": "你好",
|
||
}, token)
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("status = %d, body = %s", resp.StatusCode, body)
|
||
}
|
||
if got := rec.Voices(); len(got) != 1 || got[0] != defaultVoice {
|
||
t.Fatalf("请求体里的 voice = %v, want [%s]", got, defaultVoice)
|
||
}
|
||
}
|
||
|
||
// TestVoiceOutputFrameVoiceFollowsTurnPersona 是 WS 口的实测:发音人取**本回合**的
|
||
// env.PersonaID。两次回合(两个 persona)⇒ 两次帧里的音频内容分别属于两个角色。
|
||
func TestVoiceOutputFrameVoiceFollowsTurnPersona(t *testing.T) {
|
||
for _, persona := range []string{"cyrene", "furina"} {
|
||
t.Run(persona, func(t *testing.T) {
|
||
hb := newTestHub(&stubRunner{res: &loop.Result{Text: "你好,我是苏梦。", Steps: 1, StopReason: "stop"}}, nil)
|
||
hb.envResolver = personaResolver(persona)
|
||
rec := &vocalRecorder{}
|
||
hb.voice = emotionTTSService(t, rec, voicetts.Config{Personas: personaCharacters}, &silentBase{})
|
||
c := newOwnerConn(hb, 32)
|
||
hb.register(c)
|
||
|
||
c.route(wsInbound{Type: "voice_input", Data: json.RawMessage(`{"text":"你好","format":"wav"}`)})
|
||
ev := readFakeFrame(t, c)
|
||
if ev.Type != "voice_output" {
|
||
t.Fatalf("frame = %q, want voice_output (%+v)", ev.Type, ev)
|
||
}
|
||
data, ok := ev.Data.(map[string]any)
|
||
if !ok {
|
||
t.Fatalf("frame data = %T", ev.Data)
|
||
}
|
||
raw, err := base64.StdEncoding.DecodeString(asString(data["audio"]))
|
||
if err != nil || len(raw) == 0 {
|
||
t.Fatalf("帧里没有可解码的音频(err=%v)", err)
|
||
}
|
||
want := fakeWAV(personaCharacters[persona])
|
||
if string(raw) != string(want) {
|
||
t.Fatalf("帧里的声音不是 %s 的: %q", persona, string(raw))
|
||
}
|
||
if data["reply"] != "你好,我是苏梦。" {
|
||
t.Errorf("reply = %v, want 助手正文", data["reply"])
|
||
}
|
||
if got := rec.Voices(); len(got) != 1 || got[0] != personaCharacters[persona] {
|
||
t.Fatalf("请求体里的 voice = %v, want [%s]", got, personaCharacters[persona])
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestSpeechEndpointDisabledVoiceDoesNotReadPersona 是**关闭态零行为变化**的自证
|
||
// (复审要求在当前 tip 上重跑的那一条,而我这一版动了 `gateway/audio.go`):
|
||
//
|
||
// 注入的语音服务**实现了 personaVoice 却报告未生效**(`inactivePersonaVoice`,
|
||
// 模拟"门面在、这一级关着")时,语音合成**一次都不查会话人格** —— 与引入本切片
|
||
// 之前逐字同行为;反之(真 emotionTTS 门面,PrimaryActive=true)才会查,说明这个闸
|
||
// 不是"永远不查"。
|
||
//
|
||
// 变异:去掉 handleSpeech 里的 `&& pv.PrimaryActive()`(只留类型断言)⇒ 本用例
|
||
// 第一段红。🔴 这条变异此前**打不红**,因为替身不实现 `PrimaryActive`,拦住读库的
|
||
// 只有类型断言(复审 r3 P2-2);现在替身实现了它,布尔值才是唯一的闸。
|
||
func TestSpeechEndpointDisabledVoiceDoesNotReadPersona(t *testing.T) {
|
||
env := newEnv(t, nil, false)
|
||
stub := &inactivePersonaVoice{audioStubVoice: audioStubVoice{
|
||
ttsAudio: mvoice.Audio{Format: "wav", Data: fakeWAV("chain")},
|
||
}}
|
||
env.srv.SetVoice(stub)
|
||
counter := &countingHistory{fakeHistory: &fakeHistory{}}
|
||
env.srv.envResolver = turnEnvResolver{history: counter, log: env.srv.log}
|
||
token := env.token(t, auth.RoleOwner)
|
||
|
||
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/speech", map[string]any{
|
||
"text": "你好", "sessionId": "web:sub-1",
|
||
}, token)
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("status = %d, body = %s", resp.StatusCode, body)
|
||
}
|
||
if got := counter.Calls(); got != 0 {
|
||
t.Fatalf("未生效的 personaVoice 却查了 %d 次会话人格(关闭态行为变了)", got)
|
||
}
|
||
if string(body) != string(fakeWAV("chain")) {
|
||
t.Fatalf("关闭态的音频不是既有链产物: %q", string(body))
|
||
}
|
||
|
||
// 对照:接上真的 emotionTTS 门面后,同一个端点会查一次人格(闸不是恒关)。
|
||
env2 := newEnv(t, nil, false)
|
||
env2.srv.SetVoice(emotionTTSService(t, &vocalRecorder{}, voicetts.Config{Personas: personaCharacters}, &silentBase{}))
|
||
counter2 := &countingHistory{fakeHistory: &fakeHistory{}}
|
||
env2.srv.envResolver = turnEnvResolver{history: counter2, log: env2.srv.log}
|
||
if resp, body := doReq(t, http.MethodPost, env2.ts.URL+"/api/v1/audio/speech", map[string]any{
|
||
"text": "你好", "sessionId": "web:sub-1",
|
||
}, env.token(t, auth.RoleOwner)); resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("对照组 status = %d, body = %s", resp.StatusCode, body)
|
||
}
|
||
if got := counter2.Calls(); got != 1 {
|
||
t.Fatalf("启用 emotionTTS 后应查 1 次人格, got %d", got)
|
||
}
|
||
}
|
||
|
||
// countingHistory 记录 SessionPersona 被查了几次(同时满足 HistoryStore 的其余方法)。
|
||
type countingHistory struct {
|
||
*fakeHistory
|
||
mu sync.Mutex
|
||
calls int
|
||
}
|
||
|
||
// SessionPersona 计数并回一个固定的 persona。
|
||
func (h *countingHistory) SessionPersona(context.Context, string) (string, error) {
|
||
h.mu.Lock()
|
||
h.calls++
|
||
h.mu.Unlock()
|
||
return "furina", nil
|
||
}
|
||
|
||
// Calls 返回查询次数。
|
||
func (h *countingHistory) Calls() int {
|
||
h.mu.Lock()
|
||
defer h.mu.Unlock()
|
||
return h.calls
|
||
}
|
||
|
||
// inactivePersonaVoice 是"**实现了** personaVoice 但报告这一级未生效"的替身
|
||
// (模拟 emotionTTS 门面存在、`enabled` 关着的情形)。它让 `PrimaryActive()` 成为
|
||
// 关闭态唯一的闸 —— 这样把 `&& pv.PrimaryActive()` 摘掉的变异才会打红。
|
||
type inactivePersonaVoice struct{ audioStubVoice }
|
||
|
||
// PrimaryActive 报告"按 persona 选发音"的那一级**未**生效。
|
||
func (i *inactivePersonaVoice) PrimaryActive() bool { return false }
|
||
|
||
// asString 安全地取一个 string 值(帧载荷是 map[string]any)。
|
||
func asString(v any) string {
|
||
s, _ := v.(string)
|
||
return s
|
||
}
|
||
|
||
// silentBase 是最小的底链替身:只在测"主路健康"时用作类型占位,
|
||
// 一旦被调用就说明主路降级了(测试会因此失败)。
|
||
type silentBase struct{}
|
||
|
||
func (s *silentBase) STT(_ context.Context, _ mvoice.Audio) (string, error) {
|
||
return "", mvoice.ErrProviderFailed
|
||
}
|
||
|
||
func (s *silentBase) STTWithProvider(_ context.Context, _ mvoice.Audio) (string, string, error) {
|
||
return "", "", mvoice.ErrProviderFailed
|
||
}
|
||
|
||
func (s *silentBase) TTS(_ context.Context, _ string) (mvoice.Audio, error) {
|
||
return mvoice.Audio{}, mvoice.ErrProviderFailed
|
||
}
|
||
|
||
func (s *silentBase) TTSWithProvider(_ context.Context, _ string) (mvoice.Audio, string, error) {
|
||
return mvoice.Audio{}, "", mvoice.ErrProviderFailed
|
||
}
|
||
|
||
func (s *silentBase) CloneVoice(_ context.Context, _, _ string) (string, error) {
|
||
return "", mvoice.ErrProviderFailed
|
||
}
|