- 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
193 lines
5.1 KiB
Go
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
|
|
}
|