Files
Cyrene_v2/internal/services/gateway/voice_emotiontts_test.go
T
AskaEth 1c2944fcf4 docs(voice): 收回 r2 修掉的旧语义注释 + 让关闭态变异真能打红(r3 P2-1/P2-2)
P2-1(两处注释在描述已被推翻的旧语义,会把 r2 的修复写回去):
- voice.go 的 voiceReplySink 文档:原写「personaID = env.PersonaID,会话无绑定时为空」
  ⇒ 实际传的是 turnPersona.Effective,**未绑定 ≠ 空**(有全局默认人格时非空)。
  改为写明「生效人格(会话绑定 → 全局默认人格;两者都没有才是空)」,并显式指出
  它不是 env.PersonaID、把空当"没有角色"会让主会话落到 default(正是 r2 修掉的)。
- voice.go 的 synthesizeVoiceReply:原写「空 personaID = 会话无绑定 ⇒ 走 default」
  ⇒ 反了。改为「空 = 连全局默认人格都没有 ⇒ 才走 voice-tts.json 的 default」。
- 同族 format 残留措辞:client_test.go 的「非法格式的回落」(该行为已不存在)、
  cmd/cyrene/wire_voice_tts_test.go 的「角色/格式/超时按文件来」都改写。
- nit:voice.go 的 `env, turnPersona := ...` 遮蔽同包类型名,改成 `tp`。

P2-2(我上轮那条「摘掉 PrimaryActive 闸 ⇒ 用例红」不成立,复审实测仍绿):
根因是 audioStubVoice 不实现 PrimaryActive,拦住 persona 读取的只有类型断言。
按塔选的第二种收口(把测试做强):
- audioStubVoice 补 `PrimaryActive() bool { return true }`(它是"生效中的 persona
  voice"替身),并在注释里说明它存在的理由;
- 关闭态用例改用新替身 inactivePersonaVoice(实现 personaVoice 但报 false),
  于是 PrimaryActive 成为关闭态唯一的闸;
- 重跑变异(摘掉 `&& pv.PrimaryActive()`)**实测打红**:
  「未生效的 personaVoice 却查了 1 次会话人格(关闭态行为变了)」。

未动:功能面、错误码、500 状态码、白名单、session 可读、隐私/format/Stats/redactURL。
go build ./... + go vet ./... + go test -count=1 -race 覆盖 tts/runtimeconfig/gateway/
cmd/cyrene 全绿。
2026-10-26 13:32:04 +08:00

340 lines
14 KiB
Go
Raw 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 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 ⇒ persona 解析不到 ⇒ 用配置的 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
}