Files
viettranx ee57ec0fdc feat(tts): expand per-provider params + validation + golden fixtures
Phase 1 of the TTS params/layout/agent-override plan.

Capability schema:
- `ParamSchema.Group` field (`"basic"` default, `"advanced"` when set).
- Tag existing advanced params across openai/elevenlabs/minimax/gemini.

Gemini:
- Expose `temperature` (basic, 0.0–2.0, default 1.0, subtle-effect note),
  `seed`, `presencePenalty`, `frequencyPenalty` (advanced, experimental).
- Merge into `generationConfig` via explicit-presence resolvers so
  nil-params bodies stay byte-equivalent.

ElevenLabs:
- `output_format` enum (27 variants, default `mp3_44100_128`, advanced).
- `FormatMeta` lookup drives SynthResult MIME + extension.
- URL built via `net/url.Values.Encode()`; regex pre-validation for
  `output_format` (`^[a-z0-9_]+$`) and `language_code`
  (`^[a-z]{2,3}(-[A-Z]{2})?$`) blocks query-string injection.
- Telegram opus contract preserved: `opts.Format=="opus"` forces
  `audio/ogg; codecs=opus` regardless of user-set `output_format`.

MiniMax:
- `language_boost` (basic enum), `subtitle_enable` (basic bool),
  `pronunciation_dict` (advanced text, 8KB cap, wrapped as
  `{"tone":[...]}`). Parse failure logs length only + omits.

Validation:
- `audio.ValidateParams` enforces Min/Max/Enum + rejects unknown keys.
- Wired into `/v1/tts/synthesize` and `/v1/tts/config` write paths.
- `loadParamsBlob` capped at 16KB.
- i18n keys `MsgTtsParamOutOfRange`, `MsgTtsParamInvalidJSON`,
  `MsgTtsParamUnknownKey` added to en/vi/zh catalogs.

Tests:
- Golden `testdata/default_body.golden.json` per provider (gemini,
  elevenlabs, minimax, openai) checked in; invariant tests diff
  against file instead of self-referential capture.
- Round-trip tests for each new param + Telegram opus contract +
  URL-injection attempts.
2026-04-20 00:20:50 +07:00

