feat(voice): 语音silent降级信号(C2) — provider仅在结果为空时出现
- voice/Chain+Service: STTWithProvider/TTSWithProvider(真实档位cloud/local/edge-tts/espeak/silent), 旧方法改薄包装
- gateway: transcribe空文本带{text,provider} / voice_output空音频带provider / speech空音频tts_empty+X-Voice-Provider头
- middleware: CORS Access-Control-Expose-Headers: X-Voice-Provider
- 可选接口 voiceProviderReporter(type断言透传), 自定义实现回退silent
- 新增测试10个(provider_signal/voice_signal), build/vet/test全绿+交叉编译
- Docs/api-client v0.23 + api-reference CORS 同步
This commit is contained in:
1 parent
00f784b55f
commit
99f222761e
9 files changed
+345
-25
No files matched your search
@@ -0,0 +1,181 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
|
||||
)
|
||||
|
||||
// signalVoice is a VoiceService that also implements voiceProviderReporter, so a
|
||||
// test can control both the STT/TTS result and the reported provider rung.
|
||||
type signalVoice struct {
|
||||
text string
|
||||
audio voice.Audio
|
||||
rung string
|
||||
sttErr error
|
||||
ttsErr error
|
||||
}
|
||||
|
||||
func (s *signalVoice) STT(_ context.Context, _ voice.Audio) (string, error) {
|
||||
return s.text, s.sttErr
|
||||
}
|
||||
|
||||
func (s *signalVoice) TTS(_ context.Context, _ string) (voice.Audio, error) {
|
||||
return s.audio, s.ttsErr
|
||||
}
|
||||
|
||||
func (s *signalVoice) CloneVoice(_ context.Context, _, _ string) (string, error) { return "", nil }
|
||||
|
||||
func (s *signalVoice) STTWithProvider(_ context.Context, _ voice.Audio) (string, string, error) {
|
||||
return s.text, s.rung, s.sttErr
|
||||
}
|
||||
|
||||
func (s *signalVoice) TTSWithProvider(_ context.Context, _ string) (voice.Audio, string, error) {
|
||||
return s.audio, s.rung, s.ttsErr
|
||||
}
|
||||
|
||||
func TestHandleTranscribe_SilentSignal(t *testing.T) {
|
||||
env := newEnv(t, nil, false)
|
||||
env.srv.SetVoice(&signalVoice{rung: "silent"})
|
||||
|
||||
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")),
|
||||
}, token)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", resp.StatusCode, body)
|
||||
}
|
||||
var data struct {
|
||||
Text string `json:"text"`
|
||||
Provider string `json:"provider"`
|
||||
}
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &data); err != nil {
|
||||
t.Fatalf("decode data: %v (body=%s)", err, body)
|
||||
}
|
||||
if data.Text != "" || data.Provider != "silent" {
|
||||
t.Errorf("data = %+v, want empty text + provider silent", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTranscribe_SilentFallbackWithoutReporter(t *testing.T) {
|
||||
env := newEnv(t, nil, false)
|
||||
// audioStubVoice does not implement voiceProviderReporter, so the handler
|
||||
// must still flag an empty transcription as silent.
|
||||
env.srv.SetVoice(&audioStubVoice{})
|
||||
|
||||
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")),
|
||||
}, token)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", resp.StatusCode, body)
|
||||
}
|
||||
var data struct {
|
||||
Provider string `json:"provider"`
|
||||
}
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &data); err != nil {
|
||||
t.Fatalf("decode data: %v (body=%s)", err, body)
|
||||
}
|
||||
if data.Provider != "silent" {
|
||||
t.Errorf("provider = %q, want silent (body=%s)", data.Provider, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTranscribe_SuccessOmitsProvider(t *testing.T) {
|
||||
env := newEnv(t, nil, false)
|
||||
env.srv.SetVoice(&signalVoice{text: "你好", rung: "cloud"})
|
||||
|
||||
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")),
|
||||
}, token)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", resp.StatusCode, body)
|
||||
}
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &raw); err != nil {
|
||||
t.Fatalf("decode data: %v (body=%s)", err, body)
|
||||
}
|
||||
if _, present := raw["provider"]; present {
|
||||
t.Errorf("provider must be omitted on a non-empty transcription: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSpeech_EmptyAudioSignal(t *testing.T) {
|
||||
env := newEnv(t, nil, false)
|
||||
env.srv.SetVoice(&signalVoice{rung: "silent"})
|
||||
|
||||
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.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
if got := resp.Header.Get(voiceProviderHeader); got != "silent" {
|
||||
t.Errorf("%s = %q, want silent", voiceProviderHeader, got)
|
||||
}
|
||||
var env2 struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &env2); err != nil {
|
||||
t.Fatalf("decode error: %v (body=%s)", err, body)
|
||||
}
|
||||
if env2.Error.Code != "tts_empty" {
|
||||
t.Errorf("error code = %q, want tts_empty (body=%s)", env2.Error.Code, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubVoiceOutput_SilentSignal(t *testing.T) {
|
||||
t.Parallel()
|
||||
hb := newTestHub(&stubRunner{res: &loop.Result{Text: "你好", Steps: 1, StopReason: "stop"}}, nil)
|
||||
hb.voice = &signalVoice{text: "你好", rung: "silent"}
|
||||
c := newFakeConn(hb, 32)
|
||||
hb.register(c)
|
||||
|
||||
c.route(wsInbound{Type: "voice_input", Data: json.RawMessage(`{"audio":"aGVsbG8=","format":"wav"}`)})
|
||||
ev := readFakeFrame(t, c)
|
||||
if ev.Type != wsVoiceOutput {
|
||||
t.Fatalf("frame type = %q, want voice_output (frame=%+v)", ev.Type, ev)
|
||||
}
|
||||
r, ok := ev.Data.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("data = %T, want map[string]any", ev.Data)
|
||||
}
|
||||
if r["provider"] != "silent" {
|
||||
t.Errorf("provider = %v, want silent (frame=%+v)", r["provider"], ev.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubVoiceOutput_SuccessOmitsProvider(t *testing.T) {
|
||||
t.Parallel()
|
||||
hb := newTestHub(&stubRunner{res: &loop.Result{Text: "你好", Steps: 1, StopReason: "stop"}}, nil)
|
||||
hb.voice = &signalVoice{
|
||||
text: "你好",
|
||||
audio: voice.Audio{Format: "wav", Data: []byte("audio-bytes")},
|
||||
rung: "edge-tts",
|
||||
}
|
||||
c := newFakeConn(hb, 32)
|
||||
hb.register(c)
|
||||
|
||||
c.route(wsInbound{Type: "voice_input", Data: json.RawMessage(`{"audio":"aGVsbG8=","format":"wav"}`)})
|
||||
ev := readFakeFrame(t, c)
|
||||
if ev.Type != wsVoiceOutput {
|
||||
t.Fatalf("frame type = %q, want voice_output (frame=%+v)", ev.Type, ev)
|
||||
}
|
||||
r, ok := ev.Data.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("data = %T, want map[string]any", ev.Data)
|
||||
}
|
||||
if _, present := r["provider"]; present {
|
||||
t.Errorf("provider must be omitted for non-empty audio: %+v", ev.Data)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user