- 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
149 lines
4.4 KiB
Go
149 lines
4.4 KiB
Go
// Package voice provides speech-to-text and text-to-speech capabilities.
|
|
// 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"
|
|
"net/http"
|
|
"time"
|
|
)
|
|
|
|
// STTProvider converts speech audio to text.
|
|
type STTProvider interface {
|
|
// Name returns the provider identifier.
|
|
Name() string // "whisper-api", "silent"
|
|
|
|
// Recognize converts audio data to text.
|
|
Recognize(ctx context.Context, audioData []byte) (string, error)
|
|
}
|
|
|
|
// TTSProvider converts text to speech audio.
|
|
type TTSProvider interface {
|
|
// Name returns the provider identifier.
|
|
Name() string // "cloud", "local", "edge-tts", "espeak", "silent"
|
|
|
|
// Synthesize converts text to audio bytes.
|
|
Synthesize(ctx context.Context, text string, voice string) ([]byte, error)
|
|
}
|
|
|
|
// Service manages STT and TTS providers with fallback chains.
|
|
type Service struct {
|
|
sttProvider STTProvider
|
|
ttsProviders []TTSProvider // Ordered by priority (first = preferred)
|
|
}
|
|
|
|
// Config holds voice service configuration.
|
|
type Config struct {
|
|
// 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
|
|
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{}
|
|
}
|
|
|
|
// 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":
|
|
if e := newEdgeTTS(); e.available() {
|
|
svc.ttsProviders = append(svc.ttsProviders, e)
|
|
}
|
|
case "espeak":
|
|
if e := newEspeakTTS(); e.bin != "" {
|
|
svc.ttsProviders = append(svc.ttsProviders, e)
|
|
}
|
|
case "silent":
|
|
svc.ttsProviders = append(svc.ttsProviders, &silentTTS{})
|
|
}
|
|
}
|
|
if len(svc.ttsProviders) == 0 {
|
|
svc.ttsProviders = append(svc.ttsProviders, &silentTTS{})
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// Synthesize performs TTS with automatic fallback through the provider chain.
|
|
func (s *Service) Synthesize(ctx context.Context, text string, voice string) ([]byte, error) {
|
|
var lastErr error
|
|
for _, p := range s.ttsProviders {
|
|
audio, err := p.Synthesize(ctx, text, voice)
|
|
if err == nil {
|
|
return audio, nil
|
|
}
|
|
lastErr = err
|
|
}
|
|
return nil, lastErr
|
|
}
|
|
|
|
// --- Silent fallbacks ---
|
|
|
|
type silentSTT struct{}
|
|
func (p *silentSTT) Name() string { return "silent" }
|
|
func (p *silentSTT) Recognize(_ context.Context, _ []byte) (string, error) {
|
|
return "", nil
|
|
}
|
|
|
|
type silentTTS struct{}
|
|
func (p *silentTTS) Name() string { return "silent" }
|
|
func (p *silentTTS) Synthesize(_ context.Context, _ string, _ string) ([]byte, error) {
|
|
return make([]byte, 0), nil
|
|
}
|