mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 14:16:08 +00:00
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.
231 lines
7.6 KiB
Go
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")
|
|
}
|
|
}
|