From acc7dfce43f402681319bbe3ad6a0bcd5b865a14 Mon Sep 17 00:00:00 2001 From: hhs <386998068@qq.com> Date: Sat, 13 Jun 2026 15:47:43 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20OpenAI=20TTS=20?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=EF=BC=88=E9=80=90=E5=8F=A5=E5=90=88=E6=88=90?= =?UTF-8?q?=20+=205s=20=E8=B6=85=E6=97=B6=20+=20=E9=9D=99=E9=BB=98?= =?UTF-8?q?=E8=B7=B3=E8=BF=87=E5=A4=B1=E8=B4=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/ai/tts/openai.go | 149 ++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 backend/internal/ai/tts/openai.go diff --git a/backend/internal/ai/tts/openai.go b/backend/internal/ai/tts/openai.go new file mode 100644 index 0000000..24e350f --- /dev/null +++ b/backend/internal/ai/tts/openai.go @@ -0,0 +1,149 @@ +package tts + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "go.uber.org/zap" +) + +// OpenAIService 基于 OpenAI TTS API 的语音合成实现。 +type OpenAIService struct { + apiKey string + voice string + speed float64 + endpoint string + timeout time.Duration + logger *zap.SugaredLogger + client *http.Client +} + +// NewOpenAIService 创建 OpenAI TTS 服务。 +func NewOpenAIService(apiKey, voice, endpoint string, speed float64, timeoutSec int, logger *zap.SugaredLogger) *OpenAIService { + if voice == "" { + voice = "alloy" + } + if speed <= 0 { + speed = 1.0 + } + if endpoint == "" { + endpoint = "https://api.openai.com/v1" + } + timeout := time.Duration(timeoutSec) * time.Second + if timeout <= 0 { + timeout = 5 * time.Second + } + return &OpenAIService{ + apiKey: apiKey, + voice: voice, + speed: speed, + endpoint: endpoint, + timeout: timeout, + logger: logger, + client: &http.Client{Timeout: 30 * time.Second}, + } +} + +// ttsRequest OpenAI TTS API 请求。 +type ttsRequest struct { + Model string `json:"model"` + Input string `json:"input"` + Voice string `json:"voice"` + ResponseFormat string `json:"response_format"` + Speed float64 `json:"speed"` +} + +// SynthesizeStream 实现 tts.Service。从 textStream 读取句子,逐句调用 OpenAI TTS API。 +func (o *OpenAIService) SynthesizeStream(ctx context.Context, textStream <-chan string, opts Options) (<-chan Chunk, error) { + voice := opts.Voice + if voice == "" { + voice = o.voice + } + speed := opts.Speed + if speed <= 0 { + speed = o.speed + } + + ch := make(chan Chunk, 4) + go func() { + defer close(ch) + + for text := range textStream { + if text == "" { + continue + } + + audio, err := o.synthesize(ctx, text, voice, speed) + if err != nil { + o.logger.Warnw("tts: synthesize failed", "error", err, "text", text) + // 静默跳过,不中断整个流 + continue + } + + select { + case ch <- Chunk{Audio: audio, IsLast: false}: + case <-ctx.Done(): + return + } + } + + // textStream 关闭,发送 IsLast 标记 + select { + case ch <- Chunk{Audio: nil, IsLast: true}: + case <-ctx.Done(): + } + }() + + return ch, nil +} + +// synthesize 调用 OpenAI TTS API 合成单个句子。 +func (o *OpenAIService) synthesize(ctx context.Context, text, voice string, speed float64) ([]byte, error) { + // 单句超时 + ctx, cancel := context.WithTimeout(ctx, o.timeout) + defer cancel() + + body := ttsRequest{ + Model: "tts-1", + Input: text, + Voice: voice, + ResponseFormat: "mp3", + Speed: speed, + } + + payload, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("tts: marshal request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, o.endpoint+"/audio/speech", bytes.NewReader(payload)) + if err != nil { + return nil, fmt.Errorf("tts: create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+o.apiKey) + + resp, err := o.client.Do(req) + if err != nil { + return nil, fmt.Errorf("tts: send request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + errBody, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("tts: api error (status %d): %s", resp.StatusCode, string(errBody)) + } + + // 读取整个 MP3 响应 + audio, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("tts: read response: %w", err) + } + + return audio, nil +}