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.
This commit is contained in:
viettranx committed 2026-04-20 00:20:50 +07:00
1 parent 613b6e38d7
commit ee57ec0fdc
28 files changed
+1435 -70

No files matched your search

+76 -20
View File
@@ -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"},
},
},
}
@@ -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)
}
+60
View File
@@ -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)
}
}
@@ -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"
}
+71 -11
View File
@@ -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 {
+63 -1
View File
@@ -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,
+4 -3
View File
@@ -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))
}
}
@@ -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)
}
+140
View File
@@ -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
}
+23
View File
@@ -0,0 +1,23 @@
{
"contents": [
{
"parts": [
{
"text": "hello"
}
]
}
],
"generationConfig": {
"responseModalities": [
"AUDIO"
],
"speechConfig": {
"voiceConfig": {
"prebuiltVoiceConfig": {
"voiceName": "Kore"
}
}
}
}
}
+63 -4
View File
@@ -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
+59 -3
View File
@@ -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.
+60
View File
@@ -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)
}
}
+84
View File
@@ -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) {
@@ -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"
}
+44 -9
View File
@@ -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)
+2 -1
View File
@@ -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"},
},
+65
View File
@@ -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)
}
@@ -0,0 +1,7 @@
{
"input": "hello",
"model": "gpt-4o-mini-tts",
"response_format": "mp3",
"speed": 1,
"voice": "alloy"
}
+6 -4
View File
@@ -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
}
+155
View File
@@ -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
}
+35 -2
View File
@@ -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
+43 -6
View File
@@ -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)
}
+3 -6
View File
@@ -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
}
+2
View File
@@ -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
+2
View File
@@ -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
+2
View File
@@ -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
+2
View File
@@ -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 ---