Files
viettranx 613b6e38d7 feat(tts): provider capabilities schema + Gemini TTS + dynamic param forms
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.
2026-04-20 00:05:19 +07:00

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
}