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 全绿。
245 lines
7.8 KiB
Go
245 lines
7.8 KiB
Go
package gateway
|
||
|
||
import (
|
||
"context"
|
||
"encoding/base64"
|
||
"net/http"
|
||
"strings"
|
||
"testing"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
|
||
)
|
||
|
||
// audioStubVoice 是一个 VoiceService,为 REST /api/v1/audio/* handler
|
||
// 测试返回固定的 STT/TTS 结果。
|
||
type audioStubVoice struct {
|
||
sttText string
|
||
sttErr error
|
||
ttsAudio voice.Audio
|
||
ttsErr error
|
||
gotAudio voice.Audio
|
||
gotText string
|
||
sttCalled bool
|
||
ttsCalled bool
|
||
|
||
cloneVoiceID string
|
||
cloneErr error
|
||
cloneCalled bool
|
||
gotCloneURL string
|
||
gotClonePfx string
|
||
gotInstr string
|
||
}
|
||
|
||
func (s *audioStubVoice) STT(ctx context.Context, audio voice.Audio) (string, error) {
|
||
s.sttCalled = true
|
||
s.gotAudio = audio
|
||
return s.sttText, s.sttErr
|
||
}
|
||
|
||
func (s *audioStubVoice) TTS(ctx context.Context, text string) (voice.Audio, error) {
|
||
s.ttsCalled = true
|
||
s.gotText = text
|
||
s.gotInstr = voice.TTSInstructionFromContext(ctx)
|
||
return s.ttsAudio, s.ttsErr
|
||
}
|
||
|
||
func (s *audioStubVoice) CloneVoice(ctx context.Context, referenceURL, prefix string) (string, error) {
|
||
s.cloneCalled = true
|
||
s.gotCloneURL = referenceURL
|
||
s.gotClonePfx = prefix
|
||
return s.cloneVoiceID, s.cloneErr
|
||
}
|
||
|
||
// PrimaryActive 让本替身**实现**网关的 `personaVoice` 扩展并报告"按 persona 选发音
|
||
// 的那一级正在生效"(复审 r3 P2-2)。
|
||
//
|
||
// 🔴 它存在是为了让"关闭态不查库"那条用例的变异**真能打红**:此前 `audioStubVoice`
|
||
// 不实现 `PrimaryActive`,于是拦下 persona 读取的只有 `s.voice.(personaVoice)` 这个
|
||
// **类型断言**,把 `&& pv.PrimaryActive()` 摘掉用例照样绿 —— 等于那条变异没被覆盖。
|
||
// 现在类型断言会通过,拦住它的只剩这个布尔值。要表达"未生效"的替身请用
|
||
// `inactivePersonaVoice`(`voice_emotiontts_test.go`)。
|
||
func (s *audioStubVoice) PrimaryActive() bool { return true }
|
||
|
||
func TestHandleTranscribe(t *testing.T) {
|
||
env := newEnv(t, nil, false)
|
||
sv := &audioStubVoice{sttText: "你好世界"}
|
||
env.srv.SetVoice(sv)
|
||
|
||
token := env.token(t, auth.RoleOwner)
|
||
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/transcribe", map[string]any{
|
||
"audio": base64.StdEncoding.EncodeToString([]byte("raw")),
|
||
"format": "wav",
|
||
}, token)
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("status = %d, body = %s", resp.StatusCode, body)
|
||
}
|
||
if !sv.sttCalled {
|
||
t.Fatal("STT not called")
|
||
}
|
||
if string(sv.gotAudio.Data) != "raw" || sv.gotAudio.Format != "wav" {
|
||
t.Errorf("audio = %+v, want format wav / data raw", sv.gotAudio)
|
||
}
|
||
env2 := decodeBody(t, body)
|
||
if got := string(env2["data"]); got == "" {
|
||
t.Errorf("data envelope empty: %s", body)
|
||
}
|
||
}
|
||
|
||
func TestHandleTranscribe_AuthRequired(t *testing.T) {
|
||
env := newEnv(t, nil, false)
|
||
sv := &audioStubVoice{sttText: "x"}
|
||
env.srv.SetVoice(sv)
|
||
|
||
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/transcribe", map[string]any{
|
||
"audio": base64.StdEncoding.EncodeToString([]byte("raw")),
|
||
}, "")
|
||
if resp.StatusCode != http.StatusUnauthorized {
|
||
t.Fatalf("status = %d, want 401 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
}
|
||
|
||
func TestHandleTranscribe_EmptyAudio(t *testing.T) {
|
||
env := newEnv(t, nil, false)
|
||
sv := &audioStubVoice{sttText: "x"}
|
||
env.srv.SetVoice(sv)
|
||
|
||
token := env.token(t, auth.RoleOwner)
|
||
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/transcribe", map[string]any{
|
||
"audio": base64.StdEncoding.EncodeToString([]byte("")),
|
||
}, token)
|
||
if resp.StatusCode != http.StatusBadRequest {
|
||
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
}
|
||
|
||
func TestHandleSpeech(t *testing.T) {
|
||
env := newEnv(t, nil, false)
|
||
sv := &audioStubVoice{ttsAudio: voice.Audio{Format: "wav", Data: []byte("audio-bytes")}}
|
||
env.srv.SetVoice(sv)
|
||
|
||
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 ct := resp.Header.Get("Content-Type"); ct != "audio/wav" {
|
||
t.Errorf("Content-Type = %q, want audio/wav", ct)
|
||
}
|
||
if string(body) != "audio-bytes" {
|
||
t.Errorf("body = %q, want audio-bytes", body)
|
||
}
|
||
if !sv.ttsCalled || sv.gotText != "你好" {
|
||
t.Errorf("TTS got text = %q (called=%v), want 你好", sv.gotText, sv.ttsCalled)
|
||
}
|
||
}
|
||
|
||
func TestHandleSpeech_EmptyText(t *testing.T) {
|
||
env := newEnv(t, nil, false)
|
||
env.srv.SetVoice(&audioStubVoice{})
|
||
|
||
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.StatusBadRequest {
|
||
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
}
|
||
|
||
func TestHandleSpeech_InstructionPassthrough(t *testing.T) {
|
||
env := newEnv(t, nil, false)
|
||
sv := &audioStubVoice{ttsAudio: voice.Audio{Format: "wav", Data: []byte("audio-bytes")}}
|
||
env.srv.SetVoice(sv)
|
||
|
||
token := env.token(t, auth.RoleOwner)
|
||
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/speech", map[string]any{
|
||
"text": "你好,我是昔涟",
|
||
"instruction": "语气开朗俏皮,像女朋友撒娇",
|
||
}, token)
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("status = %d, body = %s", resp.StatusCode, body)
|
||
}
|
||
if !sv.ttsCalled {
|
||
t.Fatal("TTS not called")
|
||
}
|
||
if sv.gotText != "你好,我是昔涟" {
|
||
t.Errorf("TTS text = %q, want 你好,我是昔涟", sv.gotText)
|
||
}
|
||
if sv.gotInstr != "语气开朗俏皮,像女朋友撒娇" {
|
||
t.Errorf("instruction threaded = %q, want the per-request instruction", sv.gotInstr)
|
||
}
|
||
}
|
||
|
||
func TestHandleSpeech_NoInstruction(t *testing.T) {
|
||
env := newEnv(t, nil, false)
|
||
sv := &audioStubVoice{ttsAudio: voice.Audio{Format: "wav", Data: []byte("audio-bytes")}}
|
||
env.srv.SetVoice(sv)
|
||
|
||
token := env.token(t, auth.RoleOwner)
|
||
resp, _ := 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, want 200", resp.StatusCode)
|
||
}
|
||
if sv.gotInstr != "" {
|
||
t.Errorf("instruction = %q, want empty when not supplied", sv.gotInstr)
|
||
}
|
||
}
|
||
|
||
func TestHandleVoiceClone(t *testing.T) {
|
||
env := newEnv(t, nil, false)
|
||
sv := &audioStubVoice{cloneVoiceID: "cosyvoice-v3-flash-cyrene-abc123"}
|
||
env.srv.SetVoice(sv)
|
||
|
||
token := env.token(t, auth.RoleOwner)
|
||
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/voice-clone", map[string]any{
|
||
"url": "https://example.com/ref.wav",
|
||
"prefix": "xilian",
|
||
}, token)
|
||
if resp.StatusCode != http.StatusOK {
|
||
t.Fatalf("status = %d, body = %s", resp.StatusCode, body)
|
||
}
|
||
if !sv.cloneCalled {
|
||
t.Fatal("CloneVoice not called")
|
||
}
|
||
if sv.gotCloneURL != "https://example.com/ref.wav" || sv.gotClonePfx != "xilian" {
|
||
t.Errorf("clone url/prefix = %q / %q, want the request values", sv.gotCloneURL, sv.gotClonePfx)
|
||
}
|
||
env2 := decodeBody(t, body)
|
||
if got := string(env2["data"]); got == "" {
|
||
t.Errorf("data envelope empty: %s", body)
|
||
}
|
||
if !strings.Contains(string(env2["data"]), "cosyvoice-v3-flash-cyrene-abc123") {
|
||
t.Errorf("voiceId missing: %s", body)
|
||
}
|
||
}
|
||
|
||
func TestHandleVoiceClone_AuthRequired(t *testing.T) {
|
||
env := newEnv(t, nil, false)
|
||
env.srv.SetVoice(&audioStubVoice{})
|
||
|
||
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/voice-clone", map[string]any{
|
||
"url": "https://example.com/ref.wav",
|
||
}, "")
|
||
if resp.StatusCode != http.StatusUnauthorized {
|
||
t.Fatalf("status = %d, want 401 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
}
|
||
|
||
func TestHandleVoiceClone_EmptyURL(t *testing.T) {
|
||
env := newEnv(t, nil, false)
|
||
env.srv.SetVoice(&audioStubVoice{})
|
||
|
||
token := env.token(t, auth.RoleOwner)
|
||
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/voice-clone", map[string]any{
|
||
"url": " ",
|
||
}, token)
|
||
if resp.StatusCode != http.StatusBadRequest {
|
||
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body)
|
||
}
|
||
}
|