Files
viettranx 613b6e38d7 feat(tts): provider capabilities schema + Gemini TTS + dynamic param forms
Baseline groundwork for TTS expansion plan. Introduces a capabilities
system that the follow-up plan (phase-01..04) builds on.

Backend:
- `audio.ParamSchema` / `ProviderCapabilities` types + per-provider
  capabilities.go for edge, elevenlabs, minimax, openai, gemini.
- Gemini TTS provider (client, models, voices, wav encoder,
  multi-speaker via SpeakerVoice, audio-tag aware prompts).
- TTSOptions gains `Params map[string]any` (read-only) + `Speakers`.
- `VoiceListProvider` interface decouples HTTP voice handler from
  provider-specific impls.
- `nested_keys.go` resolves dot-separated param paths for nested
  provider bodies (voice_settings.stability etc.).
- Characterization + defaults-invariant tests per provider lock
  nil-params byte-equivalence before new params land.
- `/v1/tts/capabilities` HTTP endpoint + integration coverage.
- Dual-read tests (PG + SQLite) for tts_config.

Frontend (web + desktop):
- `DynamicParamForm` with depends-on evaluation, split into
  fields/logic modules. Slider primitive added.
- `AudioTagPicker` + `MultiSpeakerEditor` for Gemini.
- `voice-picker` refactored toward portal UX; combobox tightened.
- tts-capabilities API client + typed hooks.
- i18n catalogs (en/vi/zh) expanded; parity tests guard key drift.
- TTS page reorganised (voice-model-section removed; playground +
  credentials + provider-setup split cleanly).

Docs: codebase-summary, project-changelog, tts-provider-capabilities.
2026-04-20 00:05:19 +07:00

117 lines
3.4 KiB
Go

package elevenlabs_test
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/audio"
"github.com/nextlevelbuilder/goclaw/internal/audio/elevenlabs"
)
// captureElevenLabsBody sends a Synthesize request to a mock server and
// returns the decoded JSON body map + the URL path used.
func captureElevenLabsBody(t *testing.T, cfg elevenlabs.Config, opts audio.TTSOptions) (map[string]any, string) {
t.Helper()
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", "audio/mpeg")
w.Write([]byte("AUDIO"))
}))
t.Cleanup(srv.Close)
cfg.BaseURL = srv.URL
p := elevenlabs.NewTTSProvider(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
}
// TestCharacterization_ElevenLabs_DefaultOpts is the golden-fixture test.
// It captures the exact voice_settings shape that the current hardcoded tts.go emits.
// These values MUST remain byte-compatible after the Synthesize refactor.
func TestCharacterization_ElevenLabs_DefaultOpts(t *testing.T) {
cfg := elevenlabs.Config{APIKey: "test-key", VoiceID: "test-voice"}
body, _ := captureElevenLabsBody(t, cfg, audio.TTSOptions{})
// Assert top-level fields.
if v, _ := body["text"].(string); v != "hello" {
t.Errorf("text: got %q, want %q", v, "hello")
}
// Assert voice_settings defaults — these are the hardcoded values at tts.go:54-59.
vs, ok := body["voice_settings"].(map[string]any)
if !ok {
t.Fatalf("voice_settings missing or not an object: %#v", body["voice_settings"])
}
assertFloat(t, vs, "stability", 0.5)
assertFloat(t, vs, "similarity_boost", 0.75)
assertFloat(t, vs, "style", 0.0)
assertBool(t, vs, "use_speaker_boost", true)
}
// TestCharacterization_ElevenLabs_DefaultOutputFormat verifies the default
// output_format query param is mp3_44100_128 when Format is empty.
func TestCharacterization_ElevenLabs_DefaultOutputFormat(t *testing.T) {
cfg := elevenlabs.Config{APIKey: "test-key", VoiceID: "my-voice"}
_, path := captureElevenLabsBody(t, cfg, audio.TTSOptions{})
if !containsStr(path, "output_format=mp3_44100_128") {
t.Errorf("expected output_format=mp3_44100_128 in URL, got: %s", path)
}
if !containsStr(path, "my-voice") {
t.Errorf("expected voice ID in URL path, got: %s", path)
}
}
func assertFloat(t *testing.T, m map[string]any, key string, want float64) {
t.Helper()
v, ok := m[key]
if !ok {
t.Errorf("missing field %q", key)
return
}
got, ok := v.(float64)
if !ok {
t.Errorf("field %q: expected float64, got %T (%v)", key, v, v)
return
}
if got != want {
t.Errorf("field %q: got %v, want %v", key, got, want)
}
}
func assertBool(t *testing.T, m map[string]any, key string, want bool) {
t.Helper()
v, ok := m[key]
if !ok {
t.Errorf("missing field %q", key)
return
}
got, ok := v.(bool)
if !ok {
t.Errorf("field %q: expected bool, got %T (%v)", key, v, v)
return
}
if got != want {
t.Errorf("field %q: got %v, want %v", key, got, want)
}
}