Files
CamTalk/backend/internal/ai/tts/openai.go

157 lines
3.8 KiB
Go

package tts
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/hhs/camtalk/internal/trace"
"github.com/hhs/camtalk/internal/util"
"go.uber.org/zap"
)
// OpenAIService 基于 OpenAI TTS API 的语音合成实现。
type OpenAIService struct {
apiKey string
model string
voice string
speed float64
endpoint string
timeout time.Duration
logger *zap.SugaredLogger
client *http.Client
}
// NewOpenAIService 创建 OpenAI TTS 服务。
// model、voice、endpoint 由 config 层保证非空。
func NewOpenAIService(apiKey, model, voice, endpoint string, speed float64, timeoutSec, httpClientTimeoutSec int, logger *zap.SugaredLogger) *OpenAIService {
if speed <= 0 {
speed = 1.0
}
timeout := time.Duration(timeoutSec) * time.Second
if timeout <= 0 {
timeout = 5 * time.Second
}
httpClientTimeout := time.Duration(httpClientTimeoutSec) * time.Second
if httpClientTimeout <= 0 {
httpClientTimeout = 30 * time.Second
}
return &OpenAIService{
apiKey: apiKey,
model: model,
voice: voice,
speed: speed,
endpoint: endpoint,
timeout: timeout,
logger: logger,
client: &http.Client{Timeout: httpClientTimeout},
}
}
// 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 {
log := trace.FromContext(ctx)
log.Warnw("tts: synthesize failed",
"error", err,
"text_len", len(text),
"text_preview", util.Truncate(text, 100))
// 静默跳过,不中断整个流
continue
}
select {
case ch <- Chunk{Audio: audio, IsLast: true, Final: false}:
case <-ctx.Done():
return
}
}
// textStream 关闭,发送 Final 标记
select {
case ch <- Chunk{Audio: nil, IsLast: false, Final: 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: o.model,
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
}