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
This commit is contained in:
1 parent
b8fb7feb86
commit
a0357a7bbd
7 files changed
+410
-63
No files matched your search
@@ -2,14 +2,15 @@ package voice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewServiceDefaultConfig(t *testing.T) {
|
||||
svc := New(Config{
|
||||
STTProvider: "silent",
|
||||
TTSProviders: []string{"silent"},
|
||||
})
|
||||
svc := New(Config{TTSProviders: []string{"silent"}})
|
||||
|
||||
if svc.sttProvider == nil {
|
||||
t.Fatal("expected STT provider")
|
||||
@@ -28,6 +29,9 @@ func TestNewServiceEmptyConfig(t *testing.T) {
|
||||
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) {
|
||||
@@ -54,37 +58,106 @@ func TestServiceSynthesize(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTTSProviderChain(t *testing.T) {
|
||||
svc := New(Config{
|
||||
TTSProviders: []string{"edge-tts", "espeak", "silent"},
|
||||
})
|
||||
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"}})
|
||||
|
||||
if len(svc.ttsProviders) != 3 {
|
||||
t.Errorf("expected 3 TTS providers, got %d", len(svc.ttsProviders))
|
||||
names := svc.TTSProviderNames()
|
||||
if names[len(names)-1] != "silent" {
|
||||
t.Errorf("chain must end with silent, got %v", names)
|
||||
}
|
||||
|
||||
names := []string{"edge-tts", "espeak", "silent"}
|
||||
for i, p := range svc.ttsProviders {
|
||||
if p.Name() != names[i] {
|
||||
t.Errorf("provider[%d]: expected %s, got %s", i, names[i], p.Name())
|
||||
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) {
|
||||
if n := (&dashscopeSTT{}).Name(); n != "dashscope" {
|
||||
t.Errorf("got %s", n)
|
||||
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("got %s", n)
|
||||
t.Errorf("stt name = %s", n)
|
||||
}
|
||||
if n := (&edgeTTS{}).Name(); n != "edge-tts" {
|
||||
t.Errorf("got %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 n := (&espeakTTS{}).Name(); n != "espeak" {
|
||||
t.Errorf("got %s", n)
|
||||
if string(audio) != "fake-mp3-bytes" {
|
||||
t.Errorf("audio = %q", audio)
|
||||
}
|
||||
if n := (&silentTTS{}).Name(); n != "silent" {
|
||||
t.Errorf("got %s", n)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user