2026-10-18 16:34:43 +02:00
|
|
|
package provider
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"fmt"
|
|
|
|
|
"net/http"
|
|
|
|
|
"strings"
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// OpenAI TTS constants (public contract as with ElevenLabs).
|
2026-10-18 16:34:43 +02:00
|
|
|
const (
|
2026-10-24 11:15:35 +02:00
|
|
|
// DefaultEndpointOpenAI is the production API endpoint.
|
2026-10-18 16:34:43 +02:00
|
|
|
DefaultEndpointOpenAI = "https://api.openai.com"
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// DefaultModelOpenAI: decision note. The model string is configurable
|
|
|
|
|
// ([providers.openai] model), default "tts-1"; "gpt-4o-mini-tts" is a
|
|
|
|
|
// valid alternative.
|
2026-10-18 16:34:43 +02:00
|
|
|
DefaultModelOpenAI = "tts-1"
|
|
|
|
|
|
|
|
|
|
EnvOpenAIKey = "TALK_OPENAI_KEY"
|
|
|
|
|
EnvOpenAIEndpoint = "TALK_OPENAI_ENDPOINT"
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// OpenAI is the second tier of the chain (for users with an existing OpenAI
|
|
|
|
|
// key). Synthesize performs POST <endpoint>/v1/audio/speech with a bearer
|
|
|
|
|
// key and body {model, voice, input, response_format: "mp3"} and returns
|
|
|
|
|
// the response bytes unchanged. Return format: MP3.
|
2026-10-18 16:34:43 +02:00
|
|
|
type OpenAI struct {
|
2026-10-24 11:15:35 +02:00
|
|
|
// Endpoint is the base URL (without path); empty = default at
|
|
|
|
|
// construction.
|
2026-10-18 16:34:43 +02:00
|
|
|
Endpoint string
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// APIKey is the OpenAI key (from TALK_OPENAI_KEY).
|
2026-10-18 16:34:43 +02:00
|
|
|
APIKey string
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Model is the TTS model string; empty = DefaultModelOpenAI.
|
2026-10-18 16:34:43 +02:00
|
|
|
Model string
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Voices is the config mapping voice.openai.<lang> = id; empty =
|
|
|
|
|
// neutral repo defaults (alloy, see voices.go).
|
2026-10-18 16:34:43 +02:00
|
|
|
Voices map[string]string
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Client is the HTTP client; nil = shared client with timeout.
|
2026-10-18 16:34:43 +02:00
|
|
|
Client *http.Client
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
pinned string // --voice pin (VoicePinner)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// NewOpenAI builds the provider from the chain configuration: endpoint from
|
|
|
|
|
// TALK_OPENAI_ENDPOINT > [providers.openai].endpoint > default; model from
|
|
|
|
|
// [providers.openai].model; key exclusively from TALK_OPENAI_KEY.
|
2026-10-18 16:34:43 +02:00
|
|
|
func NewOpenAI(cfg ChainConfig) *OpenAI {
|
|
|
|
|
return &OpenAI{
|
|
|
|
|
Endpoint: endpointFrom(cfg, "openai", EnvOpenAIEndpoint, DefaultEndpointOpenAI),
|
|
|
|
|
APIKey: cfg.Getenv(EnvOpenAIKey),
|
|
|
|
|
Model: cfg.Model,
|
|
|
|
|
Voices: cfg.Voices["openai"],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Name implements Provider.
|
2026-10-18 16:34:43 +02:00
|
|
|
func (*OpenAI) Name() string { return "openai" }
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// PinVoice implements VoicePinner (CLI flag --voice).
|
2026-10-18 16:34:43 +02:00
|
|
|
func (p *OpenAI) PinVoice(id string) { p.pinned = id }
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Available implements Provider: key and endpoint must be set (without a
|
|
|
|
|
// network call, see ElevenLabs.Available).
|
2026-10-18 16:34:43 +02:00
|
|
|
func (p *OpenAI) Available(ctx context.Context) bool {
|
|
|
|
|
return p.APIKey != "" && p.Endpoint != ""
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// openAIRequest is the request body.
|
2026-10-18 16:34:43 +02:00
|
|
|
type openAIRequest struct {
|
|
|
|
|
Model string `json:"model"`
|
|
|
|
|
Voice string `json:"voice"`
|
|
|
|
|
Input string `json:"input"`
|
|
|
|
|
ResponseFormat string `json:"response_format"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Synthesize implements Provider. Return format: MP3.
|
2026-10-18 16:34:43 +02:00
|
|
|
func (p *OpenAI) Synthesize(ctx context.Context, text, lang string) ([]byte, error) {
|
|
|
|
|
if text == "" {
|
2026-10-24 11:15:35 +02:00
|
|
|
return nil, fmt.Errorf("openai: empty text")
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
voice := resolveVoice("openai", lang, p.pinned, p.Voices)
|
|
|
|
|
if voice == "" {
|
2026-10-24 11:15:35 +02:00
|
|
|
return nil, fmt.Errorf("openai: no voice configured for language %q", lang)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
model := p.Model
|
|
|
|
|
if model == "" {
|
|
|
|
|
model = DefaultModelOpenAI
|
|
|
|
|
}
|
|
|
|
|
body, err := json.Marshal(openAIRequest{
|
|
|
|
|
Model: model,
|
|
|
|
|
Voice: voice,
|
|
|
|
|
Input: text,
|
|
|
|
|
ResponseFormat: "mp3",
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
return nil, fmt.Errorf("openai: building request body: %w", err)
|
2026-10-18 16:34:43 +02:00
|
|
|
}
|
|
|
|
|
url := strings.TrimRight(p.Endpoint, "/") + "/v1/audio/speech"
|
|
|
|
|
return postForBytes(ctx, p.Client, "openai", url,
|
|
|
|
|
map[string]string{"Authorization": "Bearer " + p.APIKey}, body)
|
|
|
|
|
}
|