Files
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

245 lines
7.8 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 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)
}
}