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

+64 -31
View File
@@ -1,13 +1,20 @@
// 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
import "context"
import (
"context"
"net/http"
"time"
)
// STTProvider converts speech audio to text.
type STTProvider interface {
// Name returns the provider identifier.
Name() string // "dashscope", "whisper-local"
Name() string // "whisper-api", "silent"
// Recognize converts audio data to text.
Recognize(ctx context.Context, audioData []byte) (string, error)
@@ -16,7 +23,7 @@ type STTProvider interface {
// TTSProvider converts text to speech audio.
type TTSProvider interface {
// 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(ctx context.Context, text string, voice string) ([]byte, error)
@@ -30,18 +37,35 @@ type Service struct {
// Config holds voice service configuration.
type Config struct {
STTProvider string // "dashscope" | "whisper-local"
TTSProviders []string // ["edge-tts", "espeak", "silent"] ordered by priority
DashScopeKey string
// STT (Whisper-compatible endpoint; works with Bailian compatible mode
// or a self-hosted faster-whisper server)
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.
func New(cfg Config) *Service {
svc := &Service{}
// STT: for now, use a simple placeholder
if cfg.STTProvider == "dashscope" && cfg.DashScopeKey != "" {
svc.sttProvider = &dashscopeSTT{apiKey: cfg.DashScopeKey}
// STT
if cfg.STTApiURL != "" && cfg.STTApiKey != "" {
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 {
svc.sttProvider = &silentSTT{}
}
@@ -49,10 +73,28 @@ func New(cfg Config) *Service {
// TTS: build fallback chain
for _, name := range cfg.TTSProviders {
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":
svc.ttsProviders = append(svc.ttsProviders, &edgeTTS{})
if e := newEdgeTTS(); e.available() {
svc.ttsProviders = append(svc.ttsProviders, e)
}
case "espeak":
svc.ttsProviders = append(svc.ttsProviders, &espeakTTS{})
if e := newEspeakTTS(); e.bin != "" {
svc.ttsProviders = append(svc.ttsProviders, e)
}
case "silent":
svc.ttsProviders = append(svc.ttsProviders, &silentTTS{})
}
@@ -64,6 +106,15 @@ func New(cfg Config) *Service {
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.
func (s *Service) Recognize(ctx context.Context, audioData []byte) (string, error) {
return s.sttProvider.Recognize(ctx, audioData)
@@ -82,13 +133,7 @@ func (s *Service) Synthesize(ctx context.Context, text string, voice string) ([]
return nil, lastErr
}
// --- Provider stubs ---
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
}
// --- Silent fallbacks ---
type silentSTT struct{}
func (p *silentSTT) Name() string { return "silent" }
@@ -96,18 +141,6 @@ func (p *silentSTT) Recognize(_ context.Context, _ []byte) (string, error) {
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{}
func (p *silentTTS) Name() string { return "silent" }
func (p *silentTTS) Synthesize(_ context.Context, _ string, _ string) ([]byte, error) {