Files
Cyrene_v2/internal/runtime/multimodal/voice/dashscope_test.go
T
Aska 73dabb9dde
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(voice): 阿里百炼 STT/TTS 主链路接通(base64 STT + cosyvoice TTS按voice_id + REST /audio/* + voice_input.deviceId)
- voice/dashscope.go 新建:DashscopeSTT(qwen-audio-3.0-asr-flash, base64 data URI→text) + DashscopeTTS(cosyvoice 按 voice_id, 取非流式 24h URL 下载字节)
- cloud.go newDashboardSTT/TTS 由空 endpoint stub 改为真实百炼 adapter
- chain.go gating 从 DashscopeAPIKey!="" 改为 voiceConfigured(cfg); silent 兜底保留
- config.VoiceConfig 扩展 DASHSCOPE_BASE_URL/VOICE_STT_*/VOICE_TTS_* 字段(必填 DASHSCOPE_API_KEY)
- gateway: POST /api/v1/audio/{transcribe,speech} (owner+审计+MaxBytesReader); voiceInputRequest 增 deviceId 并透传 runner.Run
- 实测: cosyvoice-v3.5-plus+longxiaochun 回 418(引擎拒绝), 改用 cosyvoice-v3-flash+longxiaochun_v3; TTS 你好我是昔涟->106124B wav -> STT 回 你好我是西联, 链返回真实字节/文本
- 单测: dashscope_test/config voice defaults/gateway audio_test; go build/vet/test 全绿
- 声音复刻(响昔涟音色)待公网参考音频; 不做 create_voice
2026-09-08 06:06:02 +08:00

207 lines
7.1 KiB
Go

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)
}
}
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)
}
}