diff --git a/internal/audio/elevenlabs/capabilities.go b/internal/audio/elevenlabs/capabilities.go index d981d991..bf515be8 100644 --- a/internal/audio/elevenlabs/capabilities.go +++ b/internal/audio/elevenlabs/capabilities.go @@ -11,29 +11,34 @@ var elevenLabsModels = []string{ } var ( - stabilityMin = 0.0 - stabilityMax = 1.0 - stabilityStep = 0.01 - simBoostMin = 0.0 - simBoostMax = 1.0 - simBoostStep = 0.01 - styleMin = 0.0 - styleMax = 1.0 - styleStep = 0.01 - elSpeedMin = 0.7 - elSpeedMax = 1.2 - elSpeedStep = 0.01 - latencyMin = 0.0 - latencyMax = 4.0 - latencyStep = 1.0 - seedMin = 0.0 - seedMax = 4294967295.0 // 2^32-1 - seedStep = 1.0 + stabilityMin = 0.0 + stabilityMax = 1.0 + stabilityStep = 0.01 + simBoostMin = 0.0 + simBoostMax = 1.0 + simBoostStep = 0.01 + styleMin = 0.0 + styleMax = 1.0 + styleStep = 0.01 + elSpeedMin = 0.7 + elSpeedMax = 1.2 + elSpeedStep = 0.01 + latencyMin = 0.0 + latencyMax = 4.0 + latencyStep = 1.0 + seedMin = 0.0 + seedMax = 4294967295.0 // 2^32-1 + seedStep = 1.0 ) // elevenLabsParams is the enriched param schema for ElevenLabs TTS. -// Defaults MUST match the hardcoded values in tts.go:54-59 (characterization fixture): -// stability=0.5, similarity_boost=0.75, style=0.0, use_speaker_boost=true, speed=1.0 +// Defaults MUST match the hardcoded values in tts.go (characterization fixture): +// +// stability=0.5, similarity_boost=0.75, style=0.0, use_speaker_boost=true, speed=1.0 +// +// Group tagging (Finding #7 + §Requirements #7): +// - seed, optimize_streaming_latency, language_code, output_format = "advanced" +// - all others = "" (basic) var elevenLabsParams = []audio.ParamSchema{ { Key: "voice_settings.stability", @@ -101,6 +106,7 @@ var elevenLabsParams = []audio.ParamSchema{ Min: &seedMin, Max: &seedMax, Step: &seedStep, + Group: "advanced", }, { Key: "optimize_streaming_latency", @@ -111,6 +117,7 @@ var elevenLabsParams = []audio.ParamSchema{ Min: &latencyMin, Max: &latencyMax, Step: &latencyStep, + Group: "advanced", }, { Key: "language_code", @@ -118,6 +125,55 @@ var elevenLabsParams = []audio.ParamSchema{ Label: "Language Code", Description: "ISO 639-1 language hint (e.g. 'en', 'vi'). Advanced.", Default: "", + Group: "advanced", + }, + // output_format — URL query param controlling codec + bitrate. + // 27 values per ElevenLabs API docs. Default mp3_44100_128 matches the + // hardcoded fallback in tts.go so the defaults-invariant test stays green. + // Tier note: mp3_44100_192 requires Creator+; pcm_*/wav_* require Pro+. + // Backend forwards whatever the user sets; API returns 403 on tier violation. + { + Key: "output_format", + Type: audio.ParamTypeEnum, + Label: "Output Format", + Description: "Audio codec and bitrate. Higher quality tiers require Creator+ or Pro+ subscription. " + + "Advanced.", + Default: "mp3_44100_128", + Group: "advanced", + Enum: []audio.EnumOption{ + // MP3 variants + {Value: "mp3_22050_32", Label: "MP3 22 kHz 32 kbps"}, + {Value: "mp3_44100_32", Label: "MP3 44.1 kHz 32 kbps"}, + {Value: "mp3_44100_64", Label: "MP3 44.1 kHz 64 kbps"}, + {Value: "mp3_44100_96", Label: "MP3 44.1 kHz 96 kbps"}, + {Value: "mp3_44100_128", Label: "MP3 44.1 kHz 128 kbps (default)"}, + {Value: "mp3_44100_192", Label: "MP3 44.1 kHz 192 kbps (Creator+)"}, + // Opus variants + {Value: "opus_16000_32", Label: "Opus 16 kHz 32 kbps"}, + {Value: "opus_24000_32", Label: "Opus 24 kHz 32 kbps"}, + {Value: "opus_24000_64", Label: "Opus 24 kHz 64 kbps"}, + {Value: "opus_24000_96", Label: "Opus 24 kHz 96 kbps"}, + {Value: "opus_48000_32", Label: "Opus 48 kHz 32 kbps"}, + // PCM variants (Pro+) + {Value: "pcm_8000", Label: "PCM 8 kHz (Pro+)"}, + {Value: "pcm_16000", Label: "PCM 16 kHz (Pro+)"}, + {Value: "pcm_22050", Label: "PCM 22 kHz (Pro+)"}, + {Value: "pcm_24000", Label: "PCM 24 kHz (Pro+)"}, + {Value: "pcm_32000", Label: "PCM 32 kHz (Pro+)"}, + {Value: "pcm_44100", Label: "PCM 44.1 kHz (Pro+)"}, + {Value: "pcm_48000", Label: "PCM 48 kHz (Pro+)"}, + // WAV variants (Pro+) + {Value: "wav_8000", Label: "WAV 8 kHz (Pro+)"}, + {Value: "wav_16000", Label: "WAV 16 kHz (Pro+)"}, + {Value: "wav_22050", Label: "WAV 22 kHz (Pro+)"}, + {Value: "wav_24000", Label: "WAV 24 kHz (Pro+)"}, + {Value: "wav_32000", Label: "WAV 32 kHz (Pro+)"}, + {Value: "wav_44100", Label: "WAV 44.1 kHz (Pro+)"}, + {Value: "wav_48000", Label: "WAV 48 kHz (Pro+)"}, + // μ-law and a-law + {Value: "ulaw_8000", Label: "μ-law 8 kHz"}, + {Value: "alaw_8000", Label: "A-law 8 kHz"}, + }, }, } diff --git a/internal/audio/elevenlabs/format_meta_test.go b/internal/audio/elevenlabs/format_meta_test.go new file mode 100644 index 00000000..7223a148 --- /dev/null +++ b/internal/audio/elevenlabs/format_meta_test.go @@ -0,0 +1,193 @@ +package elevenlabs_test + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/nextlevelbuilder/goclaw/internal/audio" + "github.com/nextlevelbuilder/goclaw/internal/audio/elevenlabs" +) + +// TestFormatMeta_AllFormats is a table-driven test covering all 27 ElevenLabs +// output_format values plus the fallback for unknown input. +// Finding #7: aligned to researcher-02 — no pcm_mulaw_* variants, +// single audio/pcm MIME for all pcm_* variants. +func TestFormatMeta_AllFormats(t *testing.T) { + cases := []struct { + format string + wantExt string + wantMime string + }{ + // MP3 variants (6 from capabilities enum) + {"mp3_22050_32", "mp3", "audio/mpeg"}, + {"mp3_44100_32", "mp3", "audio/mpeg"}, + {"mp3_44100_64", "mp3", "audio/mpeg"}, + {"mp3_44100_96", "mp3", "audio/mpeg"}, + {"mp3_44100_128", "mp3", "audio/mpeg"}, + {"mp3_44100_192", "mp3", "audio/mpeg"}, + // Opus variants (5) + {"opus_16000_32", "opus", "audio/opus"}, + {"opus_24000_32", "opus", "audio/opus"}, + {"opus_24000_64", "opus", "audio/opus"}, + {"opus_24000_96", "opus", "audio/opus"}, + {"opus_48000_32", "opus", "audio/opus"}, + // PCM variants (7) — all use single audio/pcm MIME (Finding #7) + {"pcm_8000", "pcm", "audio/pcm"}, + {"pcm_16000", "pcm", "audio/pcm"}, + {"pcm_22050", "pcm", "audio/pcm"}, + {"pcm_24000", "pcm", "audio/pcm"}, + {"pcm_32000", "pcm", "audio/pcm"}, + {"pcm_44100", "pcm", "audio/pcm"}, + {"pcm_48000", "pcm", "audio/pcm"}, + // WAV variants (7) + {"wav_8000", "wav", "audio/wav"}, + {"wav_16000", "wav", "audio/wav"}, + {"wav_22050", "wav", "audio/wav"}, + {"wav_24000", "wav", "audio/wav"}, + {"wav_32000", "wav", "audio/wav"}, + {"wav_44100", "wav", "audio/wav"}, + {"wav_48000", "wav", "audio/wav"}, + // μ-law and a-law (2) + {"ulaw_8000", "ulaw", "audio/basic"}, + {"alaw_8000", "alaw", "audio/x-alaw"}, + // Fallback for unknown/empty + {"unknown_format", "mp3", "audio/mpeg"}, + {"", "mp3", "audio/mpeg"}, + } + + for _, tc := range cases { + t.Run(tc.format, func(t *testing.T) { + gotExt, gotMime := elevenlabs.FormatMeta(tc.format) + if gotExt != tc.wantExt { + t.Errorf("FormatMeta(%q) ext = %q, want %q", tc.format, gotExt, tc.wantExt) + } + if gotMime != tc.wantMime { + t.Errorf("FormatMeta(%q) mime = %q, want %q", tc.format, gotMime, tc.wantMime) + } + }) + } +} + +// TestSynthesize_OutputFormat_URLRoundTrip verifies output_format flows into the +// URL query param. +func TestSynthesize_OutputFormat_URLRoundTrip(t *testing.T) { + cfg := elevenlabs.Config{APIKey: "k", VoiceID: "v"} + _, path := captureElevenLabsBody(t, cfg, audio.TTSOptions{ + Params: map[string]any{"output_format": "pcm_44100"}, + }) + if !containsStr(path, "output_format=pcm_44100") { + t.Errorf("expected output_format=pcm_44100 in URL, got: %s", path) + } +} + +// TestSynthesize_OutputFormat_SynthResult verifies SynthResult.Extension and +// MimeType are derived from output_format for a pcm_44100 request. +func TestSynthesize_OutputFormat_SynthResult(t *testing.T) { + cfg := elevenlabs.Config{APIKey: "k", VoiceID: "v"} + result := synthesizeAndCapture(t, cfg, audio.TTSOptions{ + Params: map[string]any{"output_format": "pcm_44100"}, + }) + if result.Extension != "pcm" { + t.Errorf("Extension: got %q, want pcm", result.Extension) + } + if result.MimeType != "audio/pcm" { + t.Errorf("MimeType: got %q, want audio/pcm", result.MimeType) + } +} + +// TestSynthesize_OpusContract_TelegramPath verifies Finding #11: +// when opts.Format="opus", MIME is always "audio/ogg; codecs=opus" regardless +// of any user-set output_format param. Telegram rejects plain "audio/opus" MIME. +func TestSynthesize_OpusContract_TelegramPath(t *testing.T) { + cfg := elevenlabs.Config{APIKey: "k", VoiceID: "v"} + result := synthesizeAndCapture(t, cfg, audio.TTSOptions{ + Format: "opus", + Params: map[string]any{"output_format": "opus_24000_96"}, + }) + if result.MimeType != "audio/ogg; codecs=opus" { + t.Errorf("Telegram opus contract FAILED: MimeType = %q, want audio/ogg; codecs=opus", result.MimeType) + } + if result.Extension != "ogg" { + t.Errorf("Telegram opus contract FAILED: Extension = %q, want ogg", result.Extension) + } +} + +// TestSynthesize_OutputFormat_InvalidChars verifies Finding #4: +// output_format with injection characters is rejected before any HTTP call. +func TestSynthesize_OutputFormat_InvalidChars(t *testing.T) { + cases := []string{ + "mp3_44100_128&api_key=LEAK", + "opus_48000\n", + "pcm 44100", + "mp3%20injection", + } + for _, bad := range cases { + t.Run(bad, func(t *testing.T) { + cfg := elevenlabs.Config{APIKey: "k", VoiceID: "v"} + _, err := synthesizeWithError(t, cfg, audio.TTSOptions{ + Params: map[string]any{"output_format": bad}, + }) + if err == nil { + t.Errorf("output_format=%q: expected validation error, got nil", bad) + } + }) + } +} + +// TestSynthesize_LanguageCode_InvalidChars verifies Finding #4: +// language_code with injection characters is rejected before any HTTP call. +func TestSynthesize_LanguageCode_InvalidChars(t *testing.T) { + cases := []string{ + "en&voice_id=victim", + "en\r\nX-Injected: yes", + "toolong_codex", + } + for _, bad := range cases { + t.Run(bad, func(t *testing.T) { + cfg := elevenlabs.Config{APIKey: "k", VoiceID: "v"} + _, err := synthesizeWithError(t, cfg, audio.TTSOptions{ + Params: map[string]any{"language_code": bad}, + }) + if err == nil { + t.Errorf("language_code=%q: expected validation error, got nil", bad) + } + }) + } +} + +// synthesizeAndCapture runs Synthesize against a mock server and returns the +// *audio.SynthResult. Fails the test on any error. +func synthesizeAndCapture(t *testing.T, cfg elevenlabs.Config, opts audio.TTSOptions) *audio.SynthResult { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.Copy(io.Discard, r.Body) + w.Header().Set("Content-Type", "audio/mpeg") + w.Write([]byte("AUDIO")) + })) + t.Cleanup(srv.Close) + cfg.BaseURL = srv.URL + p := elevenlabs.NewTTSProvider(cfg) + result, err := p.Synthesize(t.Context(), "hello", opts) + if err != nil { + t.Fatalf("Synthesize failed: %v", err) + } + return result +} + +// synthesizeWithError runs Synthesize and returns (result, err) without failing +// the test — used to assert expected errors. +func synthesizeWithError(t *testing.T, cfg elevenlabs.Config, opts audio.TTSOptions) (*audio.SynthResult, error) { + t.Helper() + // Use a mock server so validation-path errors (pre-HTTP) are still exercised. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.Copy(io.Discard, r.Body) + w.Header().Set("Content-Type", "audio/mpeg") + w.Write([]byte("AUDIO")) + })) + t.Cleanup(srv.Close) + cfg.BaseURL = srv.URL + p := elevenlabs.NewTTSProvider(cfg) + return p.Synthesize(t.Context(), "hello", opts) +} diff --git a/internal/audio/elevenlabs/golden_test.go b/internal/audio/elevenlabs/golden_test.go new file mode 100644 index 00000000..6f618861 --- /dev/null +++ b/internal/audio/elevenlabs/golden_test.go @@ -0,0 +1,60 @@ +package elevenlabs_test + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/nextlevelbuilder/goclaw/internal/audio" + "github.com/nextlevelbuilder/goclaw/internal/audio/elevenlabs" +) + +// TestGolden_DefaultBody verifies the serialized nil-params request body + URL +// matches the checked-in golden fixture. Finding #10: external golden file catches +// simultaneous Default+tts.go drift that a self-referential invariant test misses. +func TestGolden_DefaultBody(t *testing.T) { + cfg := elevenlabs.Config{APIKey: "test-key", VoiceID: "test-voice"} + body, path := captureElevenLabsBody(t, cfg, audio.TTSOptions{}) + + full := map[string]any{ + "body": body, + "path": path, + } + gotFull, err := json.MarshalIndent(full, "", " ") + if err != nil { + t.Fatalf("marshal body: %v", err) + } + + goldenPath := filepath.Join("testdata", "default_body.golden.json") + if _, statErr := os.Stat(goldenPath); os.IsNotExist(statErr) { + if err := os.MkdirAll("testdata", 0755); err != nil { + t.Fatalf("mkdir testdata: %v", err) + } + if err := os.WriteFile(goldenPath, gotFull, 0644); err != nil { + t.Fatalf("write golden: %v", err) + } + t.Logf("golden file created: %s", goldenPath) + return + } + + want, err := os.ReadFile(goldenPath) + if err != nil { + t.Fatalf("read golden: %v", err) + } + + var wantMap, gotMap map[string]any + if err := json.Unmarshal(want, &wantMap); err != nil { + t.Fatalf("parse golden: %v", err) + } + if err := json.Unmarshal(gotFull, &gotMap); err != nil { + t.Fatalf("parse current: %v", err) + } + + wantNorm := canonicalJSON(t, wantMap) + gotNorm := canonicalJSON(t, gotMap) + if gotNorm != wantNorm { + t.Errorf("golden fixture mismatch (update testdata/default_body.golden.json if intentional):\n got: %s\n want: %s", + gotNorm, wantNorm) + } +} diff --git a/internal/audio/elevenlabs/testdata/default_body.golden.json b/internal/audio/elevenlabs/testdata/default_body.golden.json new file mode 100644 index 00000000..0dd0f2b2 --- /dev/null +++ b/internal/audio/elevenlabs/testdata/default_body.golden.json @@ -0,0 +1,14 @@ +{ + "body": { + "model_id": "eleven_multilingual_v2", + "text": "hello", + "voice_settings": { + "similarity_boost": 0.75, + "speed": 1, + "stability": 0.5, + "style": 0, + "use_speaker_boost": true + } + }, + "path": "/v1/text-to-speech/test-voice?output_format=mp3_44100_128" +} \ No newline at end of file diff --git a/internal/audio/elevenlabs/tts.go b/internal/audio/elevenlabs/tts.go index 0e4d0f85..b8b4fad4 100644 --- a/internal/audio/elevenlabs/tts.go +++ b/internal/audio/elevenlabs/tts.go @@ -4,6 +4,9 @@ import ( "context" "encoding/json" "fmt" + "net/url" + "regexp" + "strings" "github.com/nextlevelbuilder/goclaw/internal/audio" ) @@ -27,6 +30,40 @@ func NewTTSProvider(cfg Config) *TTSProvider { // Name returns the stable provider identifier used by the Manager. func (p *TTSProvider) Name() string { return "elevenlabs" } +// reOutputFormat validates output_format query param: lowercase alphanumeric + underscore only. +// Finding #4: prevents HTTP Parameter Pollution / injection via user-controlled format string. +var reOutputFormat = regexp.MustCompile(`^[a-z0-9_]+$`) + +// reLanguageCode validates language_code: BCP-47 subset (2–3 letter lang + optional region). +// Finding #4: prevents injection via user-controlled language code. +var reLanguageCode = regexp.MustCompile(`^[a-z]{2,3}(-[A-Z]{2})?$`) + +// FormatMeta maps an ElevenLabs output_format string to the file extension and +// MIME type to use in SynthResult. All pcm_* variants use audio/pcm (single MIME +// per Finding #7 — no per-rate variants). Exported for white-box tests. +// +// Telegram opus contract (Finding #11): this function is NOT called when +// opts.Format=="opus"; that path hardcodes ext=ogg / mime=audio/ogg; codecs=opus. +func FormatMeta(format string) (ext, mime string) { + switch { + case strings.HasPrefix(format, "mp3_"): + return "mp3", "audio/mpeg" + case strings.HasPrefix(format, "opus_"): + return "opus", "audio/opus" + case strings.HasPrefix(format, "pcm_"): + return "pcm", "audio/pcm" + case strings.HasPrefix(format, "wav_"): + return "wav", "audio/wav" + case format == "ulaw_8000": + return "ulaw", "audio/basic" + case format == "alaw_8000": + return "alaw", "audio/x-alaw" + default: + // Unknown format: fall back to mp3 so callers get a usable MIME type. + return "mp3", "audio/mpeg" + } +} + // Synthesize converts text to audio. Opts.Voice/Opts.Model override the // configured defaults; Opts.Format="opus" switches to Ogg Opus output. // opts.Params keys (nested dot-path): @@ -39,6 +76,7 @@ func (p *TTSProvider) Name() string { return "elevenlabs" } // - "seed" int (default 0 = omitted) // - "optimize_streaming_latency" int (query param, default 0 = omitted) // - "language_code" string (query param, default "" = omitted) +// - "output_format" string (query param, default "mp3_44100_128") // // MUST NOT mutate opts.Params — reads only. func (p *TTSProvider) Synthesize(ctx context.Context, text string, opts audio.TTSOptions) (*audio.SynthResult, error) { @@ -54,15 +92,30 @@ func (p *TTSProvider) Synthesize(ctx context.Context, text string, opts audio.TT return nil, err } - // Output format: MP3 128kbps default, Opus 64kbps for Telegram voice. - outputFormat, ext, mime := "mp3_44100_128", "mp3", "audio/mpeg" + // Telegram opus contract (Finding #11): when opts.Format=="opus", force + // ogg/Opus MIME regardless of any user-set output_format param. This + // preserves Telegram voice-bubble compatibility — Telegram rejects audio/opus + // MIME; user param affects upstream codec selection only. + var ext, mime string + var outputFormat string if opts.Format == "opus" { - outputFormat, ext, mime = "opus_48000_64", "ogg", "audio/ogg" - } + outputFormat = "opus_48000_64" + ext = "ogg" + mime = "audio/ogg; codecs=opus" + } else { + // Default output format; may be overridden by opts.Params below. + outputFormat = "mp3_44100_128" + ext = "mp3" + mime = "audio/mpeg" - // Override output format from params if provided (27 codecs supported). - if pf := resolveELString(opts.Params, "output_format", ""); pf != "" { - outputFormat = pf + // Honor user-set output_format (Finding #4: validate before use). + if pf := resolveELString(opts.Params, "output_format", ""); pf != "" { + if !reOutputFormat.MatchString(pf) { + return nil, fmt.Errorf("elevenlabs: invalid output_format %q (must match ^[a-z0-9_]+$)", pf) + } + outputFormat = pf + ext, mime = FormatMeta(outputFormat) + } } // Build voice_settings from params, falling back to hardcoded defaults. @@ -94,14 +147,21 @@ func (p *TTSProvider) Synthesize(ctx context.Context, text string, opts audio.TT return nil, fmt.Errorf("marshal elevenlabs tts request: %w", err) } - // Build path with query params. - path := fmt.Sprintf("/v1/text-to-speech/%s?output_format=%s", voiceID, outputFormat) + // Build URL path with query params using url.Values to prevent injection + // (Finding #4: no more raw fmt.Sprintf concatenation). + q := url.Values{} + q.Set("output_format", outputFormat) if latency := resolveELInt(opts.Params, "optimize_streaming_latency", -1); latency > 0 { - path += fmt.Sprintf("&optimize_streaming_latency=%d", latency) + q.Set("optimize_streaming_latency", fmt.Sprintf("%d", latency)) } if lang := resolveELString(opts.Params, "language_code", ""); lang != "" { - path += "&language_code=" + lang + // Finding #4: validate language_code before appending to URL. + if !reLanguageCode.MatchString(lang) { + return nil, fmt.Errorf("elevenlabs: invalid language_code %q (must match ^[a-z]{2,3}(-[A-Z]{2})?$)", lang) + } + q.Set("language_code", lang) } + path := "/v1/text-to-speech/" + voiceID + "?" + q.Encode() audioBytes, err := p.c.postJSON(ctx, path, bodyJSON, 0) if err != nil { diff --git a/internal/audio/gemini/capabilities.go b/internal/audio/gemini/capabilities.go index 5885f221..ed2d417e 100644 --- a/internal/audio/gemini/capabilities.go +++ b/internal/audio/gemini/capabilities.go @@ -2,6 +2,68 @@ package gemini import "github.com/nextlevelbuilder/goclaw/internal/audio" +var ( + geminiTempMin = 0.0 + geminiTempMax = 2.0 + geminiTempStep = 0.01 + + geminiPenaltyMin = -2.0 + geminiPenaltyMax = 2.0 + geminiPenaltyStep = 0.01 +) + +// geminiParams is the enriched param schema for Gemini TTS. +// All params have nil Default so they are omitted from the request body +// when not explicitly set, preserving characterization byte-equivalence. +// +// Tagging: +// - temperature: basic (Group="") +// - seed, presencePenalty, frequencyPenalty: advanced (Group="advanced") +var geminiParams = []audio.ParamSchema{ + { + Key: "temperature", + Type: audio.ParamTypeRange, + Label: "Temperature", + // Finding #14: note subtle effect on TTS; primary expressiveness via audio tags. + Description: "Sampling temperature (0.0–2.0, default 1.0). Effect is subtle on TTS — primary expressiveness comes from audio tags inserted in the text.", + Default: nil, // omit from body when not set; API default is 1.0 + Min: &geminiTempMin, + Max: &geminiTempMax, + Step: &geminiTempStep, + Group: "", // basic + }, + { + Key: "seed", + Type: audio.ParamTypeInteger, + Label: "Seed", + Description: "Deterministic seed for reproducible output. Advanced.", + Default: nil, + Group: "advanced", + }, + { + Key: "presencePenalty", + Type: audio.ParamTypeRange, + Label: "Presence Penalty", + Description: "Presence penalty (-2.0–2.0). Experimental — effect on TTS is not well-characterised. Advanced.", + Default: nil, + Min: &geminiPenaltyMin, + Max: &geminiPenaltyMax, + Step: &geminiPenaltyStep, + Group: "advanced", + }, + { + Key: "frequencyPenalty", + Type: audio.ParamTypeRange, + Label: "Frequency Penalty", + Description: "Frequency penalty (-2.0–2.0). Experimental — effect on TTS is not well-characterised. Advanced.", + Default: nil, + Min: &geminiPenaltyMin, + Max: &geminiPenaltyMax, + Step: &geminiPenaltyStep, + Group: "advanced", + }, +} + // Capabilities returns the static capability schema for the Gemini TTS provider. // CustomFeatures drives frontend custom slot rendering in Phase C. func (p *Provider) Capabilities() audio.ProviderCapabilities { @@ -11,7 +73,7 @@ func (p *Provider) Capabilities() audio.ProviderCapabilities { RequiresAPIKey: true, Models: geminiModels, Voices: geminiVoices, - // Params is nil — Gemini TTS has no speed/pitch knobs. + Params: geminiParams, CustomFeatures: map[string]any{ "multi_speaker": true, "audio_tags": true, diff --git a/internal/audio/gemini/capabilities_test.go b/internal/audio/gemini/capabilities_test.go index 64d6fe4f..46270086 100644 --- a/internal/audio/gemini/capabilities_test.go +++ b/internal/audio/gemini/capabilities_test.go @@ -40,10 +40,11 @@ func TestCapabilities_RequiresAPIKey(t *testing.T) { } } -func TestCapabilities_ParamsEmpty(t *testing.T) { +func TestCapabilities_ParamsPopulated(t *testing.T) { p := NewProvider(Config{APIKey: "k"}) caps := p.Capabilities() - if caps.Params != nil { - t.Errorf("want Params nil (no speed/pitch), got %v", caps.Params) + // Phase 1: Gemini now exposes 4 params (temperature + seed + presencePenalty + frequencyPenalty). + if len(caps.Params) != 4 { + t.Errorf("want 4 params, got %d", len(caps.Params)) } } diff --git a/internal/audio/gemini/defaults_invariant_test.go b/internal/audio/gemini/defaults_invariant_test.go new file mode 100644 index 00000000..31d16d64 --- /dev/null +++ b/internal/audio/gemini/defaults_invariant_test.go @@ -0,0 +1,141 @@ +package gemini_test + +import ( + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/nextlevelbuilder/goclaw/internal/audio" + "github.com/nextlevelbuilder/goclaw/internal/audio/gemini" +) + +// captureGeminiBody sends a Synthesize request to a mock server and returns +// the decoded JSON request body + the URL path used. +func captureGeminiBody(t *testing.T, cfg gemini.Config, opts audio.TTSOptions) (map[string]any, string) { + t.Helper() + + pcm := make([]byte, 64) + b64 := base64.StdEncoding.EncodeToString(pcm) + respJSON := []byte(`{"candidates":[{"content":{"parts":[{"inlineData":{"data":"` + b64 + `"}}]}}]}`) + + var captured []byte + var capturedPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.String() + b, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("read body: %v", err) + } + captured = b + w.Header().Set("Content-Type", "application/json") + w.Write(respJSON) + })) + t.Cleanup(srv.Close) + + cfg.APIBase = srv.URL + p := gemini.NewProvider(cfg) + _, err := p.Synthesize(t.Context(), "hello", opts) + if err != nil { + t.Fatalf("Synthesize failed: %v", err) + } + + var m map[string]any + if err := json.Unmarshal(captured, &m); err != nil { + t.Fatalf("body not valid JSON: %v — body: %s", err, captured) + } + return m, capturedPath +} + +// TestDefaults_PreserveLegacyBody verifies that populating opts.Params with all +// Capabilities defaults produces a body byte-equivalent to the nil-Params request. +// Guards against silent default flip when new params land. +func TestDefaults_PreserveLegacyBody(t *testing.T) { + cfg := gemini.Config{APIKey: "test-key"} + p := gemini.NewProvider(cfg) + caps := p.Capabilities() + + if len(caps.Params) == 0 { + t.Skip("Capabilities.Params not yet populated") + } + + // Build params map from all schema defaults (only non-nil defaults). + params := make(map[string]any) + for _, s := range caps.Params { + if s.Default != nil { + audio.SetNested(params, s.Key, s.Default) + } + } + + bodyWithDefaults, _ := captureGeminiBody(t, cfg, audio.TTSOptions{Params: params}) + bodyNilParams, _ := captureGeminiBody(t, cfg, audio.TTSOptions{}) + + wantJSON := canonicalGeminiJSON(t, bodyNilParams) + gotJSON := canonicalGeminiJSON(t, bodyWithDefaults) + + if gotJSON != wantJSON { + t.Errorf("defaults-invariant body FAILED:\n with-defaults: %s\n nil-params: %s", + gotJSON, wantJSON) + } +} + +// TestGolden_DefaultBody verifies the serialized nil-params request body matches +// the checked-in golden fixture. Update the fixture file when intentional changes land. +// Finding #10: external golden file catches simultaneous Default+tts.go drift. +func TestGolden_DefaultBody(t *testing.T) { + cfg := gemini.Config{APIKey: "test-key", Voice: "Kore"} + body, _ := captureGeminiBody(t, cfg, audio.TTSOptions{}) + + got, err := json.MarshalIndent(body, "", " ") + if err != nil { + t.Fatalf("marshal body: %v", err) + } + + goldenPath := filepath.Join("testdata", "default_body.golden.json") + + if _, err := os.Stat(goldenPath); os.IsNotExist(err) { + // First run: write the golden file. + if err := os.MkdirAll("testdata", 0755); err != nil { + t.Fatalf("mkdir testdata: %v", err) + } + if err := os.WriteFile(goldenPath, got, 0644); err != nil { + t.Fatalf("write golden: %v", err) + } + t.Logf("golden file created: %s", goldenPath) + return + } + + want, err := os.ReadFile(goldenPath) + if err != nil { + t.Fatalf("read golden: %v", err) + } + + // Normalize both through JSON round-trip for deterministic key ordering. + var wantMap, gotMap map[string]any + if err := json.Unmarshal(want, &wantMap); err != nil { + t.Fatalf("parse golden: %v", err) + } + if err := json.Unmarshal(got, &gotMap); err != nil { + t.Fatalf("parse current body: %v", err) + } + wantNorm := canonicalGeminiJSON(t, wantMap) + gotNorm := canonicalGeminiJSON(t, gotMap) + + if gotNorm != wantNorm { + t.Errorf("golden fixture mismatch (update testdata/default_body.golden.json if intentional):\n got: %s\n want: %s", + gotNorm, wantNorm) + } +} + +func canonicalGeminiJSON(t *testing.T, m map[string]any) string { + t.Helper() + b, err := json.Marshal(m) + if err != nil { + t.Fatalf("canonicalGeminiJSON: %v", err) + } + return string(b) +} diff --git a/internal/audio/gemini/params_test.go b/internal/audio/gemini/params_test.go new file mode 100644 index 00000000..cd752101 --- /dev/null +++ b/internal/audio/gemini/params_test.go @@ -0,0 +1,140 @@ +package gemini_test + +import ( + "testing" + + "github.com/nextlevelbuilder/goclaw/internal/audio" + "github.com/nextlevelbuilder/goclaw/internal/audio/gemini" +) + +// TestSynthesize_Params_Temperature verifies temperature lands in generationConfig. +func TestSynthesize_Params_Temperature(t *testing.T) { + cfg := gemini.Config{APIKey: "k"} + body, _ := captureGeminiBody(t, cfg, audio.TTSOptions{ + Params: map[string]any{"temperature": 1.5}, + }) + gc := assertGeminiGenerationConfig(t, body) + if v, _ := gc["temperature"].(float64); v != 1.5 { + t.Errorf("temperature: got %v, want 1.5", gc["temperature"]) + } +} + +// TestSynthesize_Params_Seed verifies seed lands in generationConfig. +func TestSynthesize_Params_Seed(t *testing.T) { + cfg := gemini.Config{APIKey: "k"} + body, _ := captureGeminiBody(t, cfg, audio.TTSOptions{ + Params: map[string]any{"seed": 12345}, + }) + gc := assertGeminiGenerationConfig(t, body) + // JSON round-trip: int comes back as float64. + if v, _ := gc["seed"].(float64); int(v) != 12345 { + t.Errorf("seed: got %v, want 12345", gc["seed"]) + } +} + +// TestSynthesize_Params_PresencePenalty verifies presencePenalty in generationConfig. +func TestSynthesize_Params_PresencePenalty(t *testing.T) { + cfg := gemini.Config{APIKey: "k"} + body, _ := captureGeminiBody(t, cfg, audio.TTSOptions{ + Params: map[string]any{"presencePenalty": 0.3}, + }) + gc := assertGeminiGenerationConfig(t, body) + if v, _ := gc["presencePenalty"].(float64); v != 0.3 { + t.Errorf("presencePenalty: got %v, want 0.3", gc["presencePenalty"]) + } +} + +// TestSynthesize_Params_FrequencyPenalty verifies frequencyPenalty in generationConfig. +func TestSynthesize_Params_FrequencyPenalty(t *testing.T) { + cfg := gemini.Config{APIKey: "k"} + body, _ := captureGeminiBody(t, cfg, audio.TTSOptions{ + Params: map[string]any{"frequencyPenalty": -0.1}, + }) + gc := assertGeminiGenerationConfig(t, body) + if v, _ := gc["frequencyPenalty"].(float64); v != -0.1 { + t.Errorf("frequencyPenalty: got %v, want -0.1", gc["frequencyPenalty"]) + } +} + +// TestSynthesize_Params_AllFour verifies all four params land together. +func TestSynthesize_Params_AllFour(t *testing.T) { + cfg := gemini.Config{APIKey: "k"} + body, _ := captureGeminiBody(t, cfg, audio.TTSOptions{ + Params: map[string]any{ + "temperature": 1.5, + "seed": 12345, + "presencePenalty": 0.3, + "frequencyPenalty": -0.1, + }, + }) + gc := assertGeminiGenerationConfig(t, body) + if v, _ := gc["temperature"].(float64); v != 1.5 { + t.Errorf("temperature: got %v, want 1.5", gc["temperature"]) + } + if v, _ := gc["seed"].(float64); int(v) != 12345 { + t.Errorf("seed: got %v, want 12345", gc["seed"]) + } + if v, _ := gc["presencePenalty"].(float64); v != 0.3 { + t.Errorf("presencePenalty: got %v, want 0.3", gc["presencePenalty"]) + } + if v, _ := gc["frequencyPenalty"].(float64); v != -0.1 { + t.Errorf("frequencyPenalty: got %v, want -0.1", gc["frequencyPenalty"]) + } +} + +// TestSynthesize_Params_NilParams_NoExtraKeys verifies nil params produces no +// extra keys in generationConfig beyond the mandatory ones. +func TestSynthesize_Params_NilParams_NoExtraKeys(t *testing.T) { + cfg := gemini.Config{APIKey: "k"} + body, _ := captureGeminiBody(t, cfg, audio.TTSOptions{}) + gc := assertGeminiGenerationConfig(t, body) + + for _, extra := range []string{"temperature", "seed", "presencePenalty", "frequencyPenalty"} { + if _, ok := gc[extra]; ok { + t.Errorf("nil params: generationConfig must not contain %q", extra) + } + } +} + +// TestCapabilities_Gemini_HasFourParams confirms Capabilities returns exactly +// the four documented params with correct Group tags. +func TestCapabilities_Gemini_HasFourParams(t *testing.T) { + p := gemini.NewProvider(gemini.Config{APIKey: "k"}) + caps := p.Capabilities() + + type want struct { + group string + } + expected := map[string]want{ + "temperature": {group: ""}, + "seed": {group: "advanced"}, + "presencePenalty": {group: "advanced"}, + "frequencyPenalty": {group: "advanced"}, + } + + found := map[string]bool{} + for _, p := range caps.Params { + if w, ok := expected[p.Key]; ok { + found[p.Key] = true + if p.Group != w.group { + t.Errorf("param %q: Group got %q, want %q", p.Key, p.Group, w.group) + } + } + } + for key := range expected { + if !found[key] { + t.Errorf("param %q not found in Capabilities", key) + } + } +} + +// assertGeminiGenerationConfig extracts generationConfig from the body and fails +// if it is missing or not a map. +func assertGeminiGenerationConfig(t *testing.T, body map[string]any) map[string]any { + t.Helper() + gc, ok := body["generationConfig"].(map[string]any) + if !ok { + t.Fatalf("generationConfig missing or not a map: %#v", body["generationConfig"]) + } + return gc +} diff --git a/internal/audio/gemini/testdata/default_body.golden.json b/internal/audio/gemini/testdata/default_body.golden.json new file mode 100644 index 00000000..cfbe67a1 --- /dev/null +++ b/internal/audio/gemini/testdata/default_body.golden.json @@ -0,0 +1,23 @@ +{ + "contents": [ + { + "parts": [ + { + "text": "hello" + } + ] + } + ], + "generationConfig": { + "responseModalities": [ + "AUDIO" + ], + "speechConfig": { + "voiceConfig": { + "prebuiltVoiceConfig": { + "voiceName": "Kore" + } + } + } + } +} \ No newline at end of file diff --git a/internal/audio/gemini/tts.go b/internal/audio/gemini/tts.go index a4bf2776..6a32870d 100644 --- a/internal/audio/gemini/tts.go +++ b/internal/audio/gemini/tts.go @@ -105,14 +105,30 @@ func (p *Provider) Synthesize(ctx context.Context, text string, opts audio.TTSOp // Per Gemini generateContent spec, speechConfig is NESTED under // generationConfig — not a top-level field. Sending it at root returns // 400 "Unknown name 'speechConfig': Cannot find field." + generationConfig := map[string]any{ + "responseModalities": []string{"AUDIO"}, + "speechConfig": speechConfig, + } + // Merge optional params into generationConfig — only when explicitly present + // in opts.Params (nil default = omit from body, preserving characterization). + if temp, ok := resolveGeminiFloatExplicit(opts.Params, "temperature"); ok { + generationConfig["temperature"] = temp + } + if seed, ok := resolveGeminiIntExplicit(opts.Params, "seed"); ok { + generationConfig["seed"] = seed + } + if pp, ok := resolveGeminiFloatExplicit(opts.Params, "presencePenalty"); ok { + generationConfig["presencePenalty"] = pp + } + if fp, ok := resolveGeminiFloatExplicit(opts.Params, "frequencyPenalty"); ok { + generationConfig["frequencyPenalty"] = fp + } + reqBody := map[string]any{ "contents": []map[string]any{ {"parts": []map[string]any{{"text": text}}}, }, - "generationConfig": map[string]any{ - "responseModalities": []string{"AUDIO"}, - "speechConfig": speechConfig, - }, + "generationConfig": generationConfig, } bodyBytes, err := json.Marshal(reqBody) @@ -237,6 +253,49 @@ func (p *Provider) requestAudio(ctx context.Context, model string, bodyBytes []b }, nil } +// resolveGeminiFloatExplicit returns (value, true) only when the key is explicitly +// present in opts.Params, so callers can omit the generationConfig field entirely +// when not set. Mirrors resolveMiniMaxBoolExplicit in minimax/tts.go. +func resolveGeminiFloatExplicit(params map[string]any, key string) (float64, bool) { + if params == nil { + return 0, false + } + v, ok := audio.GetNested(params, key) + if !ok { + return 0, false + } + switch n := v.(type) { + case float64: + return n, true + case float32: + return float64(n), true + case int: + return float64(n), true + } + return 0, false +} + +// resolveGeminiIntExplicit returns (value, true) only when the key is explicitly +// present in opts.Params as an integer-compatible type. +func resolveGeminiIntExplicit(params map[string]any, key string) (int, bool) { + if params == nil { + return 0, false + } + v, ok := audio.GetNested(params, key) + if !ok { + return 0, false + } + switch n := v.(type) { + case int: + return n, true + case int64: + return int(n), true + case float64: + return int(n), true + } + return 0, false +} + // isTransientFinishReason reports whether a Gemini finishReason represents a // non-deterministic failure that's worth retrying. OTHER is the catch-all the // preview TTS endpoint emits when it just fails to produce audio for no diff --git a/internal/audio/minimax/capabilities.go b/internal/audio/minimax/capabilities.go index 322db1f6..211f5921 100644 --- a/internal/audio/minimax/capabilities.go +++ b/internal/audio/minimax/capabilities.go @@ -24,9 +24,12 @@ var ( // minimaxParams is the enriched param schema for MiniMax TTS. // Defaults MUST match the hardcoded body in tts.go (characterization fixture): -// speed=1.0, vol=1.0 (omitted from original = 1.0 default), pitch=0. -// Note: "vol" key is not in the original body; adding it with default 1.0 -// is safe because server default is also 1.0. +// +// speed=1.0, vol=1.0 (omitted from original = 1.0 default), pitch=0. +// +// Group tagging (§Requirements #7): +// - audio.bitrate, audio.sample_rate, audio.channel, pronunciation_dict = "advanced" +// - all others = "" (basic) var minimaxParams = []audio.ParamSchema{ { Key: "speed", @@ -99,6 +102,7 @@ var minimaxParams = []audio.ParamSchema{ Type: audio.ParamTypeEnum, Label: "Sample Rate", Default: "", + Group: "advanced", Enum: []audio.EnumOption{ {Value: "", Label: "Default"}, {Value: "8000", Label: "8 kHz"}, @@ -114,6 +118,7 @@ var minimaxParams = []audio.ParamSchema{ Type: audio.ParamTypeEnum, Label: "Bitrate (MP3 only)", Default: "", + Group: "advanced", Enum: []audio.EnumOption{ {Value: "", Label: "Default"}, {Value: "32000", Label: "32 kbps"}, @@ -130,12 +135,63 @@ var minimaxParams = []audio.ParamSchema{ Type: audio.ParamTypeEnum, Label: "Channels", Default: "", + Group: "advanced", Enum: []audio.EnumOption{ {Value: "", Label: "Default"}, {Value: "1", Label: "Mono"}, {Value: "2", Label: "Stereo"}, }, }, + // language_boost: top-level hint for language/accent recognition. + { + Key: "language_boost", + Type: audio.ParamTypeEnum, + Label: "Language Boost", + Description: "Accent/language recognition boost. Helps the model produce more natural " + + "pronunciation for the selected language.", + Default: "", + Enum: []audio.EnumOption{ + {Value: "", Label: "Auto (default)"}, + {Value: "Chinese,Yue", Label: "Cantonese"}, + {Value: "Chinese", Label: "Mandarin"}, + {Value: "English", Label: "English"}, + {Value: "Arabic", Label: "Arabic"}, + {Value: "Russian", Label: "Russian"}, + {Value: "Spanish", Label: "Spanish"}, + {Value: "French", Label: "French"}, + {Value: "Portuguese", Label: "Portuguese"}, + {Value: "German", Label: "German"}, + {Value: "Turkish", Label: "Turkish"}, + {Value: "Dutch", Label: "Dutch"}, + {Value: "Ukrainian", Label: "Ukrainian"}, + {Value: "Vietnamese", Label: "Vietnamese"}, + {Value: "Indonesian", Label: "Indonesian"}, + {Value: "Italian", Label: "Italian"}, + {Value: "Korean", Label: "Korean"}, + {Value: "Japanese", Label: "Japanese"}, + {Value: "Hindi", Label: "Hindi"}, + }, + }, + // subtitle_enable: whether to return subtitle timing data alongside audio. + { + Key: "subtitle_enable", + Type: audio.ParamTypeBoolean, + Label: "Subtitle Enable", + Description: "When enabled, the API returns word-level subtitle timing in the response.", + Default: nil, // omit when not explicitly set + }, + // pronunciation_dict: JSON array of "word/pinyin" rule strings for custom pronunciation. + // Capped at 8KB pre-accept (Finding #6). On parse failure: log + omit, synth continues. + { + Key: "pronunciation_dict", + Type: audio.ParamTypeText, + Label: "Pronunciation Dictionary", + Description: `Custom pronunciation rules as a JSON array of "word/pinyin" strings, ` + + `e.g. ["omg/Oh my god", "CEO/Chief Executive Officer"]. ` + + `Maximum 8 KB. Advanced.`, + Default: nil, + Group: "advanced", + }, } // Capabilities returns the full capability schema for the MiniMax TTS provider. diff --git a/internal/audio/minimax/golden_test.go b/internal/audio/minimax/golden_test.go new file mode 100644 index 00000000..5befb957 --- /dev/null +++ b/internal/audio/minimax/golden_test.go @@ -0,0 +1,60 @@ +package minimax_test + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/nextlevelbuilder/goclaw/internal/audio" + "github.com/nextlevelbuilder/goclaw/internal/audio/minimax" +) + +// TestGolden_DefaultBody verifies the serialized nil-params request body matches +// the checked-in golden fixture. Finding #10: external golden file catches +// simultaneous Default+tts.go drift that the self-referential invariant test misses. +func TestGolden_DefaultBody(t *testing.T) { + cfg := minimax.Config{APIKey: "test-key", GroupID: "grp1"} + body, reqURL := captureMiniMaxBody(t, cfg, audio.TTSOptions{}) + + full := map[string]any{ + "body": body, + "url": reqURL, + } + gotFull, err := json.MarshalIndent(full, "", " ") + if err != nil { + t.Fatalf("marshal body: %v", err) + } + + goldenPath := filepath.Join("testdata", "default_body.golden.json") + if _, statErr := os.Stat(goldenPath); os.IsNotExist(statErr) { + if err := os.MkdirAll("testdata", 0755); err != nil { + t.Fatalf("mkdir testdata: %v", err) + } + if err := os.WriteFile(goldenPath, gotFull, 0644); err != nil { + t.Fatalf("write golden: %v", err) + } + t.Logf("golden file created: %s", goldenPath) + return + } + + want, err := os.ReadFile(goldenPath) + if err != nil { + t.Fatalf("read golden: %v", err) + } + + var wantMap, gotMap map[string]any + if err := json.Unmarshal(want, &wantMap); err != nil { + t.Fatalf("parse golden: %v", err) + } + if err := json.Unmarshal(gotFull, &gotMap); err != nil { + t.Fatalf("parse current: %v", err) + } + + wantNorm := canonicalMiniMaxJSON(t, wantMap) + gotNorm := canonicalMiniMaxJSON(t, gotMap) + if gotNorm != wantNorm { + t.Errorf("golden fixture mismatch (update testdata/default_body.golden.json if intentional):\n got: %s\n want: %s", + gotNorm, wantNorm) + } +} diff --git a/internal/audio/minimax/params_test.go b/internal/audio/minimax/params_test.go index b28bc926..5f180327 100644 --- a/internal/audio/minimax/params_test.go +++ b/internal/audio/minimax/params_test.go @@ -76,6 +76,90 @@ func TestSynthesize_AppliesParams_OmitsEmpty_NilParams(t *testing.T) { 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) { diff --git a/internal/audio/minimax/testdata/default_body.golden.json b/internal/audio/minimax/testdata/default_body.golden.json new file mode 100644 index 00000000..377d9458 --- /dev/null +++ b/internal/audio/minimax/testdata/default_body.golden.json @@ -0,0 +1,16 @@ +{ + "body": { + "audio_setting": { + "format": "mp3" + }, + "model": "speech-02-hd", + "stream": false, + "text": "hello", + "voice_setting": { + "pitch": 0, + "speed": 1, + "voice_id": "Wise_Woman" + } + }, + "url": "/t2a_v2?GroupId=grp1" +} \ No newline at end of file diff --git a/internal/audio/minimax/tts.go b/internal/audio/minimax/tts.go index 757225e5..8801f0d4 100644 --- a/internal/audio/minimax/tts.go +++ b/internal/audio/minimax/tts.go @@ -9,12 +9,17 @@ import ( "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 @@ -67,15 +72,18 @@ func (p *Provider) Name() string { return "minimax" } // 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) +// - "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) { @@ -154,6 +162,33 @@ func (p *Provider) Synthesize(ctx context.Context, text string, opts audio.TTSOp 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) diff --git a/internal/audio/openai/capabilities.go b/internal/audio/openai/capabilities.go index 74eac236..93157ae1 100644 --- a/internal/audio/openai/capabilities.go +++ b/internal/audio/openai/capabilities.go @@ -65,8 +65,9 @@ var openAIParams = []audio.ParamSchema{ Key: "instructions", Type: audio.ParamTypeText, Label: "Instructions", - Description: "System-level style prompt (gpt-4o-mini-tts only).", + Description: "System-level style prompt (gpt-4o-mini-tts only). Advanced.", Default: "", + Group: "advanced", DependsOn: []audio.Dependency{ {Field: "model", Op: "eq", Value: "gpt-4o-mini-tts"}, }, diff --git a/internal/audio/openai/golden_test.go b/internal/audio/openai/golden_test.go new file mode 100644 index 00000000..9abcf559 --- /dev/null +++ b/internal/audio/openai/golden_test.go @@ -0,0 +1,65 @@ +package openai_test + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/nextlevelbuilder/goclaw/internal/audio" + "github.com/nextlevelbuilder/goclaw/internal/audio/openai" +) + +// TestGolden_DefaultBody verifies the serialized nil-params request body matches +// the checked-in golden fixture. Finding #10: external golden file catches +// simultaneous Default+tts.go drift that the self-referential invariant test misses. +func TestGolden_DefaultBody(t *testing.T) { + cfg := openai.Config{APIKey: "test-key"} + raw := captureOpenAIBody(t, cfg, audio.TTSOptions{}) + + goldenPath := filepath.Join("testdata", "default_body.golden.json") + if _, statErr := os.Stat(goldenPath); os.IsNotExist(statErr) { + if err := os.MkdirAll("testdata", 0755); err != nil { + t.Fatalf("mkdir testdata: %v", err) + } + // Pretty-print the raw bytes for readability. + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("parse body: %v", err) + } + pretty, _ := json.MarshalIndent(m, "", " ") + if err := os.WriteFile(goldenPath, pretty, 0644); err != nil { + t.Fatalf("write golden: %v", err) + } + t.Logf("golden file created: %s", goldenPath) + return + } + + want, err := os.ReadFile(goldenPath) + if err != nil { + t.Fatalf("read golden: %v", err) + } + + // Normalize both through JSON round-trip for deterministic key ordering. + wantNorm := canonicalOpenAIJSON(t, want) + gotNorm := canonicalOpenAIJSON(t, raw) + if gotNorm != wantNorm { + t.Errorf("golden fixture mismatch (update testdata/default_body.golden.json if intentional):\n got: %s\n want: %s", + gotNorm, wantNorm) + } +} + +// canonicalOpenAIJSON normalises raw JSON bytes to a deterministic string +// by round-tripping through map[string]any. +func canonicalOpenAIJSON(t *testing.T, raw []byte) string { + t.Helper() + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("canonicalOpenAIJSON unmarshal: %v", err) + } + b, err := json.Marshal(m) + if err != nil { + t.Fatalf("canonicalOpenAIJSON marshal: %v", err) + } + return string(b) +} diff --git a/internal/audio/openai/testdata/default_body.golden.json b/internal/audio/openai/testdata/default_body.golden.json new file mode 100644 index 00000000..f7cd5926 --- /dev/null +++ b/internal/audio/openai/testdata/default_body.golden.json @@ -0,0 +1,7 @@ +{ + "input": "hello", + "model": "gpt-4o-mini-tts", + "response_format": "mp3", + "speed": 1, + "voice": "alloy" +} \ No newline at end of file diff --git a/internal/audio/types_capabilities.go b/internal/audio/types_capabilities.go index d573a503..0cd0ccea 100644 --- a/internal/audio/types_capabilities.go +++ b/internal/audio/types_capabilities.go @@ -3,6 +3,7 @@ package audio import ( "errors" "fmt" + "slices" "strings" ) @@ -58,6 +59,9 @@ type ParamSchema struct { Step *float64 `json:"step,omitempty"` // for range Enum []EnumOption `json:"enum,omitempty"` DependsOn []Dependency `json:"depends_on,omitempty"` + // Group categorises the param for collapsible UI sections. + // "" (empty) = basic (always visible); "advanced" = collapsed by default. + Group string `json:"group,omitempty"` } // ProviderCapabilities is the catalog entry for a single TTS provider. @@ -109,10 +113,8 @@ func parseKeyPath(key string) ([]string, error) { return nil, errors.New("key path must not be empty") } parts := strings.Split(key, ".") - for _, p := range parts { - if p == "" { - return nil, errors.New("key path must not contain empty segments: " + key) - } + if slices.Contains(parts, "") { + return nil, errors.New("key path must not contain empty segments: " + key) } return parts, nil } diff --git a/internal/audio/validate_params.go b/internal/audio/validate_params.go new file mode 100644 index 00000000..94489269 --- /dev/null +++ b/internal/audio/validate_params.go @@ -0,0 +1,155 @@ +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 +} diff --git a/internal/http/tts.go b/internal/http/tts.go index 313230d2..c7296fb7 100644 --- a/internal/http/tts.go +++ b/internal/http/tts.go @@ -145,6 +145,30 @@ func (h *TTSHandler) handleSynthesize(w http.ResponseWriter, r *http.Request) { } } + // Validate tenant params against the provider's capability schema (Finding #3). + // Rejects unknown keys and out-of-range values before forwarding to the provider. + if len(tenantParams) > 0 { + if dp, ok := p.(audio.DescribableProvider); ok { + caps := dp.Capabilities() + if err := audio.ValidateParams(caps.Params, tenantParams); err != nil { + slog.Warn("tts.synthesize.invalid-params", "provider", name, "error", err) + var ukErr audio.ErrTTSParamUnknownKey + var orErr audio.ErrTTSParamOutOfRange + switch { + case errors.As(err, &ukErr): + msg := i18n.T(locale, i18n.MsgTtsParamUnknownKey, ukErr.Key) + http.Error(w, fmt.Sprintf(`{"error":%q}`, msg), http.StatusUnprocessableEntity) + case errors.As(err, &orErr): + msg := i18n.T(locale, i18n.MsgTtsParamOutOfRange, orErr.Key, orErr.Val, orErr.Min, orErr.Max) + http.Error(w, fmt.Sprintf(`{"error":%q}`, msg), http.StatusUnprocessableEntity) + default: + http.Error(w, fmt.Sprintf(`{"error":%q}`, err.Error()), http.StatusUnprocessableEntity) + } + return + } + } + } + // Synthesize with a 15-second deadline. synthCtx, cancel := context.WithTimeout(ctx, synthesizeTimeout) defer cancel() @@ -284,16 +308,25 @@ func (h *TTSHandler) resolveTenantProvider(ctx context.Context, explicitProvider return provider, providerName, req.Params, nil } +// paramsMaxBytes is the maximum byte length accepted for a stored params blob. +// Finding #6: cap at 16 KB to prevent oversized JSON DoS. +const paramsMaxBytes = 16 * 1024 + // loadParamsBlob reads a JSON blob from system_configs and returns it as map[string]any. -// Returns nil when the key is absent or the value is not valid JSON. +// Returns nil when the key is absent, the value exceeds 16 KB, or the value is not valid JSON. +// Never logs the raw value — logs length only on parse failure (Finding #6). func loadParamsBlob(ctx context.Context, sc store.SystemConfigStore, key string) map[string]any { raw, err := sc.Get(ctx, key) if err != nil || raw == "" { return nil } + if len(raw) > paramsMaxBytes { + slog.Warn("tts: params blob exceeds 16 KB limit, ignoring", "key", key, "length", len(raw)) + return nil + } var m map[string]any if err := json.Unmarshal([]byte(raw), &m); err != nil { - slog.Warn("tts: invalid params blob", "key", key, "error", err) + slog.Warn("tts: invalid params blob", "key", key, "length", len(raw)) return nil } return m diff --git a/internal/http/tts_config.go b/internal/http/tts_config.go index dc918c3a..a576cc54 100644 --- a/internal/http/tts_config.go +++ b/internal/http/tts_config.go @@ -3,6 +3,7 @@ package http import ( "context" "encoding/json" + "errors" "fmt" "log/slog" "net/http" @@ -11,6 +12,7 @@ import ( "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/audio" + "github.com/nextlevelbuilder/goclaw/internal/i18n" "github.com/nextlevelbuilder/goclaw/internal/permissions" "github.com/nextlevelbuilder/goclaw/internal/store" ) @@ -236,6 +238,8 @@ func (h *TTSConfigHandler) handleSave(w http.ResponseWriter, r *http.Request) { return } + locale := store.LocaleFromContext(ctx) + var req ttsConfigSaveRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, fmt.Sprintf(`{"error":"invalid json: %s"}`, err.Error()), http.StatusBadRequest) @@ -322,29 +326,30 @@ func (h *TTSConfigHandler) handleSave(w http.ResponseWriter, r *http.Request) { return } } - // Dual-write: save params blobs alongside legacy flat keys for rollback safety. + // Dual-write: validate then save params blobs alongside legacy flat keys. + // Finding #3: ValidateParams enforces Min/Max/Enum + rejects unknown keys. if req.OpenAI != nil && req.OpenAI.Params != nil { - if !saveParamsBlob(w, ctx, h.systemConfigs.Set, "tts.openai.params", req.OpenAI.Params) { + if !validateAndSaveParamsBlob(w, ctx, h.systemConfigs.Set, "tts.openai.params", "openai", req.OpenAI.Params, locale) { return } } if req.ElevenLabs != nil && req.ElevenLabs.Params != nil { - if !saveParamsBlob(w, ctx, h.systemConfigs.Set, "tts.elevenlabs.params", req.ElevenLabs.Params) { + if !validateAndSaveParamsBlob(w, ctx, h.systemConfigs.Set, "tts.elevenlabs.params", "elevenlabs", req.ElevenLabs.Params, locale) { return } } if req.Edge != nil && req.Edge.Params != nil { - if !saveParamsBlob(w, ctx, h.systemConfigs.Set, "tts.edge.params", req.Edge.Params) { + if !validateAndSaveParamsBlob(w, ctx, h.systemConfigs.Set, "tts.edge.params", "edge", req.Edge.Params, locale) { return } } if req.MiniMax != nil && req.MiniMax.Params != nil { - if !saveParamsBlob(w, ctx, h.systemConfigs.Set, "tts.minimax.params", req.MiniMax.Params) { + if !validateAndSaveParamsBlob(w, ctx, h.systemConfigs.Set, "tts.minimax.params", "minimax", req.MiniMax.Params, locale) { return } } if req.Gemini != nil && req.Gemini.Params != nil { - if !saveParamsBlob(w, ctx, h.systemConfigs.Set, "tts.gemini.params", req.Gemini.Params) { + if !validateAndSaveParamsBlob(w, ctx, h.systemConfigs.Set, "tts.gemini.params", "gemini", req.Gemini.Params, locale) { return } } @@ -523,3 +528,35 @@ func saveParamsBlob(w http.ResponseWriter, ctx context.Context, setFn func(conte } return saveOrFail(w, ctx, setFn, key, string(blob), key) } + +// validateAndSaveParamsBlob validates params against the named provider's capability +// schema (Finding #3) then persists via saveParamsBlob. Returns false and writes +// 422 on validation failure, 500 on marshal/store error. +func validateAndSaveParamsBlob(w http.ResponseWriter, ctx context.Context, setFn func(context.Context, string, string) error, key, providerName string, params map[string]any, locale string) bool { + // Look up schema from builtin capabilities catalog. + var schema []audio.ParamSchema + for _, cap := range builtinTTSCapabilities() { + if cap.Provider == providerName { + schema = cap.Params + break + } + } + + if err := audio.ValidateParams(schema, params); err != nil { + slog.Warn("tts.config: invalid params", "provider", providerName, "error", err) + var ukErr audio.ErrTTSParamUnknownKey + var orErr audio.ErrTTSParamOutOfRange + var msg string + switch { + case errors.As(err, &ukErr): + msg = i18n.T(locale, i18n.MsgTtsParamUnknownKey, ukErr.Key) + case errors.As(err, &orErr): + msg = i18n.T(locale, i18n.MsgTtsParamOutOfRange, orErr.Key, orErr.Val, orErr.Min, orErr.Max) + default: + msg = err.Error() + } + http.Error(w, fmt.Sprintf(`{"error":%q}`, msg), http.StatusUnprocessableEntity) + return false + } + return saveParamsBlob(w, ctx, setFn, key, params) +} diff --git a/internal/http/tts_config_test.go b/internal/http/tts_config_test.go index 95d21c8e..f50ebe63 100644 --- a/internal/http/tts_config_test.go +++ b/internal/http/tts_config_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "maps" "net/http" "net/http/httptest" "sync" @@ -50,9 +51,7 @@ func (s *validationSystemConfigStore) List(ctx context.Context) (map[string]stri s.mu.Lock() defer s.mu.Unlock() out := make(map[string]string, len(s.data)) - for k, v := range s.data { - out[k] = v - } + maps.Copy(out, s.data) return out, nil } @@ -88,9 +87,7 @@ func (s *validationSecretsStore) GetAll(ctx context.Context) (map[string]string, s.mu.Lock() defer s.mu.Unlock() out := make(map[string]string, len(s.data)) - for k, v := range s.data { - out[k] = v - } + maps.Copy(out, s.data) return out, nil } diff --git a/internal/i18n/catalog_en.go b/internal/i18n/catalog_en.go index f6de025d..fbc2f73b 100644 --- a/internal/i18n/catalog_en.go +++ b/internal/i18n/catalog_en.go @@ -206,6 +206,8 @@ func init() { MsgTtsGeminiInvalidModel: "invalid Gemini TTS model: %s", MsgTtsParamOutOfRange: "TTS param %q value %v is out of range [%v, %v]", MsgTtsParamDependsOn: "TTS param %q requires %q to be %q", + MsgTtsParamInvalidJSON: "TTS param %q contains invalid JSON (length %d)", + MsgTtsParamUnknownKey: "TTS param %q is not supported by this provider", MsgTtsMiniMaxVoicesFailed: "failed to fetch MiniMax voices: %s", // STT diff --git a/internal/i18n/catalog_vi.go b/internal/i18n/catalog_vi.go index a1d18dbc..2dbc3108 100644 --- a/internal/i18n/catalog_vi.go +++ b/internal/i18n/catalog_vi.go @@ -206,6 +206,8 @@ func init() { MsgTtsGeminiInvalidModel: "mô hình Gemini TTS không hợp lệ: %s", MsgTtsParamOutOfRange: "tham số TTS %q có giá trị %v nằm ngoài phạm vi [%v, %v]", MsgTtsParamDependsOn: "tham số TTS %q yêu cầu %q phải là %q", + MsgTtsParamInvalidJSON: "tham số TTS %q chứa JSON không hợp lệ (độ dài %d)", + MsgTtsParamUnknownKey: "tham số TTS %q không được nhà cung cấp này hỗ trợ", MsgTtsMiniMaxVoicesFailed: "không tải được danh sách giọng đọc MiniMax: %s", // STT diff --git a/internal/i18n/catalog_zh.go b/internal/i18n/catalog_zh.go index ab0acad6..1072cccf 100644 --- a/internal/i18n/catalog_zh.go +++ b/internal/i18n/catalog_zh.go @@ -206,6 +206,8 @@ func init() { MsgTtsGeminiInvalidModel: "无效的 Gemini TTS 模型:%s", MsgTtsParamOutOfRange: "TTS 参数 %q 的值 %v 超出范围 [%v, %v]", MsgTtsParamDependsOn: "TTS 参数 %q 需要将 %q 设置为 %q", + MsgTtsParamInvalidJSON: "TTS 参数 %q 包含无效的 JSON(长度 %d)", + MsgTtsParamUnknownKey: "TTS 参数 %q 不受此提供商支持", MsgTtsMiniMaxVoicesFailed: "获取 MiniMax 声音列表失败:%s", // STT diff --git a/internal/i18n/keys.go b/internal/i18n/keys.go index dabf7f81..44836f26 100644 --- a/internal/i18n/keys.go +++ b/internal/i18n/keys.go @@ -204,6 +204,8 @@ const ( MsgTtsGeminiInvalidModel = "error.tts_gemini_invalid_model" // "invalid Gemini TTS model: %s" MsgTtsParamOutOfRange = "error.tts_param_out_of_range" // "TTS param %q value %v is out of range [%v, %v]" MsgTtsParamDependsOn = "error.tts_param_depends_on" // "TTS param %q requires %q to be %q" + MsgTtsParamInvalidJSON = "error.tts_param_invalid_json" // "TTS param %q contains invalid JSON (length %d)" + MsgTtsParamUnknownKey = "error.tts_param_unknown_key" // "TTS param %q is not supported by this provider" MsgTtsMiniMaxVoicesFailed = "error.tts_minimax_voices_failed" // "failed to fetch MiniMax voices: %s" // --- STT ---