Files
Cyrene_v2/backend/services/voice/service_test.go
T
Aska a0357a7bbd feat(voice): real TTS fallback chain and whisper-compatible STT
- TTS providers: cloud (OpenAI-compatible /audio/speech, e.g. Bailian
  compatible mode with cosyvoice), local self-hosted HTTP server,
  edge-tts CLI, espeak CLI, silent — ordered by TTS_PROVIDER list,
  skipping providers whose binary/config is missing
- STT: whisper-compatible /audio/transcriptions endpoint (cloud or
  self-hosted faster-whisper), replacing the empty dashscope stub
- config: TTS_CLOUD_URL/KEY/MODEL/VOICE, TTS_LOCAL_URL, STT_API_URL/
  KEY/MODEL; cloud keys fall back to DASHSCOPE_API_KEY
- gateway /voice/tts now returns real audio/mpeg bytes instead of a
  byte count
- tests: cloud/local/whisper round-trips via httptest, chain skip
  behavior, fallback order; kept and adapted prior service tests
2026-08-21 18:28:39 +08:00

164 lines
4.5 KiB
Go

package voice
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestNewServiceDefaultConfig(t *testing.T) {
svc := New(Config{TTSProviders: []string{"silent"}})
if svc.sttProvider == nil {
t.Fatal("expected STT provider")
}
if svc.sttProvider.Name() != "silent" {
t.Errorf("expected silent STT, got %s", svc.sttProvider.Name())
}
}
func TestNewServiceEmptyConfig(t *testing.T) {
svc := New(Config{})
if svc.sttProvider == nil {
t.Fatal("expected fallback STT provider")
}
if len(svc.ttsProviders) == 0 {
t.Fatal("expected at least one fallback TTS provider")
}
if got := svc.TTSProviderNames(); len(got) != 1 || got[0] != "silent" {
t.Errorf("expected [silent] chain, got %v", got)
}
}
func TestServiceRecognize(t *testing.T) {
svc := New(Config{})
result, err := svc.Recognize(context.Background(), []byte("test audio"))
if err != nil {
t.Errorf("Recognize should not error: %v", err)
}
if result != "" {
t.Logf("Recognize result: %s", result)
}
}
func TestServiceSynthesize(t *testing.T) {
svc := New(Config{TTSProviders: []string{"silent"}})
audio, err := svc.Synthesize(context.Background(), "你好世界", "zh-CN-XiaoxiaoNeural")
if err != nil {
t.Fatalf("Synthesize should not error: %v", err)
}
if audio == nil {
t.Error("expected non-nil audio")
}
}
func TestTTSProviderChainSkipsUnavailable(t *testing.T) {
// edge-tts/espeak binaries are usually absent in CI — they must be
// skipped, leaving at least the silent provider.
svc := New(Config{TTSProviders: []string{"edge-tts", "espeak", "silent"}})
names := svc.TTSProviderNames()
if names[len(names)-1] != "silent" {
t.Errorf("chain must end with silent, got %v", names)
}
for _, n := range names {
if n == "edge-tts" && newEdgeTTS().bin == "" {
t.Error("edge-tts in chain but binary not found")
}
}
}
func TestProviderNames(t *testing.T) {
cases := map[string]TTSProvider{
"cloud": &cloudTTS{},
"local": &localTTS{},
"edge-tts": &edgeTTS{},
"espeak": &espeakTTS{},
"silent": &silentTTS{},
}
for want, p := range cases {
if p.Name() != want {
t.Errorf("provider name = %s, want %s", p.Name(), want)
}
}
if n := (STTProvider)(&whisperSTT{}).Name(); n != "whisper-api" {
t.Errorf("stt name = %s", n)
}
if n := (&silentSTT{}).Name(); n != "silent" {
t.Errorf("stt name = %s", n)
}
}
func TestCloudTTS(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/audio/speech" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
body, _ := io.ReadAll(r.Body)
if !strings.Contains(string(body), "cosyvoice-v1") || !strings.Contains(string(body), "你好") {
t.Errorf("unexpected body: %s", body)
}
w.Write([]byte("fake-mp3-bytes"))
}))
defer srv.Close()
p := &cloudTTS{baseURL: srv.URL, apiKey: "k", model: "cosyvoice-v1", defaultVoice: "longxiaochun", client: srv.Client()}
audio, err := p.Synthesize(context.Background(), "你好", "")
if err != nil {
t.Fatalf("Synthesize: %v", err)
}
if string(audio) != "fake-mp3-bytes" {
t.Errorf("audio = %q", audio)
}
}
func TestLocalTTS(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("local-audio"))
}))
defer srv.Close()
p := &localTTS{url: srv.URL, client: srv.Client()}
audio, err := p.Synthesize(context.Background(), "文本", "")
if err != nil || string(audio) != "local-audio" {
t.Errorf("audio=%q err=%v", audio, err)
}
}
func TestWhisperSTT(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/audio/transcriptions" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
if err := r.ParseMultipartForm(1 << 20); err != nil {
t.Errorf("parse multipart: %v", err)
}
w.Write([]byte(`{"text":"识别结果"}`))
}))
defer srv.Close()
p := &whisperSTT{baseURL: srv.URL, apiKey: "k", model: "whisper-large-v3", client: srv.Client()}
text, err := p.Recognize(context.Background(), []byte("RIFF....fake-wav"))
if err != nil || text != "识别结果" {
t.Errorf("text=%q err=%v", text, err)
}
}
func TestFallbackChain(t *testing.T) {
// First provider unreachable, second (silent) succeeds
svc := &Service{ttsProviders: []TTSProvider{
&localTTS{url: "http://127.0.0.1:1", client: &http.Client{}},
&silentTTS{},
}}
audio, err := svc.Synthesize(context.Background(), "text", "")
if err != nil || len(audio) != 0 {
t.Errorf("expected silent fallback empty audio, got %d bytes, err=%v", len(audio), err)
}
}