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"
"os"
"os/signal"
"strings"
"syscall"
"time"
@@ -124,12 +125,24 @@ func main() {
logger.Infof("[plugin] loaded %d plugins: %v", reg.Count(), reg.ListNames())
// === 5. Voice Service ===
orEmpty := func(v, fallback string) string {
if v != "" {
return v
}
return fallback
}
voiceSvc := voicesvc.New(voicesvc.Config{
STTProvider: cfg.ASRProvider,
TTSProviders: []string{cfg.TTSProvider},
DashScopeKey: cfg.DashScopeAPIKey,
STTApiURL: cfg.STTApiURL,
STTApiKey: orEmpty(cfg.STTApiKey, 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 ===
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) }
// 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 {
result := make([]map[string]string, len(names))
for i, n := range names {