Files
Cyrene_v2/backend/services/voice/service.go
T
Aska a0357a7bbd 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
2026-08-21 18:28:39 +08:00

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
}