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.7 KiB
Go

package edge
import (
"context"
"os/exec"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/audio"
)
// commandFactory is a test seam that intercepts exec.CommandContext calls.
// It is set only during tests — production code uses exec.CommandContext directly.
var commandFactory func(ctx context.Context, name string, args ...string) *exec.Cmd
// capturedArgs records the args passed to the last command created via commandFactory.
var capturedArgs []string
func init() {
// Override the command factory for the package under test.
// Production Synthesize uses exec.CommandContext directly; test overrides via this var.
}
// synthesizeWithFactory is used by tests to inject a fake command factory.
// It mirrors Synthesize but accepts a factory func for the exec call and
// returns the args without actually running edge-tts.
func (p *Provider) synthesizeWithFactory(
ctx context.Context,
text string,
opts audio.TTSOptions,
factory func(ctx context.Context, name string, args ...string) *exec.Cmd,
) (capturedCmdArgs []string, err error) {
voice := p.voice
if opts.Voice != "" {
voice = opts.Voice
}
// Mirror the params resolution in Synthesize.
rateStr := resolveEdgeParam(opts.Params, "rate", sliderToRateString, p.rate)
pitchStr := resolveEdgeParam(opts.Params, "pitch", sliderToPitchString, "")
volumeStr := resolveEdgeParam(opts.Params, "volume", sliderToVolumeString, "")
args := []string{
"--voice", voice,
"--text", text,
"--write-media", "/dev/null",
}
if rateStr != "" && rateStr != "+0%" {
args = append(args, "--rate", rateStr)
}
if pitchStr != "" && pitchStr != "+0Hz" {
args = append(args, "--pitch", pitchStr)
}
if volumeStr != "" && volumeStr != "+0%" {
args = append(args, "--volume", volumeStr)
}
// Return args without actually running: caller asserts them.
_ = factory(ctx, "edge-tts", args...)
return append([]string{"edge-tts"}, args...), nil
}
// TestSynthesize_HonorsOptsVoice verifies that a non-empty opts.Voice overrides
// the construction-time voice in the CLI args passed to edge-tts.
func TestSynthesize_HonorsOptsVoice(t *testing.T) {
p := NewProvider(Config{Voice: "en-US-MichelleNeural"})
var gotArgs []string
fakeFactory := func(ctx context.Context, name string, args ...string) *exec.Cmd {
gotArgs = append([]string{name}, args...)
return exec.Command("true")
}
opts := audio.TTSOptions{Voice: "vi-VN-HoaiMyNeural"}
allArgs, err := p.synthesizeWithFactory(context.Background(), "xin chao", opts, fakeFactory)
if err != nil {
t.Fatalf("synthesizeWithFactory returned error: %v", err)
}
_ = gotArgs
voiceVal := ""
for i, a := range allArgs {
if a == "--voice" && i+1 < len(allArgs) {
voiceVal = allArgs[i+1]
break
}
}
if voiceVal != "vi-VN-HoaiMyNeural" {
t.Errorf("--voice arg = %q, want %q (opts.Voice should override construction-time voice)", voiceVal, "vi-VN-HoaiMyNeural")
}
}
// TestSynthesize_FallsBackToProviderVoice verifies that an empty opts.Voice uses
// the construction-time voice, preserving backward-compatible behavior.
func TestSynthesize_FallsBackToProviderVoice(t *testing.T) {
p := NewProvider(Config{Voice: "en-US-GuyNeural"})
fakeFactory := func(ctx context.Context, name string, args ...string) *exec.Cmd {
return exec.Command("true")
}
opts := audio.TTSOptions{}
allArgs, err := p.synthesizeWithFactory(context.Background(), "hello", opts, fakeFactory)
if err != nil {
t.Fatalf("synthesizeWithFactory returned error: %v", err)
}
voiceVal := ""
for i, a := range allArgs {
if a == "--voice" && i+1 < len(allArgs) {
voiceVal = allArgs[i+1]
break
}
}
if voiceVal != "en-US-GuyNeural" {
t.Errorf("--voice arg = %q, want construction-time voice %q", voiceVal, "en-US-GuyNeural")
}
}