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

145 lines
5.0 KiB
Go

package http
import (
"fmt"
"log/slog"
"net/http"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/audio"
"github.com/nextlevelbuilder/goclaw/internal/audio/elevenlabs"
"github.com/nextlevelbuilder/goclaw/internal/audio/minimax"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// VoicesHandler serves GET /v1/voices and POST /v1/voices/refresh.
// provider is typed as audio.VoiceListProvider so any provider can be injected.
type VoicesHandler struct {
cache *audio.VoiceCache
provider audio.VoiceListProvider // nil when resolved at request time from stores
secretStore store.ConfigSecretsStore
tenantStore store.TenantStore
}
// NewVoicesHandler creates a handler that resolves the provider at request time
// from config_secrets. Use NewVoicesHandlerWithProvider for tests.
func NewVoicesHandler(cache *audio.VoiceCache, secretStore store.ConfigSecretsStore, tenantStore store.TenantStore) *VoicesHandler {
return &VoicesHandler{cache: cache, secretStore: secretStore, tenantStore: tenantStore}
}
// NewVoicesHandlerWithProvider creates a handler with a pre-built provider.
// Accepts audio.VoiceListProvider so any provider (ElevenLabs, MiniMax, mock) can be injected.
// Primarily used in tests.
func NewVoicesHandlerWithProvider(cache *audio.VoiceCache, p audio.VoiceListProvider) *VoicesHandler {
return &VoicesHandler{cache: cache, provider: p}
}
// RegisterRoutes wires the voices endpoints onto mux.
func (h *VoicesHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /v1/voices", requireAuth("", h.handleList))
mux.HandleFunc("POST /v1/voices/refresh", requireAuth(permissions.RoleAdmin, h.handleRefresh))
}
// handleList serves GET /v1/voices — returns cached list or fetches live.
func (h *VoicesHandler) handleList(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
tenantID := store.TenantIDFromContext(ctx)
locale := store.LocaleFromContext(ctx)
if voices, ok := h.cache.Get(tenantID); ok {
writeJSON(w, http.StatusOK, map[string]any{"voices": voices})
return
}
p, err := h.resolveProvider(r, tenantID)
if err != nil {
slog.Warn("voices: no provider configured", "tenant_id", tenantID, "error", err)
writeJSON(w, http.StatusNotFound, map[string]string{
"error": i18n.T(locale, i18n.MsgVoicesListFailed, err.Error()),
})
return
}
voices, err := p.ListVoices(ctx)
if err != nil {
slog.Warn("voices: list failed", "tenant_id", tenantID, "error", err)
writeJSON(w, http.StatusBadGateway, map[string]string{
"error": i18n.T(locale, i18n.MsgTtsMiniMaxVoicesFailed, err.Error()),
})
return
}
h.cache.Set(tenantID, voices)
writeJSON(w, http.StatusOK, map[string]any{"voices": voices})
}
// handleRefresh serves POST /v1/voices/refresh — admin-only, forces a live
// refetch by invalidating the tenant's cache entry.
func (h *VoicesHandler) handleRefresh(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
tenantID := store.TenantIDFromContext(ctx)
locale := store.LocaleFromContext(ctx)
h.cache.Invalidate(tenantID)
p, err := h.resolveProvider(r, tenantID)
if err != nil {
slog.Warn("voices: no provider on refresh", "tenant_id", tenantID, "error", err)
writeJSON(w, http.StatusNotFound, map[string]string{
"error": i18n.T(locale, i18n.MsgVoicesListFailed, err.Error()),
})
return
}
voices, err := p.ListVoices(ctx)
if err != nil {
slog.Warn("voices: refresh fetch failed", "tenant_id", tenantID, "error", err)
writeJSON(w, http.StatusBadGateway, map[string]string{
"error": i18n.T(locale, i18n.MsgVoicesListFailed, err.Error()),
})
return
}
h.cache.Set(tenantID, voices)
writeJSON(w, http.StatusOK, map[string]any{"voices": voices})
}
// resolveProvider returns the VoiceListProvider for this request.
// Priority: injected provider (test/pre-built) > query-param ?provider > secret store lookup (elevenlabs default).
func (h *VoicesHandler) resolveProvider(r *http.Request, tenantID uuid.UUID) (audio.VoiceListProvider, error) {
if h.provider != nil {
return h.provider, nil
}
if h.secretStore == nil {
return nil, fmt.Errorf("no voice provider configured")
}
providerName := r.URL.Query().Get("provider")
if providerName == "" {
providerName = "elevenlabs" // backward-compatible default
}
switch providerName {
case "minimax":
apiKey, err := h.secretStore.Get(r.Context(), "tts.minimax.api_key")
if err != nil || apiKey == "" {
return nil, fmt.Errorf("MiniMax API key not found for tenant %s", tenantID)
}
apiBase, _ := h.secretStore.Get(r.Context(), "tts.minimax.api_base")
return minimax.NewVoiceLister(apiKey, apiBase, 15000, tenantID), nil
case "elevenlabs":
apiKey, err := h.secretStore.Get(r.Context(), "tts.elevenlabs.api_key")
if err != nil || apiKey == "" {
return nil, fmt.Errorf("ElevenLabs API key not found for tenant %s", tenantID)
}
return elevenlabs.NewTTSProvider(elevenlabs.Config{APIKey: apiKey}), nil
default:
return nil, fmt.Errorf("unsupported voice provider: %s", providerName)
}
}