mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-07-28 01:27:58 +00:00
227 lines
5.7 KiB
Go
227 lines
5.7 KiB
Go
package tts
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/sipeed/picoclaw/pkg/logger"
|
|
"github.com/sipeed/picoclaw/pkg/providers/common"
|
|
)
|
|
|
|
type OpenAITTSProvider struct {
|
|
apiKey string
|
|
apiBase string
|
|
voice string
|
|
model string
|
|
responseFormat string
|
|
httpClient *http.Client
|
|
}
|
|
|
|
type OpenAITTSOptions struct {
|
|
Voice string
|
|
ResponseFormat string
|
|
}
|
|
|
|
type openAITTSAudioStream struct {
|
|
io.ReadCloser
|
|
fileExt string
|
|
contentType string
|
|
}
|
|
|
|
func (s *openAITTSAudioStream) AudioFileMeta() (string, string) {
|
|
return s.fileExt, s.contentType
|
|
}
|
|
|
|
type openAITTSAPIError struct {
|
|
statusCode int
|
|
body string
|
|
}
|
|
|
|
func (e *openAITTSAPIError) Error() string {
|
|
return fmt.Sprintf("API error (status %d): %s", e.statusCode, e.body)
|
|
}
|
|
|
|
func NewOpenAITTSProvider(apiKey string, apiBase string, proxyURL string, model string) *OpenAITTSProvider {
|
|
return NewOpenAITTSProviderWithOptions(apiKey, apiBase, proxyURL, model, OpenAITTSOptions{})
|
|
}
|
|
|
|
func NewOpenAITTSProviderWithOptions(
|
|
apiKey string,
|
|
apiBase string,
|
|
proxyURL string,
|
|
model string,
|
|
options OpenAITTSOptions,
|
|
) *OpenAITTSProvider {
|
|
// Normalize apiBase to avoid malformed endpoints like
|
|
// "https://api.openai.com/audio/speech" when "/v1" is required.
|
|
if apiBase == "" {
|
|
apiBase = "https://api.openai.com/v1/audio/speech"
|
|
} else {
|
|
if u, err := url.Parse(apiBase); err == nil && u.Scheme != "" && u.Host != "" {
|
|
path := u.Path
|
|
if u.Host == "api.openai.com" {
|
|
// For the official OpenAI host, ensure exactly one /v1 prefix and
|
|
// that the path ends with /audio/speech.
|
|
if path == "" || path == "/" || path == "/v1" {
|
|
path = "/v1/audio/speech"
|
|
} else {
|
|
if !strings.HasPrefix(path, "/") {
|
|
path = "/" + path
|
|
}
|
|
if !strings.HasPrefix(path, "/v1/") {
|
|
path = "/v1" + strings.TrimSuffix(path, "/")
|
|
}
|
|
if !strings.HasSuffix(path, "/audio/speech") {
|
|
path = strings.TrimSuffix(path, "/") + "/audio/speech"
|
|
}
|
|
}
|
|
} else {
|
|
// For non-OpenAI hosts (e.g., proxies), preserve the existing base
|
|
// path and only ensure it ends with /audio/speech.
|
|
if !strings.HasSuffix(path, "/audio/speech") {
|
|
path = strings.TrimSuffix(path, "/") + "/audio/speech"
|
|
}
|
|
}
|
|
u.Path = path
|
|
apiBase = u.String()
|
|
} else {
|
|
// Fallback to the previous string-based behavior if parsing fails.
|
|
if apiBase == "https://api.openai.com/v1" {
|
|
apiBase = "https://api.openai.com/v1/audio/speech"
|
|
} else if !strings.HasSuffix(apiBase, "/audio/speech") {
|
|
// Just in case they provide openrouter base or standard base
|
|
apiBase = strings.TrimSuffix(apiBase, "/") + "/audio/speech"
|
|
}
|
|
}
|
|
}
|
|
|
|
client := common.NewHTTPClient(proxyURL)
|
|
client.Timeout = 60 * time.Second
|
|
|
|
model = strings.TrimSpace(model)
|
|
if model == "" {
|
|
model = "tts-1"
|
|
}
|
|
|
|
voice := strings.TrimSpace(options.Voice)
|
|
if voice == "" {
|
|
voice = "alloy"
|
|
}
|
|
|
|
responseFormat := strings.TrimSpace(options.ResponseFormat)
|
|
if responseFormat == "" {
|
|
responseFormat = "opus"
|
|
}
|
|
|
|
return &OpenAITTSProvider{
|
|
apiKey: apiKey,
|
|
apiBase: apiBase,
|
|
voice: voice,
|
|
model: model,
|
|
responseFormat: responseFormat,
|
|
httpClient: client,
|
|
}
|
|
}
|
|
|
|
func (t *OpenAITTSProvider) Name() string {
|
|
return "openai-tts"
|
|
}
|
|
|
|
func (t *OpenAITTSProvider) Synthesize(ctx context.Context, text string) (io.ReadCloser, error) {
|
|
logger.DebugCF("voice-tts", "Starting TTS synthesis", map[string]any{"text_len": len(text)})
|
|
|
|
responseFormat := t.responseFormat
|
|
resp, err := t.doSpeechRequest(ctx, text, responseFormat)
|
|
if err != nil {
|
|
var apiErr *openAITTSAPIError
|
|
if errors.As(err, &apiErr) && shouldRetryWithoutResponseFormat(apiErr.body) {
|
|
logger.InfoCF("voice-tts", "Retrying TTS without response_format after provider rejection", map[string]any{
|
|
"model": t.model,
|
|
})
|
|
responseFormat = ""
|
|
resp, err = t.doSpeechRequest(ctx, text, responseFormat)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
fileExt, contentType := audioFileMetaForResponseFormat(responseFormat)
|
|
return &openAITTSAudioStream{
|
|
ReadCloser: resp.Body,
|
|
fileExt: fileExt,
|
|
contentType: contentType,
|
|
}, nil
|
|
}
|
|
|
|
func (t *OpenAITTSProvider) doSpeechRequest(
|
|
ctx context.Context,
|
|
text string,
|
|
responseFormat string,
|
|
) (*http.Response, error) {
|
|
reqBody := map[string]any{
|
|
"model": t.model,
|
|
"input": text,
|
|
"voice": t.voice,
|
|
}
|
|
if responseFormat != "" {
|
|
reqBody["response_format"] = responseFormat
|
|
}
|
|
|
|
jsonData, err := json.Marshal(reqBody)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", t.apiBase, bytes.NewReader(jsonData))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+t.apiKey)
|
|
|
|
resp, err := t.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to send request: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return nil, &openAITTSAPIError{
|
|
statusCode: resp.StatusCode,
|
|
body: string(body),
|
|
}
|
|
}
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
func shouldRetryWithoutResponseFormat(body string) bool {
|
|
lower := strings.ToLower(body)
|
|
if !strings.Contains(lower, "response_format") {
|
|
return false
|
|
}
|
|
return strings.Contains(lower, "invalid") || strings.Contains(lower, "unsupported")
|
|
}
|
|
|
|
func audioFileMetaForResponseFormat(responseFormat string) (string, string) {
|
|
switch strings.ToLower(strings.TrimSpace(responseFormat)) {
|
|
case "", "mp3":
|
|
return ".mp3", "audio/mpeg"
|
|
case "wav":
|
|
return ".wav", "audio/wav"
|
|
default:
|
|
return ".ogg", "audio/ogg"
|
|
}
|
|
}
|