package voice import ( "context" "encoding/base64" "encoding/hex" "encoding/json" "errors" "net/http" "net/http/httptest" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" ) func TestDashscopeSTT(t *testing.T) { t.Parallel() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !strings.HasSuffix(r.URL.Path, "/generation") { t.Errorf("path = %q, want .../generation", r.URL.Path) } if r.Header.Get("Authorization") != "Bearer sk" { t.Errorf("auth = %q, want Bearer sk", r.Header.Get("Authorization")) } if r.Header.Get("X-DashScope-SSE") != "disable" { t.Errorf("sse = %q, want disable", r.Header.Get("X-DashScope-SSE")) } var body map[string]any if err := json.NewDecoder(r.Body).Decode(&body); err != nil { t.Fatalf("decode: %v", err) } if body["model"] != "qwen-audio-3.0-asr-flash" { t.Errorf("model = %v, want qwen-audio-3.0-asr-flash", body["model"]) } input := body["input"].(map[string]any) msgs := input["messages"].([]any) content := msgs[0].(map[string]any)["content"].([]any) audio := content[0].(map[string]any)["input_audio"].(map[string]any)["data"].(string) if !strings.HasPrefix(audio, "data:audio/wav;base64,") { t.Errorf("data uri = %q, want data:audio/wav;base64,...", audio) } params := body["parameters"].(map[string]any) if params["format"] != "wav" { t.Errorf("format = %v, want wav", params["format"]) } hints := params["language_hints"].([]any) if len(hints) != 1 || hints[0] != "zh" { t.Errorf("language_hints = %v, want [zh]", hints) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"output":{"output":{"sentence":{"text":"你好世界"}}}}`)) })) defer srv.Close() stt := NewDashscopeSTT(config.VoiceConfig{DashscopeBaseURL: srv.URL, DashscopeAPIKey: "sk"}) text, err := stt.STT(context.Background(), Audio{Format: "wav", Data: []byte("rawaudio")}) if err != nil { t.Fatalf("STT error: %v", err) } if text != "你好世界" { t.Errorf("text = %q, want 你好世界", text) } } func TestDashscopeSTT_ChoicesText(t *testing.T) { t.Parallel() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"output":{"choices":[{"message":{"content":[{"text":"hello"}]}}]}}`)) })) defer srv.Close() stt := NewDashscopeSTT(config.VoiceConfig{DashscopeBaseURL: srv.URL, DashscopeAPIKey: "sk"}) text, err := stt.STT(context.Background(), Audio{Format: "wav", Data: []byte("x")}) if err != nil { t.Fatalf("STT error: %v", err) } if text != "hello" { t.Errorf("text = %q, want hello", text) } } func TestDashscopeSTT_NotConfigured(t *testing.T) { t.Parallel() stt := NewDashscopeSTT(config.VoiceConfig{}) if _, err := stt.STT(context.Background(), Audio{Format: "wav", Data: []byte("x")}); err != ErrNotConfigured { t.Fatalf("err = %v, want ErrNotConfigured", err) } } func TestDashscopeSTT_EmptyAudio(t *testing.T) { t.Parallel() stt := NewDashscopeSTT(config.VoiceConfig{DashscopeBaseURL: "http://x", DashscopeAPIKey: "sk"}) if _, err := stt.STT(context.Background(), Audio{}); !errors.Is(err, ErrProviderFailed) { t.Fatalf("err = %v, want ErrProviderFailed", err) } } func TestDashscopeSTT_Non200(t *testing.T) { t.Parallel() srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) _, _ = w.Write([]byte(`{"code":"InvalidParameter","message":"bad"}`)) })) defer srv.Close() stt := NewDashscopeSTT(config.VoiceConfig{DashscopeBaseURL: srv.URL, DashscopeAPIKey: "sk"}) _, err := stt.STT(context.Background(), Audio{Format: "wav", Data: []byte("x")}) if !errors.Is(err, ErrProviderFailed) { t.Fatalf("err = %v, want ErrProviderFailed", err) } if !strings.Contains(err.Error(), "400") { t.Errorf("err = %v, want it to mention status 400", err) } } // TestDashscopeTTS_Non200DropsSpokenText 覆盖 M41 的 DashScope 一处:TTS 请求的 // input 就是**她要念的那段话**,而 DashScope 的 4xx 错误体会把它(或回显它的 // message)带回来。旧实现把整包响应体(cloudMaxResponseBytes = 8 MiB,无截断) // 拼进 error,error 又被语音链打日志。 // // 断言「文本里搜不到台词」,同时保留两类可诊断字段:分类哨兵链与 code / // request_id(DashScope 报障时确实要看这两个)。 func TestDashscopeTTS_Non200DropsSpokenText(t *testing.T) { t.Parallel() const spoken = "SENTINEL-SPOKEN-TEXT-1e4f" srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) _, _ = w.Write([]byte(`{"code":"InvalidParameter","message":"text: '` + spoken + `' is invalid","request_id":"req-0123456789abcdef"}`)) })) defer srv.Close() tts := NewDashscopeTTS(config.VoiceConfig{DashscopeBaseURL: srv.URL, DashscopeAPIKey: "sk"}) _, err := tts.TTS(context.Background(), spoken) if !errors.Is(err, ErrProviderFailed) { t.Fatalf("err = %v, want ErrProviderFailed", err) } if strings.Contains(err.Error(), spoken) { t.Errorf("error text echoes the text to be spoken: %v", err) } if !strings.Contains(err.Error(), "InvalidParameter") { t.Errorf("err = %v, want the error code kept for triage", err) } if !strings.Contains(err.Error(), "req-0123456789abcdef") { t.Errorf("err = %v, want the request id kept for provider support", err) } } func TestDashscopeTTS_URL(t *testing.T) { t.Parallel() audioBytes := []byte("RIFFxxxxWAVE") var srv *httptest.Server srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case strings.HasSuffix(r.URL.Path, "/SpeechSynthesizer"): if r.Header.Get("Authorization") != "Bearer sk" { t.Errorf("auth = %q, want Bearer sk", r.Header.Get("Authorization")) } if sse := r.Header.Get("X-DashScope-SSE"); sse != "" { t.Errorf("sse = %q, want empty (non-streaming URL)", sse) } var body map[string]any if err := json.NewDecoder(r.Body).Decode(&body); err != nil { t.Fatalf("decode: %v", err) } if body["model"] != "cosyvoice-v3-flash" { t.Errorf("model = %v, want cosyvoice-v3-flash", body["model"]) } input := body["input"].(map[string]any) if input["text"] != "你好" { t.Errorf("text = %v, want 你好", input["text"]) } if input["voice"] != "longxiaochun_v3" { t.Errorf("voice = %v, want longxiaochun_v3", input["voice"]) } if input["format"] != "wav" { t.Errorf("format = %v, want wav", input["format"]) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"output":{"audio":{"url":"` + srv.URL + `/audio/out.wav"}}}`)) case strings.HasSuffix(r.URL.Path, "/audio/out.wav"): w.Header().Set("Content-Type", "audio/wav") _, _ = w.Write(audioBytes) default: t.Errorf("unexpected path %q", r.URL.Path) } })) defer srv.Close() tts := NewDashscopeTTS(config.VoiceConfig{DashscopeBaseURL: srv.URL, DashscopeAPIKey: "sk"}) audio, err := tts.TTS(context.Background(), "你好") if err != nil { t.Fatalf("TTS error: %v", err) } if audio.Format != "wav" { t.Errorf("format = %q, want wav", audio.Format) } if hex.EncodeToString(audio.Data) != hex.EncodeToString(audioBytes) { t.Errorf("data = %s, want %s", hex.EncodeToString(audio.Data), hex.EncodeToString(audioBytes)) } } func TestDashscopeTTS_DataBlob(t *testing.T) { t.Parallel() audioBytes := []byte{9, 8, 7} b64 := base64.StdEncoding.EncodeToString(audioBytes) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"output":{"audio":{"data":"` + b64 + `"}}}`)) })) defer srv.Close() tts := NewDashscopeTTS(config.VoiceConfig{DashscopeBaseURL: srv.URL, DashscopeAPIKey: "sk"}) audio, err := tts.TTS(context.Background(), "hi") if err != nil { t.Fatalf("TTS error: %v", err) } if hex.EncodeToString(audio.Data) != hex.EncodeToString(audioBytes) { t.Errorf("data = %s, want %s", hex.EncodeToString(audio.Data), hex.EncodeToString(audioBytes)) } } func TestDashscopeTTS_EmptyText(t *testing.T) { t.Parallel() tts := NewDashscopeTTS(config.VoiceConfig{DashscopeBaseURL: "http://x", DashscopeAPIKey: "sk"}) if _, err := tts.TTS(context.Background(), " "); err != ErrEmptyText { t.Fatalf("err = %v, want ErrEmptyText", err) } } func TestDashscopeTTS_NotConfigured(t *testing.T) { t.Parallel() tts := NewDashscopeTTS(config.VoiceConfig{}) if _, err := tts.TTS(context.Background(), "hi"); err != ErrNotConfigured { t.Fatalf("err = %v, want ErrNotConfigured", err) } }