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

156 lines
3.9 KiB
Go

package audio
import (
"fmt"
"maps"
)
// ErrTTSParamOutOfRange is returned when a numeric param value is outside the
// declared Min/Max bounds.
type ErrTTSParamOutOfRange struct {
Key string
Val any
Min any
Max any
}
func (e ErrTTSParamOutOfRange) Error() string {
return fmt.Sprintf("TTS param %q value %v is out of range [%v, %v]", e.Key, e.Val, e.Min, e.Max)
}
// ErrTTSParamUnknownKey is returned when a user-supplied key is not present in
// the capability schema for the provider.
type ErrTTSParamUnknownKey struct {
Key string
}
func (e ErrTTSParamUnknownKey) Error() string {
return fmt.Sprintf("TTS param %q is not supported by this provider", e.Key)
}
// ValidateParams checks all keys in values against the capability schema:
// - Unknown keys return ErrTTSParamUnknownKey.
// - Numeric values outside declared Min/Max return ErrTTSParamOutOfRange.
// - String values not in the Enum list (when non-empty) return an out-of-range error.
//
// Empty values map: no-op returns nil.
// Empty schema: every key is unknown — callers that don't have a schema may pass nil schema to skip.
// Only the first validation error is returned (fail-fast).
func ValidateParams(schema []ParamSchema, values map[string]any) error {
if len(values) == 0 {
return nil
}
// Build a flat key → schema index map for O(1) lookup.
// ParamSchema keys use dot-notation (e.g. "voice_settings.stability"), but
// values arriving from the HTTP layer use the same dot-notation in a nested map.
// We flatten the incoming map to dot-notation keys for matching.
flat := flattenMap(values, "")
// Index schema by key for fast lookup.
schemaIdx := make(map[string]ParamSchema, len(schema))
for _, s := range schema {
schemaIdx[s.Key] = s
}
for flatKey, val := range flat {
s, ok := schemaIdx[flatKey]
if !ok {
return ErrTTSParamUnknownKey{Key: flatKey}
}
// Range / number / integer: enforce Min/Max when declared.
if s.Min != nil || s.Max != nil {
fval, ok := toFloat64(val)
if ok {
if s.Min != nil && fval < *s.Min {
return ErrTTSParamOutOfRange{Key: flatKey, Val: val, Min: *s.Min, Max: maxOrInf(s.Max)}
}
if s.Max != nil && fval > *s.Max {
return ErrTTSParamOutOfRange{Key: flatKey, Val: val, Min: minOrNegInf(s.Min), Max: *s.Max}
}
}
}
// Enum: validate against declared options (skip empty value — means "use default").
if len(s.Enum) > 0 {
if sv, ok := val.(string); ok && sv != "" {
if !enumContains(s.Enum, sv) {
return ErrTTSParamOutOfRange{Key: flatKey, Val: val, Min: enumFirst(s.Enum), Max: enumLast(s.Enum)}
}
}
}
}
return nil
}
// flattenMap converts a nested map[string]any into a flat map with dot-separated keys.
// prefix is the parent key path (empty string for the root call).
func flattenMap(m map[string]any, prefix string) map[string]any {
out := make(map[string]any)
for k, v := range m {
fullKey := k
if prefix != "" {
fullKey = prefix + "." + k
}
if nested, ok := v.(map[string]any); ok {
maps.Copy(out, flattenMap(nested, fullKey))
} else {
out[fullKey] = v
}
}
return out
}
// toFloat64 converts common numeric JSON types to float64.
func toFloat64(v any) (float64, bool) {
switch n := v.(type) {
case float64:
return n, true
case float32:
return float64(n), true
case int:
return float64(n), true
case int64:
return float64(n), true
}
return 0, false
}
func enumContains(opts []EnumOption, val string) bool {
for _, o := range opts {
if o.Value == val {
return true
}
}
return false
}
func enumFirst(opts []EnumOption) any {
if len(opts) == 0 {
return nil
}
return opts[0].Value
}
func enumLast(opts []EnumOption) any {
if len(opts) == 0 {
return nil
}
return opts[len(opts)-1].Value
}
func maxOrInf(p *float64) any {
if p == nil {
return "+∞"
}
return *p
}
func minOrNegInf(p *float64) any {
if p == nil {
return "-∞"
}
return *p
}