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 }