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
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user