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

231 lines
7.6 KiB
Go

package minimax_test
import (
"testing"
"github.com/nextlevelbuilder/goclaw/internal/audio"
"github.com/nextlevelbuilder/goclaw/internal/audio/minimax"
)
func TestSynthesize_AppliesParams_Speed(t *testing.T) {
cfg := minimax.Config{APIKey: "k", GroupID: "g"}
body, _ := captureMiniMaxBody(t, cfg, audio.TTSOptions{
Params: map[string]any{"speed": 1.5},
})
vs, _ := body["voice_setting"].(map[string]any)
assertMiniMaxFloat(t, vs, "speed", 1.5)
}
func TestSynthesize_AppliesParams_Pitch(t *testing.T) {
cfg := minimax.Config{APIKey: "k", GroupID: "g"}
body, _ := captureMiniMaxBody(t, cfg, audio.TTSOptions{
Params: map[string]any{"pitch": 3},
})
vs, _ := body["voice_setting"].(map[string]any)
if p, _ := vs["pitch"].(float64); int(p) != 3 {
t.Errorf("pitch: got %v, want 3", vs["pitch"])
}
}
func TestSynthesize_AppliesParams_AudioGroup_Format(t *testing.T) {
cfg := minimax.Config{APIKey: "k", GroupID: "g"}
body, _ := captureMiniMaxBody(t, cfg, audio.TTSOptions{
Params: map[string]any{
"audio": map[string]any{"format": "flac"},
},
})
as, _ := body["audio_setting"].(map[string]any)
if f, _ := as["format"].(string); f != "flac" {
t.Errorf("audio_setting.format: got %q, want flac", f)
}
}
func TestSynthesize_AppliesParams_AudioGroup_Bitrate(t *testing.T) {
cfg := minimax.Config{APIKey: "k", GroupID: "g"}
body, _ := captureMiniMaxBody(t, cfg, audio.TTSOptions{
Params: map[string]any{
"audio": map[string]any{"format": "mp3", "bitrate": "128000"},
},
})
as, _ := body["audio_setting"].(map[string]any)
if br, _ := as["bitrate"].(string); br != "128000" {
t.Errorf("audio_setting.bitrate: got %q, want 128000", br)
}
}
func TestSynthesize_AppliesParams_Emotion(t *testing.T) {
cfg := minimax.Config{APIKey: "k", GroupID: "g"}
body, _ := captureMiniMaxBody(t, cfg, audio.TTSOptions{
Params: map[string]any{"emotion": "happy"},
})
vs, _ := body["voice_setting"].(map[string]any)
if em, _ := vs["emotion"].(string); em != "happy" {
t.Errorf("voice_setting.emotion: got %q, want happy", em)
}
}
func TestSynthesize_AppliesParams_OmitsEmpty_NilParams(t *testing.T) {
cfg := minimax.Config{APIKey: "k", GroupID: "g"}
body, _ := captureMiniMaxBody(t, cfg, audio.TTSOptions{})
// text_normalization must be absent for nil params.
if _, ok := body["text_normalization"]; ok {
t.Error("text_normalization must not appear for nil params")
}
// emotion must be absent.
vs, _ := body["voice_setting"].(map[string]any)
if _, ok := vs["emotion"]; ok {
t.Error("emotion must not appear for nil params")
}
// New params must also be absent for nil params.
if _, ok := body["language_boost"]; ok {
t.Error("language_boost must not appear for nil params")
}
if _, ok := body["subtitle_enable"]; ok {
t.Error("subtitle_enable must not appear for nil params")
}
if _, ok := body["pronunciation_dict"]; ok {
t.Error("pronunciation_dict must not appear for nil params")
}
}
// TestSynthesize_AppliesParams_LanguageBoost verifies language_boost top-level field.
func TestSynthesize_AppliesParams_LanguageBoost(t *testing.T) {
cfg := minimax.Config{APIKey: "k", GroupID: "g"}
body, _ := captureMiniMaxBody(t, cfg, audio.TTSOptions{
Params: map[string]any{"language_boost": "Chinese"},
})
if lb, _ := body["language_boost"].(string); lb != "Chinese" {
t.Errorf("language_boost: got %q, want Chinese", body["language_boost"])
}
}
// TestSynthesize_AppliesParams_SubtitleEnable verifies subtitle_enable top-level field.
func TestSynthesize_AppliesParams_SubtitleEnable(t *testing.T) {
cfg := minimax.Config{APIKey: "k", GroupID: "g"}
body, _ := captureMiniMaxBody(t, cfg, audio.TTSOptions{
Params: map[string]any{"subtitle_enable": true},
})
if se, _ := body["subtitle_enable"].(bool); !se {
t.Errorf("subtitle_enable: got %v, want true", body["subtitle_enable"])
}
}
// TestSynthesize_AppliesParams_PronunciationDict verifies that a valid JSON array
// is wrapped in {"tone":[...]} in the request body.
func TestSynthesize_AppliesParams_PronunciationDict(t *testing.T) {
cfg := minimax.Config{APIKey: "k", GroupID: "g"}
body, _ := captureMiniMaxBody(t, cfg, audio.TTSOptions{
Params: map[string]any{"pronunciation_dict": `["omg/Oh my god"]`},
})
pd, ok := body["pronunciation_dict"].(map[string]any)
if !ok {
t.Fatalf("pronunciation_dict: expected object, got %T (%v)", body["pronunciation_dict"], body["pronunciation_dict"])
}
tone, ok := pd["tone"].([]any)
if !ok {
t.Fatalf("pronunciation_dict.tone: expected array, got %T", pd["tone"])
}
if len(tone) != 1 {
t.Fatalf("pronunciation_dict.tone: want 1 entry, got %d", len(tone))
}
if tone[0] != "omg/Oh my god" {
t.Errorf("pronunciation_dict.tone[0]: got %q, want omg/Oh my god", tone[0])
}
}
// TestSynthesize_PronunciationDict_InvalidJSON verifies that malformed JSON is
// silently omitted (synth continues) and the field is absent from the body.
func TestSynthesize_PronunciationDict_InvalidJSON(t *testing.T) {
cfg := minimax.Config{APIKey: "k", GroupID: "g"}
body, _ := captureMiniMaxBody(t, cfg, audio.TTSOptions{
Params: map[string]any{"pronunciation_dict": `not valid json`},
})
if _, ok := body["pronunciation_dict"]; ok {
t.Error("pronunciation_dict: must be omitted when JSON is invalid")
}
}
// TestSynthesize_PronunciationDict_ExceedsLimit verifies that a value exceeding
// 8 KB is silently omitted (Finding #6).
func TestSynthesize_PronunciationDict_ExceedsLimit(t *testing.T) {
cfg := minimax.Config{APIKey: "k", GroupID: "g"}
// Build a string > 8192 bytes.
large := make([]byte, 9000)
for i := range large {
large[i] = 'a'
}
body, _ := captureMiniMaxBody(t, cfg, audio.TTSOptions{
Params: map[string]any{"pronunciation_dict": string(large)},
})
if _, ok := body["pronunciation_dict"]; ok {
t.Error("pronunciation_dict: must be omitted when value exceeds 8 KB limit")
}
}
func TestSynthesize_DoesNotMutateCallerParams(t *testing.T) {
cfg := minimax.Config{APIKey: "k", GroupID: "g"}
original := map[string]any{
"speed": 1.0,
"sentinel": "untouched",
}
snapshot := map[string]any{
"speed": original["speed"],
"sentinel": original["sentinel"],
}
captureMiniMaxBody(t, cfg, audio.TTSOptions{Params: original})
for k, want := range snapshot {
if got := original[k]; got != want {
t.Errorf("caller Params mutated: key %q was %v, now %v", k, want, got)
}
}
}
func TestCapabilities_HasParam_Speed(t *testing.T) {
p := minimax.NewProvider(minimax.Config{APIKey: "k"})
caps := p.Capabilities()
for _, param := range caps.Params {
if param.Key == "speed" {
if param.Default != 1.0 {
t.Errorf("speed default: got %v, want 1.0", param.Default)
}
return
}
}
t.Error("speed param not found")
}
func TestCapabilities_DependsOn_MiniMaxBitrate(t *testing.T) {
p := minimax.NewProvider(minimax.Config{APIKey: "k"})
caps := p.Capabilities()
for _, param := range caps.Params {
if param.Key == "audio.bitrate" {
if len(param.DependsOn) == 0 {
t.Fatal("audio.bitrate must have DependsOn")
}
dep := param.DependsOn[0]
if dep.Field != "audio.format" {
t.Errorf("DependsOn.Field: got %q, want audio.format", dep.Field)
}
if dep.Value != "mp3" {
t.Errorf("DependsOn.Value: got %v, want mp3", dep.Value)
}
return
}
}
t.Error("audio.bitrate param not found")
}
func TestCapabilities_VoicesDynamic(t *testing.T) {
p := minimax.NewProvider(minimax.Config{APIKey: "k"})
caps := p.Capabilities()
if caps.CustomFeatures == nil {
t.Fatal("CustomFeatures must not be nil")
}
if dyn, _ := caps.CustomFeatures["voices_dynamic"].(bool); !dyn {
t.Error("CustomFeatures.voices_dynamic must be true")
}
if len(caps.Voices) != 0 {
t.Error("Voices must be nil/empty for dynamic provider")
}
}