mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 22:17:36 +00:00
Baseline groundwork for TTS expansion plan. Introduces a capabilities system that the follow-up plan (phase-01..04) builds on. Backend: - `audio.ParamSchema` / `ProviderCapabilities` types + per-provider capabilities.go for edge, elevenlabs, minimax, openai, gemini. - Gemini TTS provider (client, models, voices, wav encoder, multi-speaker via SpeakerVoice, audio-tag aware prompts). - TTSOptions gains `Params map[string]any` (read-only) + `Speakers`. - `VoiceListProvider` interface decouples HTTP voice handler from provider-specific impls. - `nested_keys.go` resolves dot-separated param paths for nested provider bodies (voice_settings.stability etc.). - Characterization + defaults-invariant tests per provider lock nil-params byte-equivalence before new params land. - `/v1/tts/capabilities` HTTP endpoint + integration coverage. - Dual-read tests (PG + SQLite) for tts_config. Frontend (web + desktop): - `DynamicParamForm` with depends-on evaluation, split into fields/logic modules. Slider primitive added. - `AudioTagPicker` + `MultiSpeakerEditor` for Gemini. - `voice-picker` refactored toward portal UX; combobox tightened. - tts-capabilities API client + typed hooks. - i18n catalogs (en/vi/zh) expanded; parity tests guard key drift. - TTS page reorganised (voice-model-section removed; playground + credentials + provider-setup split cleanly). Docs: codebase-summary, project-changelog, tts-provider-capabilities.
171 lines
4.1 KiB
Go
171 lines
4.1 KiB
Go
// Package openai implements the OpenAI audio/speech API for TTS.
|
|
package openai
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/audio"
|
|
)
|
|
|
|
// Config bundles credentials + TTS defaults for OpenAI.
|
|
type Config struct {
|
|
APIKey string
|
|
APIBase string // default "https://api.openai.com/v1"
|
|
Model string // default "gpt-4o-mini-tts"
|
|
Voice string // default "alloy"
|
|
TimeoutMs int // default 30000
|
|
}
|
|
|
|
// Provider implements audio.TTSProvider for OpenAI.
|
|
type Provider struct {
|
|
apiKey string
|
|
apiBase string
|
|
model string
|
|
voice string
|
|
timeoutMs int
|
|
}
|
|
|
|
// NewProvider constructs an OpenAI TTS provider with defaults applied.
|
|
func NewProvider(cfg Config) *Provider {
|
|
p := &Provider{
|
|
apiKey: cfg.APIKey,
|
|
apiBase: cfg.APIBase,
|
|
model: cfg.Model,
|
|
voice: cfg.Voice,
|
|
timeoutMs: cfg.TimeoutMs,
|
|
}
|
|
if p.apiBase == "" {
|
|
p.apiBase = "https://api.openai.com/v1"
|
|
}
|
|
if p.model == "" {
|
|
p.model = "gpt-4o-mini-tts"
|
|
}
|
|
if p.voice == "" {
|
|
p.voice = "alloy"
|
|
}
|
|
if p.timeoutMs <= 0 {
|
|
p.timeoutMs = 30000
|
|
}
|
|
return p
|
|
}
|
|
|
|
// Name returns the stable provider identifier used by the Manager.
|
|
func (p *Provider) Name() string { return "openai" }
|
|
|
|
// Synthesize calls POST {apiBase}/audio/speech.
|
|
// opts.Params keys: "speed" (float), "response_format" (string), "instructions" (string).
|
|
// When opts.Params is nil or a key is absent, schema defaults are used.
|
|
// MUST NOT mutate opts.Params — reads only.
|
|
func (p *Provider) Synthesize(ctx context.Context, text string, opts audio.TTSOptions) (*audio.SynthResult, error) {
|
|
voice := opts.Voice
|
|
if voice == "" {
|
|
voice = p.voice
|
|
}
|
|
model := opts.Model
|
|
if model == "" {
|
|
model = p.model
|
|
}
|
|
|
|
// Resolve params: caller-supplied wins over schema default.
|
|
format := resolveString(opts.Params, "response_format", "mp3")
|
|
speed := resolveFloat(opts.Params, "speed", 1.0)
|
|
instructions := resolveString(opts.Params, "instructions", "")
|
|
|
|
body := map[string]any{
|
|
"model": model,
|
|
"input": text,
|
|
"voice": voice,
|
|
"response_format": format,
|
|
"speed": speed,
|
|
}
|
|
if instructions != "" {
|
|
body["instructions"] = instructions
|
|
}
|
|
|
|
bodyJSON, err := json.Marshal(body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal openai tts request: %w", err)
|
|
}
|
|
|
|
url := p.apiBase + "/audio/speech"
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyJSON))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create openai tts request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
|
|
|
hc := &http.Client{Timeout: time.Duration(p.timeoutMs) * time.Millisecond}
|
|
resp, err := hc.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("openai tts request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
errBody, _ := io.ReadAll(resp.Body)
|
|
return nil, fmt.Errorf("openai tts error %d: %s", resp.StatusCode, string(errBody))
|
|
}
|
|
audioBytes, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read openai tts response: %w", err)
|
|
}
|
|
|
|
ext, mime := format, "audio/mpeg"
|
|
switch format {
|
|
case "opus":
|
|
ext, mime = "ogg", "audio/ogg"
|
|
case "mp3":
|
|
mime = "audio/mpeg"
|
|
}
|
|
|
|
return &audio.SynthResult{
|
|
Audio: audioBytes,
|
|
Extension: ext,
|
|
MimeType: mime,
|
|
}, nil
|
|
}
|
|
|
|
// resolveString reads a string param from params map, falling back to def.
|
|
func resolveString(params map[string]any, key, def string) string {
|
|
if params == nil {
|
|
return def
|
|
}
|
|
v, ok := audio.GetNested(params, key)
|
|
if !ok {
|
|
return def
|
|
}
|
|
if s, ok := v.(string); ok && s != "" {
|
|
return s
|
|
}
|
|
return def
|
|
}
|
|
|
|
// resolveFloat reads a float param from params map, falling back to def.
|
|
func resolveFloat(params map[string]any, key string, def float64) float64 {
|
|
if params == nil {
|
|
return def
|
|
}
|
|
v, ok := audio.GetNested(params, key)
|
|
if !ok {
|
|
return def
|
|
}
|
|
switch n := v.(type) {
|
|
case float64:
|
|
return n
|
|
case float32:
|
|
return float64(n)
|
|
case int:
|
|
return float64(n)
|
|
case int64:
|
|
return float64(n)
|
|
}
|
|
return def
|
|
}
|