mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 00:16:38 +00:00
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.
118 lines
3.7 KiB
Go
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")
|
|
}
|
|
}
|