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

118 lines
3.3 KiB
Go

package edge
import (
"context"
"os/exec"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/audio"
)
// captureEdgeArgsWithOpts is a test helper that captures CLI args via synthesizeWithFactory.
func captureEdgeArgsWithOpts(t *testing.T, p *Provider, opts audio.TTSOptions) []string {
t.Helper()
factory := func(ctx context.Context, name string, args ...string) *exec.Cmd {
return exec.Command("true")
}
args, err := p.synthesizeWithFactory(context.Background(), "test text", opts, factory)
if err != nil {
t.Fatalf("synthesizeWithFactory: %v", err)
}
return args
}
func TestSynthesize_AppliesParams_Rate(t *testing.T) {
p := NewProvider(Config{})
args := captureEdgeArgsWithOpts(t, p, audio.TTSOptions{
Params: map[string]any{"rate": 15},
})
assertArg(t, args, "--rate", "+15%")
}
func TestSynthesize_AppliesParams_NegativeRate(t *testing.T) {
p := NewProvider(Config{})
args := captureEdgeArgsWithOpts(t, p, audio.TTSOptions{
Params: map[string]any{"rate": -25},
})
assertArg(t, args, "--rate", "-25%")
}
func TestSynthesize_AppliesParams_Pitch(t *testing.T) {
p := NewProvider(Config{})
args := captureEdgeArgsWithOpts(t, p, audio.TTSOptions{
Params: map[string]any{"pitch": 5},
})
assertArg(t, args, "--pitch", "+5Hz")
}
func TestSynthesize_AppliesParams_Volume(t *testing.T) {
p := NewProvider(Config{})
args := captureEdgeArgsWithOpts(t, p, audio.TTSOptions{
Params: map[string]any{"volume": 20},
})
assertArg(t, args, "--volume", "+20%")
}
func TestSynthesize_AppliesParams_OmitsZeroRate(t *testing.T) {
// Zero rate/pitch/volume must NOT produce flags (characterization invariant).
p := NewProvider(Config{})
args := captureEdgeArgsWithOpts(t, p, audio.TTSOptions{
Params: map[string]any{"rate": 0, "pitch": 0, "volume": 0},
})
for _, flag := range []string{"--rate", "--pitch", "--volume"} {
if hasFlag(args, flag) {
t.Errorf("%s flag must not appear for zero value", flag)
}
}
}
func TestSynthesize_AppliesParams_NilParamsNoFlags(t *testing.T) {
p := NewProvider(Config{})
args := captureEdgeArgsWithOpts(t, p, audio.TTSOptions{})
for _, flag := range []string{"--rate", "--pitch", "--volume"} {
if hasFlag(args, flag) {
t.Errorf("%s flag must not appear for nil params", flag)
}
}
}
func TestSynthesize_DoesNotMutateCallerParams(t *testing.T) {
p := NewProvider(Config{})
original := map[string]any{
"rate": 10,
"sentinel": "untouched",
}
snapshot := map[string]any{
"rate": original["rate"],
"sentinel": original["sentinel"],
}
captureEdgeArgsWithOpts(t, p, audio.TTSOptions{Params: original})
for k, want := range snapshot {
if got := original[k]; got != want {
t.Errorf("caller Params mutated: key %q was %v, now %v", k, want, got)
}
}
}
func TestCapabilities_HasParam_Rate(t *testing.T) {
p := NewProvider(Config{})
caps := p.Capabilities()
for _, param := range caps.Params {
if param.Key == "rate" && param.Type == audio.ParamTypeInteger {
return
}
}
t.Error("rate param (integer) not found in Capabilities")
}
func TestCapabilities_HasParam_Pitch(t *testing.T) {
p := NewProvider(Config{})
caps := p.Capabilities()
for _, param := range caps.Params {
if param.Key == "pitch" && param.Type == audio.ParamTypeInteger {
return
}
}
t.Error("pitch param (integer) not found in Capabilities")
}