feat: 实现 OpenAI TTS 服务(逐句合成 + 5s 超时 + 静默跳过失败)
This commit is contained in:
149
backend/internal/ai/tts/openai.go
Normal file
149
backend/internal/ai/tts/openai.go
Normal file
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user