- 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
164 lines
4.5 KiB
Go
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)
|
|
}
|
|
}
|