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

139 lines
3.9 KiB
Go

// Package edge implements TTS via the Microsoft Edge TTS CLI (free, no API key).
// Requires the `edge-tts` Python CLI: `pip install edge-tts`.
package edge
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"time"
"github.com/nextlevelbuilder/goclaw/internal/audio"
)
// Config configures the Edge TTS provider.
type Config struct {
Voice string // default "en-US-MichelleNeural"
Rate string // speech rate, e.g. "+0%"; overridden by opts.Params["rate"]
TimeoutMs int
}
// Provider implements audio.TTSProvider via the edge-tts CLI.
type Provider struct {
voice string
rate string // legacy config-level rate string; opts.Params["rate"] takes precedence
timeoutMs int
}
// NewProvider returns an Edge TTS provider with defaults applied.
func NewProvider(cfg Config) *Provider {
p := &Provider{
voice: cfg.Voice,
rate: cfg.Rate,
timeoutMs: cfg.TimeoutMs,
}
if p.voice == "" {
p.voice = "en-US-MichelleNeural"
}
if p.timeoutMs <= 0 {
p.timeoutMs = 30000
}
return p
}
// Name returns the stable provider identifier used by the Manager.
func (p *Provider) Name() string { return "edge" }
// Synthesize shells out to edge-tts. Output is always MP3
// (edge-tts default format: audio-24khz-48kbitrate-mono-mp3).
// opts.Voice overrides the construction-time voice when non-empty.
// opts.Params keys (integer slider values):
// - "rate" int (-50..+100) → "--rate +N%"
// - "pitch" int (-50..+50) → "--pitch +NHz"
// - "volume" int (-50..+100) → "--volume +N%"
//
// Zero value for rate/pitch/volume means no flag is added (preserves legacy behaviour).
// MUST NOT mutate opts.Params.
func (p *Provider) Synthesize(ctx context.Context, text string, opts audio.TTSOptions) (*audio.SynthResult, error) {
tmpDir := os.TempDir()
outPath := filepath.Join(tmpDir, fmt.Sprintf("tts-%d.mp3", time.Now().UnixNano()))
defer os.Remove(outPath)
voice := p.voice
if opts.Voice != "" {
voice = opts.Voice
}
// Resolve rate/pitch/volume from opts.Params (int slider).
// When Params is nil or key absent, fall back to legacy p.rate string if set.
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", outPath,
}
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)
}
timeout := time.Duration(p.timeoutMs) * time.Millisecond
cmdCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmd := exec.CommandContext(cmdCtx, "edge-tts", args...)
if output, err := cmd.CombinedOutput(); err != nil {
return nil, fmt.Errorf("edge-tts failed: %w (output: %s)", err, string(output))
}
audioBytes, err := os.ReadFile(outPath)
if err != nil {
return nil, fmt.Errorf("read edge-tts output: %w", err)
}
return &audio.SynthResult{
Audio: audioBytes,
Extension: "mp3",
MimeType: "audio/mpeg",
}, nil
}
// resolveEdgeParam reads an integer slider value from params and converts it
// to the edge-tts string format using conv. Falls back to legacyStr when the
// key is absent from params or params is nil. Returns "" when both sources
// are absent.
func resolveEdgeParam(params map[string]any, key string, conv func(int) string, legacyStr string) string {
if params != nil {
if v, ok := audio.GetNested(params, key); ok {
n := toInt(v)
return conv(n)
}
}
return legacyStr
}
// toInt converts a numeric any value to int. Returns 0 for unknown types.
func toInt(v any) int {
switch n := v.(type) {
case int:
return n
case int64:
return int(n)
case float64:
return int(n)
case float32:
return int(n)
}
return 0
}