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:
Aska committed 2026-08-21 18:28:39 +08:00
1 parent b8fb7feb86
commit a0357a7bbd
7 files changed
+410 -63

No files matched your search

+28 -4
View File
@@ -10,6 +10,7 @@ import (
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"strings"
"syscall" "syscall"
"time" "time"
@@ -124,12 +125,24 @@ func main() {
logger.Infof("[plugin] loaded %d plugins: %v", reg.Count(), reg.ListNames()) logger.Infof("[plugin] loaded %d plugins: %v", reg.Count(), reg.ListNames())
// === 5. Voice Service === // === 5. Voice Service ===
orEmpty := func(v, fallback string) string {
if v != "" {
return v
}
return fallback
}
voiceSvc := voicesvc.New(voicesvc.Config{ voiceSvc := voicesvc.New(voicesvc.Config{
STTProvider: cfg.ASRProvider, STTApiURL: cfg.STTApiURL,
TTSProviders: []string{cfg.TTSProvider}, STTApiKey: orEmpty(cfg.STTApiKey, cfg.DashScopeAPIKey),
DashScopeKey: cfg.DashScopeAPIKey, STTModel: cfg.STTModel,
TTSProviders: splitComma(cfg.TTSProvider),
TTSCloudURL: cfg.TTSCloudURL,
TTSCloudKey: orEmpty(cfg.TTSCloudKey, cfg.DashScopeAPIKey),
TTSCloudModel: cfg.TTSCloudModel,
TTSCloudVoice: cfg.TTSCloudVoice,
TTSLocalURL: cfg.TTSLocalURL,
}) })
logger.Infof("[voice] ready") logger.Infof("[voice] ready (tts chain: %v)", voiceSvc.TTSProviderNames())
// === 6. Memory Service === // === 6. Memory Service ===
var memSvc *memory.Service var memSvc *memory.Service
@@ -407,6 +420,17 @@ type llmLoggerAdapter struct{ *llmlog.CallLogger }
func (a *llmLoggerAdapter) Recent(n int) []map[string]any { return a.CallLogger.RecentMaps(n) } func (a *llmLoggerAdapter) Recent(n int) []map[string]any { return a.CallLogger.RecentMaps(n) }
// splitComma splits a comma-separated config value into trimmed non-empty parts.
func splitComma(s string) []string {
var out []string
for _, p := range strings.Split(s, ",") {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
func pluginNamesToMap(names []string) []map[string]string { func pluginNamesToMap(names []string) []map[string]string {
result := make([]map[string]string, len(names)) result := make([]map[string]string, len(names))
for i, n := range names { for i, n := range names {
+14 -3
View File
@@ -53,8 +53,16 @@ type Config struct {
// Voice // Voice
DashScopeAPIKey string DashScopeAPIKey string
TTSProvider string // "edge-tts" | "espeak" TTSProvider string // comma-separated fallback chain: "cloud,local,edge-tts,espeak,silent"
ASRProvider string // "dashscope" | "whisper-local" TTSCloudURL string // OpenAI-compatible base URL for cloud TTS (e.g. Bailian compatible mode)
TTSCloudKey string // falls back to DashScopeAPIKey
TTSCloudModel string
TTSCloudVoice string
TTSLocalURL string // self-hosted TTS POST endpoint
ASRProvider string // legacy label
STTApiURL string // Whisper-compatible base URL (cloud or self-hosted)
STTApiKey string // falls back to DashScopeAPIKey
STTModel string
// Auth // Auth
JWTSecret string JWTSecret string
@@ -139,7 +147,10 @@ func DefaultConfig() *Config {
EmbeddingModel: "text-embedding-3-small", EmbeddingModel: "text-embedding-3-small",
TTSProvider: "edge-tts", TTSProvider: "cloud,local,edge-tts,silent",
TTSCloudURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
TTSCloudModel: "cosyvoice-v1",
TTSCloudVoice: "longxiaochun",
ASRProvider: "dashscope", ASRProvider: "dashscope",
JWTExpiryHours: 720, JWTExpiryHours: 720,
+8
View File
@@ -93,7 +93,15 @@ func (c *Config) mergeEnv() {
// Voice // Voice
c.DashScopeAPIKey = getEnv("DASHSCOPE_API_KEY", c.DashScopeAPIKey) c.DashScopeAPIKey = getEnv("DASHSCOPE_API_KEY", c.DashScopeAPIKey)
c.TTSProvider = getEnv("TTS_PROVIDER", c.TTSProvider) c.TTSProvider = getEnv("TTS_PROVIDER", c.TTSProvider)
c.TTSCloudURL = getEnv("TTS_CLOUD_URL", c.TTSCloudURL)
c.TTSCloudKey = getEnv("TTS_CLOUD_KEY", c.TTSCloudKey)
c.TTSCloudModel = getEnv("TTS_CLOUD_MODEL", c.TTSCloudModel)
c.TTSCloudVoice = getEnv("TTS_CLOUD_VOICE", c.TTSCloudVoice)
c.TTSLocalURL = getEnv("TTS_LOCAL_URL", c.TTSLocalURL)
c.ASRProvider = getEnv("ASR_PROVIDER", c.ASRProvider) c.ASRProvider = getEnv("ASR_PROVIDER", c.ASRProvider)
c.STTApiURL = getEnv("STT_API_URL", c.STTApiURL)
c.STTApiKey = getEnv("STT_API_KEY", c.STTApiKey)
c.STTModel = getEnv("STT_MODEL", c.STTModel)
// Auth // Auth
c.JWTSecret = getEnv("JWT_SECRET", c.JWTSecret) c.JWTSecret = getEnv("JWT_SECRET", c.JWTSecret)
+7 -1
View File
@@ -476,7 +476,13 @@ func (r *Router) voiceTTSHandler(w http.ResponseWriter, req *http.Request) {
writeJSON(w, 500, M{"error": err.Error()}) writeJSON(w, 500, M{"error": err.Error()})
return return
} }
writeJSON(w, 200, M{"audio_size": len(audio)}) if len(audio) == 0 {
writeJSON(w, 503, M{"error": "no tts provider available"})
return
}
w.Header().Set("Content-Type", "audio/mpeg")
w.WriteHeader(200)
w.Write(audio)
} }
// --- Admin --- // --- Admin ---
+192
View File
@@ -0,0 +1,192 @@
package voice
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// --- Cloud TTS (OpenAI-compatible /audio/speech, e.g. Alibaba Bailian compatible mode) ---
type cloudTTS struct {
baseURL, apiKey, model, defaultVoice string
client *http.Client
}
func (p *cloudTTS) Name() string { return "cloud" }
func (p *cloudTTS) Synthesize(ctx context.Context, text string, voice string) ([]byte, error) {
if voice == "" {
voice = p.defaultVoice
}
body, _ := json.Marshal(map[string]any{
"model": p.model,
"voice": voice,
"input": text,
})
req, err := http.NewRequestWithContext(ctx, "POST", p.baseURL+"/audio/speech", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+p.apiKey)
req.Header.Set("Content-Type", "application/json")
resp, err := p.client.Do(req)
if err != nil {
return nil, fmt.Errorf("cloud tts: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return nil, fmt.Errorf("cloud tts %d: %s", resp.StatusCode, msg)
}
return io.ReadAll(resp.Body)
}
// --- Local TTS (self-hosted HTTP TTS server, e.g. GPT-SoVITS / edge-tts-server / OpenTTS) ---
type localTTS struct {
url string // POST endpoint accepting {"text","voice"} and returning audio bytes
client *http.Client
}
func (p *localTTS) Name() string { return "local" }
func (p *localTTS) Synthesize(ctx context.Context, text string, voice string) ([]byte, error) {
body, _ := json.Marshal(map[string]any{"text": text, "voice": voice})
req, err := http.NewRequestWithContext(ctx, "POST", p.url, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := p.client.Do(req)
if err != nil {
return nil, fmt.Errorf("local tts: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("local tts %d", resp.StatusCode)
}
return io.ReadAll(resp.Body)
}
// --- edge-tts CLI ---
type edgeTTS struct{ bin string }
// newEdgeTTS locates the edge-tts binary: EDGETTS_BIN env, PATH, then ./.venv.
func newEdgeTTS() *edgeTTS {
if bin := os.Getenv("EDGETTS_BIN"); bin != "" {
return &edgeTTS{bin: bin}
}
if bin, err := exec.LookPath("edge-tts"); err == nil {
return &edgeTTS{bin: bin}
}
if _, err := os.Stat(".venv/bin/edge-tts"); err == nil {
abs, _ := filepath.Abs(".venv/bin/edge-tts")
return &edgeTTS{bin: abs}
}
return &edgeTTS{}
}
func (p *edgeTTS) Name() string { return "edge-tts" }
func (p *edgeTTS) available() bool { return p.bin != "" }
func (p *edgeTTS) Synthesize(ctx context.Context, text string, voice string) ([]byte, error) {
if !p.available() {
return nil, fmt.Errorf("edge-tts binary not found")
}
if voice == "" {
voice = "zh-CN-XiaoxiaoNeural"
}
out := filepath.Join(os.TempDir(), fmt.Sprintf("cyrene-tts-%d.mp3", time.Now().UnixNano()))
defer os.Remove(out)
cmd := exec.CommandContext(ctx, p.bin, "--voice", voice, "--text", text, "--write-media", out)
if combined, err := cmd.CombinedOutput(); err != nil {
return nil, fmt.Errorf("edge-tts: %w: %s", err, strings.TrimSpace(string(combined)))
}
return os.ReadFile(out)
}
// --- espeak CLI ---
type espeakTTS struct{ bin string }
func newEspeakTTS() *espeakTTS {
for _, name := range []string{"espeak-ng", "espeak"} {
if bin, err := exec.LookPath(name); err == nil {
return &espeakTTS{bin: bin}
}
}
return &espeakTTS{}
}
func (p *espeakTTS) Name() string { return "espeak" }
func (p *espeakTTS) Synthesize(ctx context.Context, text string, _ string) ([]byte, error) {
if p.bin == "" {
return nil, fmt.Errorf("espeak binary not found")
}
cmd := exec.CommandContext(ctx, p.bin, "-v", "zh", "--stdout", text)
return cmd.Output()
}
// --- Whisper-compatible STT (OpenAI /audio/transcriptions; local servers like faster-whisper too) ---
type whisperSTT struct {
baseURL, apiKey, model string
client *http.Client
}
func (p *whisperSTT) Name() string { return "whisper-api" }
func (p *whisperSTT) Recognize(ctx context.Context, audioData []byte) (string, error) {
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
fw, err := w.CreateFormFile("file", "audio.wav")
if err != nil {
return "", err
}
if _, err := fw.Write(audioData); err != nil {
return "", err
}
w.WriteField("model", p.model)
w.Close()
req, err := http.NewRequestWithContext(ctx, "POST", p.baseURL+"/audio/transcriptions", &buf)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+p.apiKey)
req.Header.Set("Content-Type", w.FormDataContentType())
resp, err := p.client.Do(req)
if err != nil {
return "", fmt.Errorf("stt: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return "", fmt.Errorf("stt %d: %s", resp.StatusCode, msg)
}
var result struct {
Text string `json:"text"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("parse stt: %w", err)
}
return result.Text, nil
}
+64 -31
View File
@@ -1,13 +1,20 @@
// Package voice provides speech-to-text and text-to-speech capabilities. // Package voice provides speech-to-text and text-to-speech capabilities.
// It defines provider interfaces allowing pluggable backends (DashScope, Whisper, Edge-TTS, espeak). // TTS providers form a fallback chain: cloud (Bailian/OpenAI-compatible),
// local self-hosted HTTP server, edge-tts CLI, espeak CLI, silent.
// STT uses a Whisper-compatible /audio/transcriptions endpoint
// (cloud or self-hosted, e.g. faster-whisper).
package voice package voice
import "context" import (
"context"
"net/http"
"time"
)
// STTProvider converts speech audio to text. // STTProvider converts speech audio to text.
type STTProvider interface { type STTProvider interface {
// Name returns the provider identifier. // Name returns the provider identifier.
Name() string // "dashscope", "whisper-local" Name() string // "whisper-api", "silent"
// Recognize converts audio data to text. // Recognize converts audio data to text.
Recognize(ctx context.Context, audioData []byte) (string, error) Recognize(ctx context.Context, audioData []byte) (string, error)
@@ -16,7 +23,7 @@ type STTProvider interface {
// TTSProvider converts text to speech audio. // TTSProvider converts text to speech audio.
type TTSProvider interface { type TTSProvider interface {
// Name returns the provider identifier. // Name returns the provider identifier.
Name() string // "edge-tts", "espeak", "silent" Name() string // "cloud", "local", "edge-tts", "espeak", "silent"
// Synthesize converts text to audio bytes. // Synthesize converts text to audio bytes.
Synthesize(ctx context.Context, text string, voice string) ([]byte, error) Synthesize(ctx context.Context, text string, voice string) ([]byte, error)
@@ -30,18 +37,35 @@ type Service struct {
// Config holds voice service configuration. // Config holds voice service configuration.
type Config struct { type Config struct {
STTProvider string // "dashscope" | "whisper-local" // STT (Whisper-compatible endpoint; works with Bailian compatible mode
TTSProviders []string // ["edge-tts", "espeak", "silent"] ordered by priority // or a self-hosted faster-whisper server)
DashScopeKey string STTApiURL string // e.g. "https://dashscope.aliyuncs.com/compatible-mode/v1"
STTApiKey string
STTModel string // e.g. "whisper-large-v3"
// TTS fallback chain, e.g. ["cloud", "local", "edge-tts", "silent"]
TTSProviders []string
TTSCloudURL string // OpenAI-compatible base URL for cloud TTS
TTSCloudKey string
TTSCloudModel string // e.g. "cosyvoice-v1" (Bailian) / "tts-1" (OpenAI)
TTSCloudVoice string // e.g. "longxiaochun"
TTSLocalURL string // self-hosted TTS POST endpoint
} }
// New creates a new voice service. // New creates a new voice service.
func New(cfg Config) *Service { func New(cfg Config) *Service {
svc := &Service{} svc := &Service{}
// STT: for now, use a simple placeholder // STT
if cfg.STTProvider == "dashscope" && cfg.DashScopeKey != "" { if cfg.STTApiURL != "" && cfg.STTApiKey != "" {
svc.sttProvider = &dashscopeSTT{apiKey: cfg.DashScopeKey} model := cfg.STTModel
if model == "" {
model = "whisper-large-v3"
}
svc.sttProvider = &whisperSTT{
baseURL: cfg.STTApiURL, apiKey: cfg.STTApiKey, model: model,
client: &http.Client{Timeout: 60 * time.Second},
}
} else { } else {
svc.sttProvider = &silentSTT{} svc.sttProvider = &silentSTT{}
} }
@@ -49,10 +73,28 @@ func New(cfg Config) *Service {
// TTS: build fallback chain // TTS: build fallback chain
for _, name := range cfg.TTSProviders { for _, name := range cfg.TTSProviders {
switch name { switch name {
case "cloud":
if cfg.TTSCloudURL != "" && cfg.TTSCloudKey != "" {
svc.ttsProviders = append(svc.ttsProviders, &cloudTTS{
baseURL: cfg.TTSCloudURL, apiKey: cfg.TTSCloudKey,
model: cfg.TTSCloudModel, defaultVoice: cfg.TTSCloudVoice,
client: &http.Client{Timeout: 60 * time.Second},
})
}
case "local":
if cfg.TTSLocalURL != "" {
svc.ttsProviders = append(svc.ttsProviders, &localTTS{
url: cfg.TTSLocalURL, client: &http.Client{Timeout: 60 * time.Second},
})
}
case "edge-tts": case "edge-tts":
svc.ttsProviders = append(svc.ttsProviders, &edgeTTS{}) if e := newEdgeTTS(); e.available() {
svc.ttsProviders = append(svc.ttsProviders, e)
}
case "espeak": case "espeak":
svc.ttsProviders = append(svc.ttsProviders, &espeakTTS{}) if e := newEspeakTTS(); e.bin != "" {
svc.ttsProviders = append(svc.ttsProviders, e)
}
case "silent": case "silent":
svc.ttsProviders = append(svc.ttsProviders, &silentTTS{}) svc.ttsProviders = append(svc.ttsProviders, &silentTTS{})
} }
@@ -64,6 +106,15 @@ func New(cfg Config) *Service {
return svc return svc
} }
// TTSProviderNames returns the active TTS fallback chain.
func (s *Service) TTSProviderNames() []string {
names := make([]string, len(s.ttsProviders))
for i, p := range s.ttsProviders {
names[i] = p.Name()
}
return names
}
// Recognize performs speech recognition with the configured STT provider. // Recognize performs speech recognition with the configured STT provider.
func (s *Service) Recognize(ctx context.Context, audioData []byte) (string, error) { func (s *Service) Recognize(ctx context.Context, audioData []byte) (string, error) {
return s.sttProvider.Recognize(ctx, audioData) return s.sttProvider.Recognize(ctx, audioData)
@@ -82,13 +133,7 @@ func (s *Service) Synthesize(ctx context.Context, text string, voice string) ([]
return nil, lastErr return nil, lastErr
} }
// --- Provider stubs --- // --- Silent fallbacks ---
type dashscopeSTT struct{ apiKey string }
func (p *dashscopeSTT) Name() string { return "dashscope" }
func (p *dashscopeSTT) Recognize(_ context.Context, _ []byte) (string, error) {
return "", nil // Placeholder: real implementation requires DashScope SDK
}
type silentSTT struct{} type silentSTT struct{}
func (p *silentSTT) Name() string { return "silent" } func (p *silentSTT) Name() string { return "silent" }
@@ -96,18 +141,6 @@ func (p *silentSTT) Recognize(_ context.Context, _ []byte) (string, error) {
return "", nil return "", nil
} }
type edgeTTS struct{}
func (p *edgeTTS) Name() string { return "edge-tts" }
func (p *edgeTTS) Synthesize(_ context.Context, text string, _ string) ([]byte, error) {
return []byte(text), nil // Placeholder
}
type espeakTTS struct{}
func (p *espeakTTS) Name() string { return "espeak" }
func (p *espeakTTS) Synthesize(_ context.Context, text string, _ string) ([]byte, error) {
return []byte(text), nil
}
type silentTTS struct{} type silentTTS struct{}
func (p *silentTTS) Name() string { return "silent" } func (p *silentTTS) Name() string { return "silent" }
func (p *silentTTS) Synthesize(_ context.Context, _ string, _ string) ([]byte, error) { func (p *silentTTS) Synthesize(_ context.Context, _ string, _ string) ([]byte, error) {
+97 -24
View File
@@ -2,14 +2,15 @@ package voice
import ( import (
"context" "context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing" "testing"
) )
func TestNewServiceDefaultConfig(t *testing.T) { func TestNewServiceDefaultConfig(t *testing.T) {
svc := New(Config{ svc := New(Config{TTSProviders: []string{"silent"}})
STTProvider: "silent",
TTSProviders: []string{"silent"},
})
if svc.sttProvider == nil { if svc.sttProvider == nil {
t.Fatal("expected STT provider") t.Fatal("expected STT provider")
@@ -28,6 +29,9 @@ func TestNewServiceEmptyConfig(t *testing.T) {
if len(svc.ttsProviders) == 0 { if len(svc.ttsProviders) == 0 {
t.Fatal("expected at least one fallback TTS provider") 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) { func TestServiceRecognize(t *testing.T) {
@@ -54,37 +58,106 @@ func TestServiceSynthesize(t *testing.T) {
} }
} }
func TestTTSProviderChain(t *testing.T) { func TestTTSProviderChainSkipsUnavailable(t *testing.T) {
svc := New(Config{ // edge-tts/espeak binaries are usually absent in CI — they must be
TTSProviders: []string{"edge-tts", "espeak", "silent"}, // skipped, leaving at least the silent provider.
}) svc := New(Config{TTSProviders: []string{"edge-tts", "espeak", "silent"}})
if len(svc.ttsProviders) != 3 { names := svc.TTSProviderNames()
t.Errorf("expected 3 TTS providers, got %d", len(svc.ttsProviders)) if names[len(names)-1] != "silent" {
t.Errorf("chain must end with silent, got %v", names)
} }
for _, n := range names {
names := []string{"edge-tts", "espeak", "silent"} if n == "edge-tts" && newEdgeTTS().bin == "" {
for i, p := range svc.ttsProviders { t.Error("edge-tts in chain but binary not found")
if p.Name() != names[i] {
t.Errorf("provider[%d]: expected %s, got %s", i, names[i], p.Name())
} }
} }
} }
func TestProviderNames(t *testing.T) { func TestProviderNames(t *testing.T) {
if n := (&dashscopeSTT{}).Name(); n != "dashscope" { cases := map[string]TTSProvider{
t.Errorf("got %s", n) "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" { 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" { if string(audio) != "fake-mp3-bytes" {
t.Errorf("got %s", n) 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)
} }
} }