mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 10:18:42 +00:00
Phase 1 of the TTS params/layout/agent-override plan.
Capability schema:
- `ParamSchema.Group` field (`"basic"` default, `"advanced"` when set).
- Tag existing advanced params across openai/elevenlabs/minimax/gemini.
Gemini:
- Expose `temperature` (basic, 0.0–2.0, default 1.0, subtle-effect note),
`seed`, `presencePenalty`, `frequencyPenalty` (advanced, experimental).
- Merge into `generationConfig` via explicit-presence resolvers so
nil-params bodies stay byte-equivalent.
ElevenLabs:
- `output_format` enum (27 variants, default `mp3_44100_128`, advanced).
- `FormatMeta` lookup drives SynthResult MIME + extension.
- URL built via `net/url.Values.Encode()`; regex pre-validation for
`output_format` (`^[a-z0-9_]+$`) and `language_code`
(`^[a-z]{2,3}(-[A-Z]{2})?$`) blocks query-string injection.
- Telegram opus contract preserved: `opts.Format=="opus"` forces
`audio/ogg; codecs=opus` regardless of user-set `output_format`.
MiniMax:
- `language_boost` (basic enum), `subtitle_enable` (basic bool),
`pronunciation_dict` (advanced text, 8KB cap, wrapped as
`{"tone":[...]}`). Parse failure logs length only + omits.
Validation:
- `audio.ValidateParams` enforces Min/Max/Enum + rejects unknown keys.
- Wired into `/v1/tts/synthesize` and `/v1/tts/config` write paths.
- `loadParamsBlob` capped at 16KB.
- i18n keys `MsgTtsParamOutOfRange`, `MsgTtsParamInvalidJSON`,
`MsgTtsParamUnknownKey` added to en/vi/zh catalogs.
Tests:
- Golden `testdata/default_body.golden.json` per provider (gemini,
elevenlabs, minimax, openai) checked in; invariant tests diff
against file instead of self-referential capture.
- Round-trip tests for each new param + Telegram opus contract +
URL-injection attempts.
194 lines
6.6 KiB
Go
194 lines
6.6 KiB
Go
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)
|
|
}
|