318 lines
8.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package minimax implements TTS via the MiniMax T2A v2 API.
// Docs: https://platform.minimax.io/docs/api-reference/speech-t2a-intro
package minimax
import (
"bytes"
"context"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"time"
"github.com/nextlevelbuilder/goclaw/internal/audio"
)
// pronunciationDictMaxBytes is the maximum accepted byte length for the
// pronunciation_dict param value. Finding #6: cap at 8 KB to prevent DoS.
const pronunciationDictMaxBytes = 8 * 1024
// Config configures the MiniMax TTS provider.
type Config struct {
APIKey string
GroupID string // required query param
APIBase string // default "https://api.minimax.io/v1"
Model string // default "speech-02-hd"
VoiceID string // default "Wise_Woman"
TimeoutMs int
}
// Provider implements audio.TTSProvider via MiniMax /t2a_v2.
type Provider struct {
apiKey string
groupID string
apiBase string
model string
voiceID string
timeoutMs int
}
// NewProvider returns a MiniMax TTS provider with defaults applied.
func NewProvider(cfg Config) *Provider {
p := &Provider{
apiKey: cfg.APIKey,
groupID: cfg.GroupID,
apiBase: cfg.APIBase,
model: cfg.Model,
voiceID: cfg.VoiceID,
timeoutMs: cfg.TimeoutMs,
}
if p.apiBase == "" {
p.apiBase = "https://api.minimax.io/v1"
}
if p.model == "" {
p.model = "speech-02-hd"
}
if p.voiceID == "" {
p.voiceID = "Wise_Woman"
}
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 "minimax" }
// Synthesize calls MiniMax t2a_v2 (non-streaming). MiniMax returns hex-encoded
// audio in response.data.audio — we decode before returning.
//
// opts.Params keys (nested dot-path):
// - "speed" float64 (0.5–2.0, default 1.0)
// - "vol" float64 (0.01–10.0, default 1.0; omitted from body at default)
// - "pitch" int (-12..12, default 0)
// - "emotion" string (optional)
// - "text_normalization" bool (optional)
// - "audio.format" string (mp3/pcm/flac/wav, default "mp3")
// - "audio.sample_rate" string (optional)
// - "audio.bitrate" string (optional, mp3 only)
// - "audio.channel" string (optional)
// - "language_boost" string (optional, e.g. "Chinese", "English")
// - "subtitle_enable" bool (optional)
// - "pronunciation_dict" string (optional JSON array, max 8 KB; Finding #6)
//
// MUST NOT mutate opts.Params — reads only.
func (p *Provider) Synthesize(ctx context.Context, text string, opts audio.TTSOptions) (*audio.SynthResult, error) {
voiceID := opts.Voice
if voiceID == "" {
voiceID = p.voiceID
}
model := opts.Model
if model == "" {
model = p.model
}
// Resolve audio format — affects both MIME type and audio_setting.format.
audioFormat := resolveMiniMaxString(opts.Params, "audio.format", "")
if audioFormat == "" {
// Fall back to opts.Format (legacy path) then default mp3.
audioFormat = opts.Format
}
if audioFormat == "" || (audioFormat != "pcm" && audioFormat != "flac" && audioFormat != "wav") {
audioFormat = "mp3"
}
ext, mime := audioFormat, "audio/mpeg"
switch audioFormat {
case "pcm":
ext, mime = "pcm", "audio/pcm"
case "flac":
ext, mime = "flac", "audio/flac"
case "wav":
ext, mime = "wav", "audio/wav"
}
// Build voice_setting from params with defaults matching characterization.
speed := resolveMiniMaxFloat(opts.Params, "speed", 1.0)
pitch := resolveMiniMaxInt(opts.Params, "pitch", 0)
voiceSetting := map[string]any{
"voice_id": voiceID,
"speed": speed,
"pitch": pitch,
}
// vol only added when explicitly set and not the default (preserves nil-params body shape).
if vol := resolveMiniMaxFloat(opts.Params, "vol", -1); vol > 0 && vol != 1.0 {
voiceSetting["vol"] = vol
}
// emotion only added when set.
if emotion := resolveMiniMaxString(opts.Params, "emotion", ""); emotion != "" {
voiceSetting["emotion"] = emotion
}
// Build audio_setting.
audioSetting := map[string]any{
"format": audioFormat,
}
if sr := resolveMiniMaxString(opts.Params, "audio.sample_rate", ""); sr != "" {
audioSetting["sample_rate"] = sr
}
if br := resolveMiniMaxString(opts.Params, "audio.bitrate", ""); br != "" {
audioSetting["bitrate"] = br
}
if ch := resolveMiniMaxString(opts.Params, "audio.channel", ""); ch != "" {
audioSetting["channel"] = ch
}
body := map[string]any{
"text": text,
"model": model,
"stream": false,
"voice_setting": voiceSetting,
"audio_setting": audioSetting,
}
// text_normalization only when explicitly set.
if tn, ok := resolveMiniMaxBoolExplicit(opts.Params, "text_normalization"); ok {
body["text_normalization"] = tn
}
// language_boost: top-level string hint (omit when empty).
if lb := resolveMiniMaxString(opts.Params, "language_boost", ""); lb != "" {
body["language_boost"] = lb
}
// subtitle_enable: only when explicitly set.
if se, ok := resolveMiniMaxBoolExplicit(opts.Params, "subtitle_enable"); ok {
body["subtitle_enable"] = se
}
// pronunciation_dict: parse user-pasted JSON array, wrap in {"tone":[...]}.
// Finding #6: cap at 8 KB; never log raw value on parse failure — log length only.
if pdRaw := resolveMiniMaxString(opts.Params, "pronunciation_dict", ""); pdRaw != "" {
if len(pdRaw) > pronunciationDictMaxBytes {
slog.Warn("minimax: pronunciation_dict exceeds 8 KB limit, omitting",
"length", len(pdRaw))
} else {
var rules []string
if err := json.Unmarshal([]byte(pdRaw), &rules); err != nil {
slog.Warn("minimax: invalid pronunciation_dict JSON, omitting",
"length", len(pdRaw))
} else if len(rules) > 0 {
body["pronunciation_dict"] = map[string]any{"tone": rules}
}
}
}
bodyJSON, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("marshal minimax tts request: %w", err)
}
url := fmt.Sprintf("%s/t2a_v2?GroupId=%s", p.apiBase, p.groupID)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyJSON))
if err != nil {
return nil, fmt.Errorf("create minimax 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("minimax tts request failed: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read minimax tts response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("minimax tts error %d: %s", resp.StatusCode, string(respBody))
}
var apiResp miniMaxResponse
if err := json.Unmarshal(respBody, &apiResp); err != nil {
return nil, fmt.Errorf("parse minimax tts response: %w", err)
}
if apiResp.BaseResp.StatusCode != 0 {
return nil, fmt.Errorf("minimax tts api error %d: %s", apiResp.BaseResp.StatusCode, apiResp.BaseResp.StatusMsg)
}
if apiResp.Data.Audio == "" {
return nil, fmt.Errorf("minimax tts returned empty audio")
}
audioBytes, err := hex.DecodeString(apiResp.Data.Audio)
if err != nil {
return nil, fmt.Errorf("decode minimax tts audio hex: %w", err)
}
return &audio.SynthResult{
Audio: audioBytes,
Extension: ext,
MimeType: mime,
}, nil
}
type miniMaxResponse struct {
BaseResp struct {
StatusCode int `json:"status_code"`
StatusMsg string `json:"status_msg"`
} `json:"base_resp"`
Data struct {
Audio string `json:"audio"` // hex-encoded audio bytes
} `json:"data"`
}
func resolveMiniMaxFloat(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)
}
return def
}
func resolveMiniMaxInt(params map[string]any, key string, def int) int {
if params == nil {
return def
}
v, ok := audio.GetNested(params, key)
if !ok {
return def
}
switch n := v.(type) {
case int:
return n
case int64:
return int(n)
case float64:
return int(n)
}
return def
}
func resolveMiniMaxString(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 {
return s
}
return def
}
// resolveMiniMaxBoolExplicit returns (value, true) only when the key is explicitly
// present in params, so callers can omit the field entirely when not set.
func resolveMiniMaxBoolExplicit(params map[string]any, key string) (bool, bool) {
if params == nil {
return false, false
}
v, ok := audio.GetNested(params, key)
if !ok {
return false, false
}
b, ok := v.(bool)
return b, ok
}