Files
Cyrene_v2/backend/services/voice/providers.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

193 lines
5.1 KiB
Go

package voice
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// --- Cloud TTS (OpenAI-compatible /audio/speech, e.g. Alibaba Bailian compatible mode) ---
type cloudTTS struct {
baseURL, apiKey, model, defaultVoice string
client *http.Client
}
func (p *cloudTTS) Name() string { return "cloud" }
func (p *cloudTTS) Synthesize(ctx context.Context, text string, voice string) ([]byte, error) {
if voice == "" {
voice = p.defaultVoice
}
body, _ := json.Marshal(map[string]any{
"model": p.model,
"voice": voice,
"input": text,
})
req, err := http.NewRequestWithContext(ctx, "POST", p.baseURL+"/audio/speech", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+p.apiKey)
req.Header.Set("Content-Type", "application/json")
resp, err := p.client.Do(req)
if err != nil {
return nil, fmt.Errorf("cloud tts: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return nil, fmt.Errorf("cloud tts %d: %s", resp.StatusCode, msg)
}
return io.ReadAll(resp.Body)
}
// --- Local TTS (self-hosted HTTP TTS server, e.g. GPT-SoVITS / edge-tts-server / OpenTTS) ---
type localTTS struct {
url string // POST endpoint accepting {"text","voice"} and returning audio bytes
client *http.Client
}
func (p *localTTS) Name() string { return "local" }
func (p *localTTS) Synthesize(ctx context.Context, text string, voice string) ([]byte, error) {
body, _ := json.Marshal(map[string]any{"text": text, "voice": voice})
req, err := http.NewRequestWithContext(ctx, "POST", p.url, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := p.client.Do(req)
if err != nil {
return nil, fmt.Errorf("local tts: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("local tts %d", resp.StatusCode)
}
return io.ReadAll(resp.Body)
}
// --- edge-tts CLI ---
type edgeTTS struct{ bin string }
// newEdgeTTS locates the edge-tts binary: EDGETTS_BIN env, PATH, then ./.venv.
func newEdgeTTS() *edgeTTS {
if bin := os.Getenv("EDGETTS_BIN"); bin != "" {
return &edgeTTS{bin: bin}
}
if bin, err := exec.LookPath("edge-tts"); err == nil {
return &edgeTTS{bin: bin}
}
if _, err := os.Stat(".venv/bin/edge-tts"); err == nil {
abs, _ := filepath.Abs(".venv/bin/edge-tts")
return &edgeTTS{bin: abs}
}
return &edgeTTS{}
}
func (p *edgeTTS) Name() string { return "edge-tts" }
func (p *edgeTTS) available() bool { return p.bin != "" }
func (p *edgeTTS) Synthesize(ctx context.Context, text string, voice string) ([]byte, error) {
if !p.available() {
return nil, fmt.Errorf("edge-tts binary not found")
}
if voice == "" {
voice = "zh-CN-XiaoxiaoNeural"
}
out := filepath.Join(os.TempDir(), fmt.Sprintf("cyrene-tts-%d.mp3", time.Now().UnixNano()))
defer os.Remove(out)
cmd := exec.CommandContext(ctx, p.bin, "--voice", voice, "--text", text, "--write-media", out)
if combined, err := cmd.CombinedOutput(); err != nil {
return nil, fmt.Errorf("edge-tts: %w: %s", err, strings.TrimSpace(string(combined)))
}
return os.ReadFile(out)
}
// --- espeak CLI ---
type espeakTTS struct{ bin string }
func newEspeakTTS() *espeakTTS {
for _, name := range []string{"espeak-ng", "espeak"} {
if bin, err := exec.LookPath(name); err == nil {
return &espeakTTS{bin: bin}
}
}
return &espeakTTS{}
}
func (p *espeakTTS) Name() string { return "espeak" }
func (p *espeakTTS) Synthesize(ctx context.Context, text string, _ string) ([]byte, error) {
if p.bin == "" {
return nil, fmt.Errorf("espeak binary not found")
}
cmd := exec.CommandContext(ctx, p.bin, "-v", "zh", "--stdout", text)
return cmd.Output()
}
// --- Whisper-compatible STT (OpenAI /audio/transcriptions; local servers like faster-whisper too) ---
type whisperSTT struct {
baseURL, apiKey, model string
client *http.Client
}
func (p *whisperSTT) Name() string { return "whisper-api" }
func (p *whisperSTT) Recognize(ctx context.Context, audioData []byte) (string, error) {
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
fw, err := w.CreateFormFile("file", "audio.wav")
if err != nil {
return "", err
}
if _, err := fw.Write(audioData); err != nil {
return "", err
}
w.WriteField("model", p.model)
w.Close()
req, err := http.NewRequestWithContext(ctx, "POST", p.baseURL+"/audio/transcriptions", &buf)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+p.apiKey)
req.Header.Set("Content-Type", w.FormDataContentType())
resp, err := p.client.Do(req)
if err != nil {
return "", fmt.Errorf("stt: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return "", fmt.Errorf("stt %d: %s", resp.StatusCode, msg)
}
var result struct {
Text string `json:"text"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("parse stt: %w", err)
}
return result.Text, nil
}