package gateway // 本文件是切片 M35 的**两条既有出站口实测**:把真的 emotionTTS provider // (指向一个 httptest 上游)注入网关,然后 // // 1. `POST /api/v1/audio/speech` 回的**是音频字节**(而不是 JSON 信封/空体); // 2. WS `voice_output` 帧里带的**是那段音频的 base64**。 // // 它钉的是"接上 provider ≠ 用户能听到"这件事:两条口各自的输出都被逐字节断言过。 import ( "context" "encoding/base64" "encoding/json" "net/http" "net/http/httptest" "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" ) // fakeWAV 是一段魔数合法的假 WAV(本文件的断言只关心"字节原样跑到客户端")。 func fakeWAV() []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("her-emotion-voice")...) } // emotionTTSServer 起一台假的 emotionTTS:校验请求是 UTF-8 JSON,回一段 WAV。 func emotionTTSServer(t *testing.T) *httptest.Server { t.Helper() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/audio/speech" { t.Errorf("path = %q, want /v1/audio/speech", r.URL.Path) } var payload struct { Model string `json:"model"` Input string `json:"input"` Voice string `json:"voice"` } if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { t.Errorf("请求体不是合法 UTF-8 JSON: %v", err) } if payload.Voice != voicetts.DefaultCharacter || payload.Model != "emotionTTS" { t.Errorf("payload = %+v, want emotionTTS / 默认角色", payload) } w.Header().Set("Content-Type", "audio/wav") _, _ = w.Write(fakeWAV()) })) t.Cleanup(srv.Close) return srv } // emotionTTSService 用假上游构造一个**真的** tts.Service(不是 stub)。 func emotionTTSService(t *testing.T, base voicetts.Chain) *voicetts.Service { t.Helper() srv := emotionTTSServer(t) client := voicetts.New(voicetts.Config{Enabled: true, BaseURL: srv.URL}) return voicetts.NewService(client, base) } // TestSpeechEndpointServesEmotionTTSAudio 是 REST 口(`POST /api/v1/audio/speech`) // 的实测证据:状态 200、Content-Type 音频、体就是上游那串字节。 func TestSpeechEndpointServesEmotionTTSAudio(t *testing.T) { env := newEnv(t, nil, false) env.srv.SetVoice(emotionTTSService(t, nil)) 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 len(body) == 0 { t.Fatal("响应体为空:用户听不到任何声音") } if string(body) != string(fakeWAV()) { t.Fatalf("body 不是上游音频字节: %d bytes", len(body)) } } // TestVoiceOutputFrameCarriesEmotionTTSAudio 是 WS 口(`voice_output` 帧)的实测 // 证据:帧里 audio 字段解码后就是上游那串字节,且 text/reply 都在。 func TestVoiceOutputFrameCarriesEmotionTTSAudio(t *testing.T) { hb := newTestHub(&stubRunner{res: &loop.Result{Text: "你好,我是苏梦。", Steps: 1, StopReason: "stop"}}, nil) hb.voice = emotionTTSService(t, &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) } encoded, _ := data["audio"].(string) if encoded == "" { t.Fatal("帧里没有 audio:用户听不到任何声音") } raw, err := base64.StdEncoding.DecodeString(encoded) if err != nil { t.Fatalf("audio 不是合法 base64: %v", err) } if string(raw) != string(fakeWAV()) { t.Fatalf("帧里的音频不是上游字节: %d bytes", len(raw)) } if data["reply"] != "你好,我是苏梦。" { t.Errorf("reply = %v, want 助手正文", data["reply"]) } } // 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 }