diff --git a/cmd/gateway.go b/cmd/gateway.go
index 2a607487..4ca3e32c 100644
--- a/cmd/gateway.go
+++ b/cmd/gateway.go
@@ -311,6 +311,7 @@ func runGateway() {
workspace: workspace,
dataDir: dataDir,
domainBus: domainBus,
+ audioMgr: audioMgr,
}
gatewayAddr := loopbackAddr(cfg.Gateway.Host, cfg.Gateway.Port)
diff --git a/cmd/gateway_deps.go b/cmd/gateway_deps.go
index a57a3852..4c860e27 100644
--- a/cmd/gateway_deps.go
+++ b/cmd/gateway_deps.go
@@ -2,6 +2,7 @@ package cmd
import (
"github.com/nextlevelbuilder/goclaw/internal/agent"
+ "github.com/nextlevelbuilder/goclaw/internal/audio"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/cache"
"github.com/nextlevelbuilder/goclaw/internal/channels"
@@ -33,4 +34,5 @@ type gatewayDeps struct {
workspace string
dataDir string
domainBus eventbus.DomainEventBus
+ audioMgr *audio.Manager // nil if TTS not configured; used by TTSHandler
}
diff --git a/cmd/gateway_http_wiring.go b/cmd/gateway_http_wiring.go
index 7093ce89..cc07eb1a 100644
--- a/cmd/gateway_http_wiring.go
+++ b/cmd/gateway_http_wiring.go
@@ -245,6 +245,17 @@ func (d *gatewayDeps) wireHTTPHandlersOnServer(
methods.NewVoicesMethods(voiceCache, nil).Register(d.server.Router())
}
+ // TTS synthesize endpoint — shares audio.Manager with setupTTS.
+ if d.audioMgr != nil {
+ ttsH := httpapi.NewTTSHandler(d.audioMgr)
+ // Reuse the server's rate limiter (per-IP/token; NOT per-user).
+ // Server.RateLimiter() is non-nil by construction (server.go:104).
+ if rl := d.server.RateLimiter(); rl != nil && rl.Enabled() {
+ ttsH.SetRateLimiter(rl.Allow)
+ }
+ d.server.SetTTSHandler(ttsH)
+ }
+
// Seed + apply builtin tool disables
if d.pgStores.BuiltinTools != nil {
seedBuiltinTools(context.Background(), d.pgStores.BuiltinTools)
diff --git a/internal/audio/edge/tts.go b/internal/audio/edge/tts.go
index a798496d..d8522787 100644
--- a/internal/audio/edge/tts.go
+++ b/internal/audio/edge/tts.go
@@ -48,13 +48,19 @@ 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).
-func (p *Provider) Synthesize(ctx context.Context, text string, _ audio.TTSOptions) (*audio.SynthResult, error) {
+// opts.Voice overrides the construction-time voice when non-empty.
+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
+ }
+
args := []string{
- "--voice", p.voice,
+ "--voice", voice,
"--text", text,
"--write-media", outPath,
}
diff --git a/internal/audio/edge/tts_test.go b/internal/audio/edge/tts_test.go
new file mode 100644
index 00000000..066ac784
--- /dev/null
+++ b/internal/audio/edge/tts_test.go
@@ -0,0 +1,107 @@
+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.
+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
+ }
+
+ args := []string{
+ "--voice", voice,
+ "--text", text,
+ "--write-media", "/dev/null",
+ }
+ if p.rate != "" {
+ args = append(args, "--rate", p.rate)
+ }
+
+ // 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 a no-op command so the test never shells out.
+ 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 // populated by factory
+
+ // Locate --voice arg in allArgs.
+ 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{} // empty Voice
+ 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")
+ }
+}
diff --git a/internal/gateway/server.go b/internal/gateway/server.go
index ea10a52c..de444d6e 100644
--- a/internal/gateway/server.go
+++ b/internal/gateway/server.go
@@ -512,6 +512,9 @@ func (s *Server) SetEvolutionHandler(h *httpapi.EvolutionHandler) {
// SetVoicesHandler sets the ElevenLabs voices list + refresh handler.
func (s *Server) SetVoicesHandler(h *httpapi.VoicesHandler) { s.handlers = append(s.handlers, h) }
+// SetTTSHandler sets the TTS synthesize handler.
+func (s *Server) SetTTSHandler(h *httpapi.TTSHandler) { s.handlers = append(s.handlers, h) }
+
// SetVaultHandler sets the Knowledge Vault document handler.
func (s *Server) SetVaultHandler(h *httpapi.VaultHandler) { s.handlers = append(s.handlers, h) }
diff --git a/internal/http/tts.go b/internal/http/tts.go
new file mode 100644
index 00000000..5b6fd9f2
--- /dev/null
+++ b/internal/http/tts.go
@@ -0,0 +1,140 @@
+package http
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/nextlevelbuilder/goclaw/internal/audio"
+ "github.com/nextlevelbuilder/goclaw/internal/audio/elevenlabs"
+ "github.com/nextlevelbuilder/goclaw/internal/i18n"
+ "github.com/nextlevelbuilder/goclaw/internal/permissions"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+// TTSHandler handles POST /v1/tts/synthesize — converts text to audio via a
+// configured TTS provider and returns raw audio bytes with the appropriate MIME type.
+type TTSHandler struct {
+ manager *audio.Manager
+ rateLimiter func(string) bool // per-IP/token rate limit check (nil = no limit)
+}
+
+// NewTTSHandler creates a TTSHandler backed by the given audio.Manager.
+func NewTTSHandler(mgr *audio.Manager) *TTSHandler {
+ return &TTSHandler{manager: mgr}
+}
+
+// SetRateLimiter injects the rate limiter function (reused from the server's global limiter).
+func (h *TTSHandler) SetRateLimiter(fn func(string) bool) { h.rateLimiter = fn }
+
+// RegisterRoutes wires POST /v1/tts/synthesize onto mux with RoleOperator auth.
+func (h *TTSHandler) RegisterRoutes(mux *http.ServeMux) {
+ mux.HandleFunc("POST /v1/tts/synthesize",
+ requireAuth(permissions.RoleOperator, h.handleSynthesize))
+}
+
+// synthesizeRequest is the JSON body for POST /v1/tts/synthesize.
+type synthesizeRequest struct {
+ Text string `json:"text"`
+ Provider string `json:"provider,omitempty"`
+ VoiceID string `json:"voice_id,omitempty"`
+ ModelID string `json:"model_id,omitempty"`
+}
+
+const (
+ maxSynthesizeBodyBytes = 4 << 10 // 4KB — enough for 500 chars + metadata
+ maxSynthesizeTextChars = 500
+ synthesizeTimeout = 15 * time.Second
+)
+
+// handleSynthesize serves POST /v1/tts/synthesize.
+func (h *TTSHandler) handleSynthesize(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ locale := store.LocaleFromContext(ctx)
+
+ // Rate limit (best-effort — reuses per-IP/token limiter; no per-user bucket).
+ if h.rateLimiter != nil {
+ key := r.RemoteAddr
+ if tok := extractBearerToken(r); tok != "" {
+ key = "token:" + tok
+ }
+ if !h.rateLimiter(key) {
+ w.Header().Set("Retry-After", "60")
+ http.Error(w, fmt.Sprintf(`{"error":%q}`, i18n.T(locale, i18n.MsgRateLimitExceeded)), http.StatusTooManyRequests)
+ return
+ }
+ }
+
+ // Cap request body to prevent DoS via oversized JSON.
+ r.Body = http.MaxBytesReader(w, r.Body, maxSynthesizeBodyBytes)
+
+ var req synthesizeRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, fmt.Sprintf(`{"error":"invalid json: %s"}`, err.Error()), http.StatusBadRequest)
+ return
+ }
+
+ req.Text = strings.TrimSpace(req.Text)
+ if req.Text == "" {
+ http.Error(w, `{"error":"text is required"}`, http.StatusBadRequest)
+ return
+ }
+ if len([]rune(req.Text)) > maxSynthesizeTextChars {
+ http.Error(w, fmt.Sprintf(`{"error":"text exceeds %d chars"}`, maxSynthesizeTextChars), http.StatusBadRequest)
+ return
+ }
+
+ // Resolve provider — explicit name or fall back to manager's primary.
+ name := req.Provider
+ if name == "" {
+ name = h.manager.PrimaryProvider()
+ }
+ if name == "" {
+ http.Error(w, `{"error":"no tts provider configured"}`, http.StatusNotFound)
+ return
+ }
+ p, ok := h.manager.GetProvider(name)
+ if !ok {
+ http.Error(w, fmt.Sprintf(`{"error":%q}`, "provider not found: "+name), http.StatusNotFound)
+ return
+ }
+
+ // ElevenLabs model validation — rejects unknown model IDs with allowlist error.
+ if name == "elevenlabs" && req.ModelID != "" {
+ if err := elevenlabs.ValidateModel(req.ModelID); err != nil {
+ http.Error(w, fmt.Sprintf(`{"error":%q}`, err.Error()), http.StatusUnprocessableEntity)
+ return
+ }
+ }
+
+ // Synthesize with a 15-second deadline.
+ synthCtx, cancel := context.WithTimeout(ctx, synthesizeTimeout)
+ defer cancel()
+
+ opts := audio.TTSOptions{Voice: req.VoiceID, Model: req.ModelID}
+ start := time.Now()
+ result, err := p.Synthesize(synthCtx, req.Text, opts)
+ dur := time.Since(start)
+
+ if err != nil {
+ if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
+ slog.Warn("tts.synthesize.timeout", "provider", name, "ms", dur.Milliseconds())
+ http.Error(w, `{"error":"synthesis timeout"}`, http.StatusGatewayTimeout)
+ return
+ }
+ slog.Warn("tts.synthesize.failed", "provider", name, "error", err)
+ http.Error(w, `{"error":"upstream synthesis failed"}`, http.StatusBadGateway)
+ return
+ }
+
+ slog.Info("tts.synthesize.ok", "provider", name, "bytes", len(result.Audio), "ms", dur.Milliseconds())
+ w.Header().Set("Content-Type", result.MimeType)
+ w.Header().Set("Content-Length", fmt.Sprintf("%d", len(result.Audio)))
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write(result.Audio)
+}
diff --git a/internal/http/tts_test.go b/internal/http/tts_test.go
new file mode 100644
index 00000000..a0bb97b7
--- /dev/null
+++ b/internal/http/tts_test.go
@@ -0,0 +1,316 @@
+package http
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/google/uuid"
+ "github.com/nextlevelbuilder/goclaw/internal/audio"
+ "github.com/nextlevelbuilder/goclaw/internal/crypto"
+ "github.com/nextlevelbuilder/goclaw/internal/store"
+)
+
+const ttsTestToken = "tts-test-token"
+
+// --- Mock TTS provider ---
+
+type mockTTSProvider struct {
+ name string
+ capturedOpts audio.TTSOptions
+ result *audio.SynthResult
+ err error
+ block bool // if true, blocks until ctx is cancelled (timeout test)
+}
+
+func (m *mockTTSProvider) Name() string { return m.name }
+
+func (m *mockTTSProvider) Synthesize(ctx context.Context, text string, opts audio.TTSOptions) (*audio.SynthResult, error) {
+ m.capturedOpts = opts
+ if m.block {
+ <-ctx.Done()
+ return nil, ctx.Err()
+ }
+ if m.err != nil {
+ return nil, m.err
+ }
+ result := m.result
+ if result == nil {
+ result = &audio.SynthResult{
+ Audio: []byte("fake-mp3-bytes"),
+ Extension: "mp3",
+ MimeType: "audio/mpeg",
+ }
+ }
+ return result, nil
+}
+
+// newTTSMux creates a ServeMux wired with a TTSHandler backed by mgr.
+func newTTSMux(mgr *audio.Manager) *http.ServeMux {
+ h := NewTTSHandler(mgr)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+ return mux
+}
+
+// ttsBody builds the JSON body for POST /v1/tts/synthesize.
+func ttsBody(t *testing.T, v any) *bytes.Buffer {
+ t.Helper()
+ b, err := json.Marshal(v)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ return bytes.NewBuffer(b)
+}
+
+// --- Tests ---
+
+// TestSynthesize_Unauthenticated verifies 401 when a gateway token is configured
+// but no Authorization header is sent.
+func TestSynthesize_Unauthenticated(t *testing.T) {
+ setupTestToken(t, ttsTestToken)
+
+ mgr := audio.NewManager(audio.ManagerConfig{Primary: "mock"})
+ mgr.RegisterTTS(&mockTTSProvider{name: "mock"})
+ mux := newTTSMux(mgr)
+
+ req := httptest.NewRequest("POST", "/v1/tts/synthesize",
+ ttsBody(t, map[string]string{"text": "hello"}))
+ req.Header.Set("Content-Type", "application/json")
+ rr := httptest.NewRecorder()
+ mux.ServeHTTP(rr, req)
+
+ if rr.Code != http.StatusUnauthorized {
+ t.Errorf("want 401, got %d: %s", rr.Code, rr.Body.String())
+ }
+}
+
+// TestSynthesize_BelowOperator verifies 403 when the caller has only Viewer role.
+// Injects a viewer API key via setupTestCache so resolveAuth picks it up.
+func TestSynthesize_BelowOperator(t *testing.T) {
+ setupTestToken(t, ttsTestToken) // token set → dev-mode fallback disabled
+
+ viewerRaw := "viewer-api-key-raw"
+ setupTestCache(t, map[string]*store.APIKeyData{
+ crypto.HashAPIKey(viewerRaw): {
+ ID: uuid.New(),
+ Scopes: []string{"operator.read"},
+ },
+ })
+
+ mgr := audio.NewManager(audio.ManagerConfig{Primary: "mock"})
+ mgr.RegisterTTS(&mockTTSProvider{name: "mock"})
+ mux := newTTSMux(mgr)
+
+ req := httptest.NewRequest("POST", "/v1/tts/synthesize",
+ ttsBody(t, map[string]string{"text": "hello"}))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer "+viewerRaw)
+ rr := httptest.NewRecorder()
+ mux.ServeHTTP(rr, req)
+
+ if rr.Code != http.StatusForbidden {
+ t.Errorf("want 403, got %d: %s", rr.Code, rr.Body.String())
+ }
+}
+
+// TestSynthesize_MissingText verifies 400 when text field is empty.
+func TestSynthesize_MissingText(t *testing.T) {
+ setupTestToken(t, "") // dev mode — everyone is admin
+
+ mgr := audio.NewManager(audio.ManagerConfig{Primary: "mock"})
+ mgr.RegisterTTS(&mockTTSProvider{name: "mock"})
+ mux := newTTSMux(mgr)
+
+ req := httptest.NewRequest("POST", "/v1/tts/synthesize",
+ ttsBody(t, map[string]string{"text": ""}))
+ req.Header.Set("Content-Type", "application/json")
+ rr := httptest.NewRecorder()
+ mux.ServeHTTP(rr, req)
+
+ if rr.Code != http.StatusBadRequest {
+ t.Errorf("want 400, got %d: %s", rr.Code, rr.Body.String())
+ }
+}
+
+// TestSynthesize_TextTooLong verifies 400 when text exceeds 500 chars.
+func TestSynthesize_TextTooLong(t *testing.T) {
+ setupTestToken(t, "") // dev mode
+
+ mgr := audio.NewManager(audio.ManagerConfig{Primary: "mock"})
+ mgr.RegisterTTS(&mockTTSProvider{name: "mock"})
+ mux := newTTSMux(mgr)
+
+ longText := strings.Repeat("a", 501) // 501 runes — one over cap
+ req := httptest.NewRequest("POST", "/v1/tts/synthesize",
+ ttsBody(t, map[string]string{"text": longText}))
+ req.Header.Set("Content-Type", "application/json")
+ rr := httptest.NewRecorder()
+ mux.ServeHTTP(rr, req)
+
+ if rr.Code != http.StatusBadRequest {
+ t.Errorf("want 400, got %d: %s", rr.Code, rr.Body.String())
+ }
+}
+
+// TestSynthesize_UnknownProvider verifies 404 when the requested provider is not registered.
+func TestSynthesize_UnknownProvider(t *testing.T) {
+ setupTestToken(t, "") // dev mode
+
+ mgr := audio.NewManager(audio.ManagerConfig{Primary: "mock"})
+ mgr.RegisterTTS(&mockTTSProvider{name: "mock"})
+ mux := newTTSMux(mgr)
+
+ req := httptest.NewRequest("POST", "/v1/tts/synthesize",
+ ttsBody(t, map[string]string{"text": "hello", "provider": "nonexistent"}))
+ req.Header.Set("Content-Type", "application/json")
+ rr := httptest.NewRecorder()
+ mux.ServeHTTP(rr, req)
+
+ if rr.Code != http.StatusNotFound {
+ t.Errorf("want 404, got %d: %s", rr.Code, rr.Body.String())
+ }
+}
+
+// TestSynthesize_InvalidElevenLabsModel verifies 422 for an unknown ElevenLabs model.
+func TestSynthesize_InvalidElevenLabsModel(t *testing.T) {
+ setupTestToken(t, "") // dev mode
+
+ mgr := audio.NewManager(audio.ManagerConfig{Primary: "elevenlabs"})
+ mgr.RegisterTTS(&mockTTSProvider{name: "elevenlabs"})
+ mux := newTTSMux(mgr)
+
+ body := map[string]string{
+ "text": "hello",
+ "provider": "elevenlabs",
+ "model_id": "eleven_totally_fake_model",
+ }
+ req := httptest.NewRequest("POST", "/v1/tts/synthesize", ttsBody(t, body))
+ req.Header.Set("Content-Type", "application/json")
+ rr := httptest.NewRecorder()
+ mux.ServeHTTP(rr, req)
+
+ if rr.Code != http.StatusUnprocessableEntity {
+ t.Errorf("want 422, got %d: %s", rr.Code, rr.Body.String())
+ }
+}
+
+// TestSynthesize_Success verifies 200 with correct Content-Type and audio body.
+func TestSynthesize_Success(t *testing.T) {
+ setupTestToken(t, "") // dev mode
+
+ expected := []byte("audio-data-bytes")
+ p := &mockTTSProvider{
+ name: "mock",
+ result: &audio.SynthResult{
+ Audio: expected,
+ Extension: "mp3",
+ MimeType: "audio/mpeg",
+ },
+ }
+ mgr := audio.NewManager(audio.ManagerConfig{Primary: "mock"})
+ mgr.RegisterTTS(p)
+ mux := newTTSMux(mgr)
+
+ req := httptest.NewRequest("POST", "/v1/tts/synthesize",
+ ttsBody(t, map[string]string{"text": "hello world"}))
+ req.Header.Set("Content-Type", "application/json")
+ rr := httptest.NewRecorder()
+ mux.ServeHTTP(rr, req)
+
+ if rr.Code != http.StatusOK {
+ t.Fatalf("want 200, got %d: %s", rr.Code, rr.Body.String())
+ }
+ if ct := rr.Header().Get("Content-Type"); ct != "audio/mpeg" {
+ t.Errorf("Content-Type = %q, want audio/mpeg", ct)
+ }
+ if !bytes.Equal(rr.Body.Bytes(), expected) {
+ t.Errorf("body = %q, want %q", rr.Body.Bytes(), expected)
+ }
+}
+
+// TestSynthesize_Timeout verifies 504 when the provider's context is cancelled.
+// We use a pre-cancelled context to trigger context.Canceled immediately from
+// the mock, which the handler treats as a timeout (context.DeadlineExceeded or Canceled).
+func TestSynthesize_Timeout(t *testing.T) {
+ setupTestToken(t, "") // dev mode
+
+ p := &mockTTSProvider{name: "mock", block: true}
+ mgr := audio.NewManager(audio.ManagerConfig{Primary: "mock"})
+ mgr.RegisterTTS(p)
+
+ h := NewTTSHandler(mgr)
+ mux := http.NewServeMux()
+ h.RegisterRoutes(mux)
+
+ // Pre-cancel so the mock's <-ctx.Done() fires immediately.
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ req := httptest.NewRequest("POST", "/v1/tts/synthesize",
+ ttsBody(t, map[string]string{"text": "hello"}))
+ req.Header.Set("Content-Type", "application/json")
+ req = req.WithContext(ctx)
+ rr := httptest.NewRecorder()
+ mux.ServeHTTP(rr, req)
+
+ if rr.Code != http.StatusGatewayTimeout {
+ t.Errorf("want 504, got %d: %s", rr.Code, rr.Body.String())
+ }
+}
+
+// TestSynthesize_EdgeHonorsOpts verifies that voice_id is forwarded to the Edge provider,
+// proving the C1 fix is wired through the HTTP handler end-to-end.
+func TestSynthesize_EdgeHonorsOpts(t *testing.T) {
+ setupTestToken(t, "") // dev mode
+
+ edgeMock := &mockTTSProvider{name: "edge"}
+ mgr := audio.NewManager(audio.ManagerConfig{Primary: "edge"})
+ mgr.RegisterTTS(edgeMock)
+ mux := newTTSMux(mgr)
+
+ body := map[string]string{
+ "text": "xin chào",
+ "provider": "edge",
+ "voice_id": "vi-VN-HoaiMyNeural",
+ }
+ req := httptest.NewRequest("POST", "/v1/tts/synthesize", ttsBody(t, body))
+ req.Header.Set("Content-Type", "application/json")
+ rr := httptest.NewRecorder()
+ mux.ServeHTTP(rr, req)
+
+ if rr.Code != http.StatusOK {
+ t.Fatalf("want 200, got %d: %s", rr.Code, rr.Body.String())
+ }
+ if edgeMock.capturedOpts.Voice != "vi-VN-HoaiMyNeural" {
+ t.Errorf("capturedOpts.Voice = %q, want %q (voice_id not forwarded to provider)",
+ edgeMock.capturedOpts.Voice, "vi-VN-HoaiMyNeural")
+ }
+}
+
+// TestSynthesize_ValidElevenLabsModel verifies that a known ElevenLabs model passes through.
+func TestSynthesize_ValidElevenLabsModel(t *testing.T) {
+ setupTestToken(t, "") // dev mode
+
+ mgr := audio.NewManager(audio.ManagerConfig{Primary: "elevenlabs"})
+ mgr.RegisterTTS(&mockTTSProvider{name: "elevenlabs"})
+ mux := newTTSMux(mgr)
+
+ body := map[string]string{
+ "text": "hello",
+ "provider": "elevenlabs",
+ "model_id": "eleven_multilingual_v2",
+ }
+ req := httptest.NewRequest("POST", "/v1/tts/synthesize", ttsBody(t, body))
+ req.Header.Set("Content-Type", "application/json")
+ rr := httptest.NewRecorder()
+ mux.ServeHTTP(rr, req)
+
+ if rr.Code != http.StatusOK {
+ t.Errorf("want 200, got %d: %s", rr.Code, rr.Body.String())
+ }
+}
diff --git a/internal/tools/tts_override_test.go b/internal/tools/tts_override_test.go
index 728e63ef..6e00812c 100644
--- a/internal/tools/tts_override_test.go
+++ b/internal/tools/tts_override_test.go
@@ -78,3 +78,46 @@ func TestResolvePrimary_EmptyOverride_FallsBack(t *testing.T) {
t.Errorf("got %q, want openai (default)", got)
}
}
+
+// TestResolvePrimary_LegacyConfig verifies that pre-existing
+// builtin_tool_tenant_configs[tts] rows (legacy from before the /tts page
+// migration) are handled gracefully — no crash, no Error-level log.
+func TestResolvePrimary_LegacyConfig(t *testing.T) {
+ cases := []struct {
+ name string
+ rawSettings BuiltinToolSettings // simulates row from builtin_tool_tenant_configs
+ want string // expected resolved provider
+ }{
+ {
+ name: "valid legacy JSON returns configured provider",
+ rawSettings: BuiltinToolSettings{"tts": []byte(`{"primary":"elevenlabs"}`)},
+ want: "elevenlabs",
+ },
+ {
+ name: "malformed JSON falls back to manager primary",
+ rawSettings: BuiltinToolSettings{"tts": []byte(`not-an-object`)},
+ want: "openai",
+ },
+ {
+ name: "empty settings map falls back to manager primary",
+ rawSettings: BuiltinToolSettings{},
+ want: "openai",
+ },
+ {
+ name: "missing tts key falls back to manager primary",
+ rawSettings: BuiltinToolSettings{"stt": []byte(`{"primary":"whisper"}`)},
+ want: "openai",
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ tool := NewTtsTool(makeTTSManager("openai", "elevenlabs", "edge"))
+ ctx := WithBuiltinToolSettings(context.Background(), tc.rawSettings)
+ got := tool.resolvePrimary(ctx, tool.manager)
+ if got != tc.want {
+ t.Errorf("got %q, want %q", got, tc.want)
+ }
+ })
+ }
+}
diff --git a/ui/desktop/frontend/src/components/agents/AgentDetailPanel.tsx b/ui/desktop/frontend/src/components/agents/AgentDetailPanel.tsx
index bb5f5063..d09b743c 100644
--- a/ui/desktop/frontend/src/components/agents/AgentDetailPanel.tsx
+++ b/ui/desktop/frontend/src/components/agents/AgentDetailPanel.tsx
@@ -17,9 +17,12 @@ import { AgentSkillsSection } from './AgentSkillsSection'
import { AgentMcpSection } from './AgentMcpSection'
import { AgentFilesTab } from './AgentFilesTab'
import { VoicePicker } from './voice-picker'
+import { TtsEmptyState } from './tts-empty-state'
import { ConfirmDialog } from '../common/ConfirmDialog'
import { useAgentDetailState } from '../../hooks/use-agent-detail-state'
+import { useDesktopTtsConfig } from '../../hooks/use-tts-config'
import type { AgentData } from '../../types/agent'
+import type { TtsProviderId } from '@/data/tts-providers'
type DetailTab = 'overview' | 'evolution' | 'files'
@@ -57,6 +60,7 @@ export function AgentDetailPanel({ agent, onSave, onResummon, onClose }: AgentDe
const s = useAgentDetailState(agent, onSaveWithVoice, onClose)
const isPredefined = agent.agent_type === 'predefined'
+ const { globalProvider } = useDesktopTtsConfig()
const handleConfirmResummon = async () => {
setConfirmResummon(false)
@@ -184,12 +188,20 @@ export function AgentDetailPanel({ agent, onSave, onResummon, onClose }: AgentDe
- {/* TTS Voice section */}
+ {/* TTS Voice section — gated on global TTS provider being configured */}
{t('tts:voice_label')}
-
+ {globalProvider ? (
+
+ ) : (
+
+ )}
) : (
diff --git a/ui/desktop/frontend/src/components/agents/tts-empty-state.tsx b/ui/desktop/frontend/src/components/agents/tts-empty-state.tsx
new file mode 100644
index 00000000..c21ad237
--- /dev/null
+++ b/ui/desktop/frontend/src/components/agents/tts-empty-state.tsx
@@ -0,0 +1,31 @@
+import { useTranslation } from 'react-i18next'
+
+/**
+ * Shown in AgentDetailPanel TTS Voice section when no global TTS provider is configured.
+ * Desktop variant — no router navigation (single-user app, settings are in-app).
+ */
+export function TtsEmptyState() {
+ const { t } = useTranslation('tts')
+
+ return (
+
+ {/* VolumeX icon — inline SVG to avoid lucide-react dep on desktop */}
+
+
{t('empty_state.title')}
+
{t('empty_state.non_owner_hint')}
+
+ )
+}
diff --git a/ui/desktop/frontend/src/components/agents/voice-picker.tsx b/ui/desktop/frontend/src/components/agents/voice-picker.tsx
index 16a9e41e..a325c9bb 100644
--- a/ui/desktop/frontend/src/components/agents/voice-picker.tsx
+++ b/ui/desktop/frontend/src/components/agents/voice-picker.tsx
@@ -3,11 +3,16 @@ import { useVoices, useRefreshVoices } from '../../hooks/use-voices'
import { VoicePreviewButton } from './voice-preview-button'
import { Combobox } from '../common/Combobox'
import type { Voice } from '../../services/voices'
+import {
+ getProviderDefinition,
+ type TtsProviderId,
+} from '@/data/tts-providers'
interface Props {
value: string | null
onChange: (voiceId: string) => void
disabled?: boolean
+ provider?: TtsProviderId | ''
}
function VoiceOption({ voice, selected }: { voice: Voice; selected: boolean }) {
@@ -28,7 +33,73 @@ function VoiceOption({ voice, selected }: { voice: Voice; selected: boolean }) {
)
}
-export function VoicePicker({ value, onChange, disabled }: Props) {
+export function VoicePicker({ value, onChange, disabled, provider }: Props) {
+ if (provider === '') {
+ return
+ }
+ const def = provider ? getProviderDefinition(provider) : null
+ if (def && !def.dynamic) {
+ return (
+
+ )
+ }
+ return (
+
+ )
+}
+
+function EmptyStatePicker() {
+ const { t } = useTranslation('tts')
+ return (
+
+ {t('voice_picker.requires_provider')}
+
+ )
+}
+
+function StaticVoicePicker({
+ value,
+ onChange,
+ disabled,
+ voices,
+}: {
+ value: string | null
+ onChange: (id: string) => void
+ disabled?: boolean
+ voices: { value: string; label: string }[]
+}) {
+ const { t } = useTranslation('tts')
+ const options = voices.map((v) => ({ value: v.value, label: v.label }))
+ return (
+
+ )
+}
+
+function DynamicVoicePicker({
+ value,
+ onChange,
+ disabled,
+}: {
+ value: string | null
+ onChange: (voiceId: string) => void
+ disabled?: boolean
+}) {
const { t } = useTranslation('tts')
const { data: voices, isLoading } = useVoices()
const { mutate: refresh, isPending: refreshing } = useRefreshVoices()
@@ -70,7 +141,6 @@ export function VoicePicker({ value, onChange, disabled }: Props) {
- {/* Preview for selected voice */}
{value && voices.find((v) => v.voice_id === value) && (
{
+ const ids: TtsProviderId[] = ["openai", "elevenlabs", "edge", "minimax"];
+
+ it("has exactly 4 provider entries matching known IDs", () => {
+ expect(Object.keys(TTS_PROVIDERS).sort()).toEqual([...ids].sort());
+ });
+
+ it.each(ids)("%s.id matches its record key", (id) => {
+ expect(TTS_PROVIDERS[id].id).toBe(id);
+ });
+
+ it("ElevenLabs is dynamic, others are not", () => {
+ expect(TTS_PROVIDERS.elevenlabs.dynamic).toBe(true);
+ expect(TTS_PROVIDERS.openai.dynamic).toBe(false);
+ expect(TTS_PROVIDERS.edge.dynamic).toBe(false);
+ expect(TTS_PROVIDERS.minimax.dynamic).toBe(false);
+ });
+
+ it.each(ids.filter((id) => TTS_PROVIDERS[id].models.length > 0))(
+ "%s.defaultModel is in its models list",
+ (id) => {
+ const def = TTS_PROVIDERS[id];
+ if (def.defaultModel) {
+ const modelValues = def.models.map((m) => m.value);
+ expect(modelValues).toContain(def.defaultModel);
+ }
+ },
+ );
+
+ it.each(ids.filter((id) => !TTS_PROVIDERS[id].dynamic))(
+ "%s.defaultVoice is in its voices list",
+ (id) => {
+ const def = TTS_PROVIDERS[id];
+ if (def.defaultVoice) {
+ const voiceValues = def.voices.map((v) => v.value);
+ expect(voiceValues).toContain(def.defaultVoice);
+ }
+ },
+ );
+
+ it("Edge requires no API key", () => {
+ expect(TTS_PROVIDERS.edge.requiresApiKey).toBe(false);
+ });
+
+ it("OpenAI and MiniMax require an API key", () => {
+ expect(TTS_PROVIDERS.openai.requiresApiKey).toBe(true);
+ expect(TTS_PROVIDERS.minimax.requiresApiKey).toBe(true);
+ });
+
+ it("ElevenLabs exposes all 4 backend-allowlisted models", () => {
+ const modelIds = TTS_PROVIDERS.elevenlabs.models.map((m) => m.value);
+ expect(modelIds).toContain("eleven_v3");
+ expect(modelIds).toContain("eleven_flash_v2_5");
+ expect(modelIds).toContain("eleven_multilingual_v2");
+ expect(modelIds).toContain("eleven_turbo_v2_5");
+ expect(modelIds).toHaveLength(4);
+ });
+
+ it("OpenAI models include the 3 standard model IDs", () => {
+ const ids = TTS_PROVIDERS.openai.models.map((m) => m.value);
+ expect(ids).toContain("gpt-4o-mini-tts");
+ expect(ids).toContain("tts-1");
+ expect(ids).toContain("tts-1-hd");
+ });
+
+ it("Edge has 20 hardcoded voices", () => {
+ expect(TTS_PROVIDERS.edge.voices).toHaveLength(20);
+ });
+
+ it("getProviderDefinition returns null for unknown id", () => {
+ expect(getProviderDefinition("")).toBeNull();
+ expect(getProviderDefinition("nonexistent")).toBeNull();
+ });
+
+ it("getProviderDefinition returns correct definition for known id", () => {
+ expect(getProviderDefinition("openai")?.id).toBe("openai");
+ });
+});
diff --git a/ui/desktop/frontend/src/data/tts-providers.ts b/ui/desktop/frontend/src/data/tts-providers.ts
new file mode 100644
index 00000000..5c315e5d
--- /dev/null
+++ b/ui/desktop/frontend/src/data/tts-providers.ts
@@ -0,0 +1,158 @@
+// KEEP IN SYNC with ui/web/src/data/tts-providers.ts
+//
+// TTS provider catalog — source of truth for non-ElevenLabs providers.
+//
+// ElevenLabs voices are fetched dynamically via /v1/voices (dynamic: true).
+// OpenAI, Edge, and MiniMax voices are hardcoded here because their backends
+// accept any string and fall back to provider defaults.
+//
+// Backend provider name refs:
+// openai → internal/audio/openai/tts.go:59
+// elevenlabs → internal/audio/elevenlabs/tts.go Name()
+// edge → internal/audio/edge/tts.go:47
+// minimax → internal/audio/minimax/tts.go:64
+
+export type TtsProviderId = "openai" | "elevenlabs" | "edge" | "minimax";
+
+export interface TtsVoiceOption {
+ value: string;
+ label: string;
+ locale?: string;
+}
+
+export interface TtsModelOption {
+ value: string;
+ label: string;
+ description?: string;
+}
+
+export interface TtsProviderDefinition {
+ id: TtsProviderId;
+ /** true = voices fetched from /v1/voices; false = use voices[] below */
+ dynamic: boolean;
+ voices: TtsVoiceOption[];
+ models: TtsModelOption[];
+ defaultVoice?: string;
+ defaultModel?: string;
+ requiresApiKey: boolean;
+}
+
+export const TTS_PROVIDERS: Record = {
+ // ElevenLabs — voices fetched from server; models hardcoded from allowlist
+ // Ref: internal/audio/elevenlabs/models.go:14-19
+ // Defaults: internal/config/config_channels.go:499-500
+ elevenlabs: {
+ id: "elevenlabs",
+ dynamic: true,
+ voices: [],
+ models: [
+ { value: "eleven_v3", label: "Eleven v3" },
+ { value: "eleven_flash_v2_5", label: "Eleven Flash v2.5" },
+ { value: "eleven_multilingual_v2", label: "Eleven Multilingual v2" },
+ { value: "eleven_turbo_v2_5", label: "Eleven Turbo v2.5" },
+ ],
+ defaultVoice: "pMsXgVXv3BLzUgSXRplE",
+ defaultModel: "eleven_multilingual_v2",
+ requiresApiKey: true,
+ },
+
+ // OpenAI TTS — docs: https://platform.openai.com/docs/guides/text-to-speech
+ // Ref: internal/audio/openai/tts.go:46-51
+ openai: {
+ id: "openai",
+ dynamic: false,
+ voices: [
+ { value: "alloy", label: "Alloy" },
+ { value: "echo", label: "Echo" },
+ { value: "fable", label: "Fable" },
+ { value: "onyx", label: "Onyx" },
+ { value: "nova", label: "Nova" },
+ { value: "shimmer", label: "Shimmer" },
+ { value: "coral", label: "Coral" },
+ { value: "sage", label: "Sage" },
+ ],
+ models: [
+ { value: "gpt-4o-mini-tts", label: "GPT-4o Mini TTS" },
+ { value: "tts-1", label: "TTS-1" },
+ { value: "tts-1-hd", label: "TTS-1 HD" },
+ ],
+ defaultVoice: "alloy",
+ defaultModel: "gpt-4o-mini-tts",
+ requiresApiKey: true,
+ },
+
+ // Microsoft Edge TTS — free, no API key required
+ // Ref: internal/audio/edge/tts.go:37-39
+ // Top-20 neural voices from edge-tts registry
+ edge: {
+ id: "edge",
+ dynamic: false,
+ voices: [
+ { value: "en-US-MichelleNeural", label: "Michelle (en-US)", locale: "en-US" },
+ { value: "en-US-AriaNeural", label: "Aria (en-US)", locale: "en-US" },
+ { value: "en-US-GuyNeural", label: "Guy (en-US)", locale: "en-US" },
+ { value: "en-GB-SoniaNeural", label: "Sonia (en-GB)", locale: "en-GB" },
+ { value: "en-GB-RyanNeural", label: "Ryan (en-GB)", locale: "en-GB" },
+ { value: "en-AU-NatashaNeural", label: "Natasha (en-AU)", locale: "en-AU" },
+ { value: "vi-VN-HoaiMyNeural", label: "Hoài My (vi-VN)", locale: "vi-VN" },
+ { value: "vi-VN-NamMinhNeural", label: "Nam Minh (vi-VN)", locale: "vi-VN" },
+ { value: "zh-CN-XiaoxiaoNeural", label: "Xiaoxiao (zh-CN)", locale: "zh-CN" },
+ { value: "zh-CN-YunxiNeural", label: "Yunxi (zh-CN)", locale: "zh-CN" },
+ { value: "ja-JP-NanamiNeural", label: "Nanami (ja-JP)", locale: "ja-JP" },
+ { value: "ko-KR-SunHiNeural", label: "Sun Hi (ko-KR)", locale: "ko-KR" },
+ { value: "es-ES-ElviraNeural", label: "Elvira (es-ES)", locale: "es-ES" },
+ { value: "fr-FR-DeniseNeural", label: "Denise (fr-FR)", locale: "fr-FR" },
+ { value: "de-DE-KatjaNeural", label: "Katja (de-DE)", locale: "de-DE" },
+ { value: "it-IT-ElsaNeural", label: "Elsa (it-IT)", locale: "it-IT" },
+ { value: "pt-BR-FranciscaNeural", label: "Francisca (pt-BR)", locale: "pt-BR" },
+ { value: "ru-RU-SvetlanaNeural", label: "Svetlana (ru-RU)", locale: "ru-RU" },
+ { value: "hi-IN-SwaraNeural", label: "Swara (hi-IN)", locale: "hi-IN" },
+ { value: "id-ID-GadisNeural", label: "Gadis (id-ID)", locale: "id-ID" },
+ ],
+ models: [],
+ defaultVoice: "en-US-MichelleNeural",
+ requiresApiKey: false,
+ },
+
+ // MiniMax TTS — docs: https://platform.minimax.io/docs/api-reference/speech-t2a-intro
+ // Ref: internal/audio/minimax/tts.go:50-56
+ minimax: {
+ id: "minimax",
+ dynamic: false,
+ voices: [
+ { value: "Wise_Woman", label: "Wise Woman" },
+ { value: "Friendly_Person", label: "Friendly Person" },
+ { value: "Inspirational_girl", label: "Inspirational Girl" },
+ { value: "Deep_Voice_Man", label: "Deep Voice Man" },
+ { value: "Calm_Woman", label: "Calm Woman" },
+ { value: "Casual_Guy", label: "Casual Guy" },
+ { value: "Lively_Girl", label: "Lively Girl" },
+ { value: "Patient_Man", label: "Patient Man" },
+ { value: "Young_Knight", label: "Young Knight" },
+ { value: "Determined_Man", label: "Determined Man" },
+ { value: "Lovely_Girl", label: "Lovely Girl" },
+ { value: "Decent_Boy", label: "Decent Boy" },
+ { value: "Imposing_Manner", label: "Imposing Manner" },
+ { value: "Elegant_Man", label: "Elegant Man" },
+ { value: "Abbess", label: "Abbess" },
+ { value: "Sweet_Girl_2", label: "Sweet Girl 2" },
+ { value: "Exuberant_Girl", label: "Exuberant Girl" },
+ ],
+ models: [
+ { value: "speech-02-hd", label: "Speech-02 HD" },
+ { value: "speech-02-turbo", label: "Speech-02 Turbo" },
+ { value: "speech-01-hd", label: "Speech-01 HD" },
+ { value: "speech-01-turbo", label: "Speech-01 Turbo" },
+ ],
+ defaultVoice: "Wise_Woman",
+ defaultModel: "speech-02-hd",
+ requiresApiKey: true,
+ },
+};
+
+/**
+ * Returns provider definition by id, or null if id is unknown/empty.
+ */
+export function getProviderDefinition(id: string): TtsProviderDefinition | null {
+ return TTS_PROVIDERS[id as TtsProviderId] ?? null;
+}
diff --git a/ui/desktop/frontend/src/hooks/use-tts-config.ts b/ui/desktop/frontend/src/hooks/use-tts-config.ts
new file mode 100644
index 00000000..3388971b
--- /dev/null
+++ b/ui/desktop/frontend/src/hooks/use-tts-config.ts
@@ -0,0 +1,42 @@
+import { useState, useEffect, useCallback } from 'react'
+import { getWsClient } from '../lib/ws'
+
+interface ConfigResponse {
+ config?: { tts?: { provider?: string } }
+}
+
+/**
+ * Read-only desktop TTS config hook.
+ * Calls WS `config.get` (no REST GET /v1/config exists on gateway).
+ * Pattern mirrors use-channel-status.ts and use-paired-devices.ts.
+ */
+export function useDesktopTtsConfig() {
+ const [globalProvider, setGlobalProvider] = useState('')
+ const [loading, setLoading] = useState(true)
+
+ const fetchConfig = useCallback(async () => {
+ try {
+ const ws = getWsClient()
+ const res = (await ws.call('config.get')) as ConfigResponse
+ setGlobalProvider(res.config?.tts?.provider ?? '')
+ } catch {
+ // Gateway may not be connected yet — treat as unconfigured.
+ setGlobalProvider('')
+ } finally {
+ setLoading(false)
+ }
+ }, [])
+
+ useEffect(() => {
+ fetchConfig()
+ // Subscribe to config.updated event if gateway emits it — invalidate cache.
+ let unsub: (() => void) | undefined
+ try {
+ const ws = getWsClient()
+ unsub = ws.on('config.updated', () => { void fetchConfig() })
+ } catch { /* ws not ready */ }
+ return () => { unsub?.() }
+ }, [fetchConfig])
+
+ return { globalProvider, loading }
+}
diff --git a/ui/desktop/frontend/src/i18n/locales/en/tts.json b/ui/desktop/frontend/src/i18n/locales/en/tts.json
index 7304837a..a92c1e70 100644
--- a/ui/desktop/frontend/src/i18n/locales/en/tts.json
+++ b/ui/desktop/frontend/src/i18n/locales/en/tts.json
@@ -70,6 +70,9 @@
},
"voice_label": "Voice",
"voice_placeholder": "Select a voice…",
+ "voice_picker": {
+ "requires_provider": "Configure TTS provider first"
+ },
"voice_refresh": "Refresh",
"voice_preview": "Preview",
"voice_stop_preview": "Stop",
@@ -77,5 +80,53 @@
"voice_loading": "Loading voices…",
"voice_preview_error": "Preview failed — refreshing list",
"model_label": "Model",
- "model_placeholder": "Default (tenant/system)"
+ "model_placeholder": "Default (tenant/system)",
+ "empty_state": {
+ "title": "AI voice is not configured",
+ "description": "Configure TTS so agents can reply with audio.",
+ "cta": "Configure TTS",
+ "non_owner_hint": "Contact an owner to enable AI voice."
+ },
+ "test": {
+ "button": "Test this voice",
+ "sample_text": "Hello, this is a voice test.",
+ "title": "Listen",
+ "placeholder": "Enter text to hear a sample…",
+ "play": "Play",
+ "stop": "Stop",
+ "error_missing_config": "Choose a provider and enter an API key first.",
+ "error_synthesize": "Could not generate audio: {{error}}"
+ },
+ "override": {
+ "label": "Override voice for this agent",
+ "inherits": "Inherits: {{provider}} · {{voice}} · {{model}}"
+ },
+ "testConnection": {
+ "label": "Test connection",
+ "testing": "Testing…",
+ "success": "Connection successful",
+ "failed": "Connection failed"
+ },
+ "connection": {
+ "test": "Test connection",
+ "ok": "Connection OK",
+ "fail": "Invalid key or network error"
+ },
+ "agent": {
+ "override_label": "Override voice for this agent",
+ "inherits_from_global": "Inherits from global: {{provider}} · {{voice}} · {{model}}"
+ },
+ "playground": {
+ "title": "Test Playground",
+ "inputLabel": "Text to synthesize",
+ "placeholder": "Enter text to hear…",
+ "play": "Play",
+ "stop": "Stop",
+ "synthesizing": "Synthesizing…",
+ "playbackFailed": "Playback failed",
+ "requiresProvider": "Select a provider above to enable test playback."
+ },
+ "advanced": {
+ "title": "Advanced settings"
+ }
}
diff --git a/ui/desktop/frontend/src/i18n/locales/vi/tts.json b/ui/desktop/frontend/src/i18n/locales/vi/tts.json
index b9760095..a2dae799 100644
--- a/ui/desktop/frontend/src/i18n/locales/vi/tts.json
+++ b/ui/desktop/frontend/src/i18n/locales/vi/tts.json
@@ -70,6 +70,9 @@
},
"voice_label": "Giọng đọc",
"voice_placeholder": "Chọn giọng đọc…",
+ "voice_picker": {
+ "requires_provider": "Vui lòng cấu hình provider TTS trước"
+ },
"voice_refresh": "Làm mới",
"voice_preview": "Nghe thử",
"voice_stop_preview": "Dừng",
@@ -77,5 +80,53 @@
"voice_loading": "Đang tải giọng đọc…",
"voice_preview_error": "Không nghe thử được — đang làm mới",
"model_label": "Mô hình",
- "model_placeholder": "Mặc định (tenant/hệ thống)"
+ "model_placeholder": "Mặc định (tenant/hệ thống)",
+ "empty_state": {
+ "title": "Giọng đọc AI chưa được cấu hình",
+ "description": "Cấu hình TTS để agent trả lời bằng giọng nói",
+ "cta": "Cấu hình TTS",
+ "non_owner_hint": "Liên hệ owner để bật giọng đọc AI"
+ },
+ "test": {
+ "button": "Thử giọng đọc",
+ "sample_text": "Xin chào, đây là giọng đọc thử.",
+ "title": "Nghe thử",
+ "placeholder": "Nhập text để nghe thử...",
+ "play": "Phát",
+ "stop": "Dừng",
+ "error_missing_config": "Chọn provider và nhập API key trước",
+ "error_synthesize": "Không tạo được audio: {{error}}"
+ },
+ "override": {
+ "label": "Ghi đè giọng đọc cho agent này",
+ "inherits": "Kế thừa: {{provider}} · {{voice}} · {{model}}"
+ },
+ "testConnection": {
+ "label": "Kiểm tra kết nối",
+ "testing": "Đang kiểm tra…",
+ "success": "Kết nối thành công",
+ "failed": "Kết nối thất bại"
+ },
+ "connection": {
+ "test": "Kiểm tra kết nối",
+ "ok": "Kết nối OK",
+ "fail": "Key không hợp lệ hoặc network lỗi"
+ },
+ "agent": {
+ "override_label": "Ghi đè voice cho agent này",
+ "inherits_from_global": "Kế thừa từ global: {{provider}} · {{voice}} · {{model}}"
+ },
+ "playground": {
+ "title": "Thử nghiệm",
+ "inputLabel": "Văn bản cần đọc",
+ "placeholder": "Nhập văn bản để nghe thử…",
+ "play": "Phát",
+ "stop": "Dừng",
+ "synthesizing": "Đang tổng hợp…",
+ "playbackFailed": "Phát âm thất bại",
+ "requiresProvider": "Chọn provider ở trên để bật phát thử."
+ },
+ "advanced": {
+ "title": "Cài đặt nâng cao"
+ }
}
diff --git a/ui/desktop/frontend/src/i18n/locales/zh/tts.json b/ui/desktop/frontend/src/i18n/locales/zh/tts.json
index b6f2811d..4394277c 100644
--- a/ui/desktop/frontend/src/i18n/locales/zh/tts.json
+++ b/ui/desktop/frontend/src/i18n/locales/zh/tts.json
@@ -70,6 +70,9 @@
},
"voice_label": "声音",
"voice_placeholder": "选择声音…",
+ "voice_picker": {
+ "requires_provider": "请先配置 TTS Provider"
+ },
"voice_refresh": "刷新",
"voice_preview": "试听",
"voice_stop_preview": "停止",
@@ -77,5 +80,53 @@
"voice_loading": "加载声音中…",
"voice_preview_error": "试听失败 — 正在刷新",
"model_label": "模型",
- "model_placeholder": "默认(租户/系统)"
+ "model_placeholder": "默认(租户/系统)",
+ "empty_state": {
+ "title": "AI 语音尚未配置",
+ "description": "配置 TTS 让智能体以语音回复",
+ "cta": "配置 TTS",
+ "non_owner_hint": "请联系 owner 启用 AI 语音"
+ },
+ "test": {
+ "button": "测试语音",
+ "sample_text": "你好,这是语音测试。",
+ "title": "试听",
+ "placeholder": "输入文本进行试听…",
+ "play": "播放",
+ "stop": "停止",
+ "error_missing_config": "请先选择提供方并输入 API key",
+ "error_synthesize": "无法生成音频:{{error}}"
+ },
+ "override": {
+ "label": "为此 Agent 覆盖语音",
+ "inherits": "继承: {{provider}} · {{voice}} · {{model}}"
+ },
+ "testConnection": {
+ "label": "测试连接",
+ "testing": "测试中…",
+ "success": "连接成功",
+ "failed": "连接失败"
+ },
+ "connection": {
+ "test": "测试连接",
+ "ok": "连接正常",
+ "fail": "Key 无效或网络错误"
+ },
+ "agent": {
+ "override_label": "为此智能体覆盖语音",
+ "inherits_from_global": "继承自全局:{{provider}} · {{voice}} · {{model}}"
+ },
+ "playground": {
+ "title": "测试播放",
+ "inputLabel": "要合成的文本",
+ "placeholder": "输入文本以试听…",
+ "play": "播放",
+ "stop": "停止",
+ "synthesizing": "合成中…",
+ "playbackFailed": "播放失败",
+ "requiresProvider": "请先选择 Provider 以启用试听。"
+ },
+ "advanced": {
+ "title": "高级设置"
+ }
}
diff --git a/ui/web/src/components/voice-picker.tsx b/ui/web/src/components/voice-picker.tsx
index d5d80827..416e46eb 100644
--- a/ui/web/src/components/voice-picker.tsx
+++ b/ui/web/src/components/voice-picker.tsx
@@ -5,14 +5,33 @@ import { createPortal } from "react-dom";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { useVoices, useRefreshVoices, type Voice } from "@/api/voices";
import { VoicePreviewButton } from "@/components/voice-preview-button";
+import {
+ getProviderDefinition,
+ type TtsProviderId,
+} from "@/data/tts-providers";
interface Props {
value?: string;
onChange: (id: string) => void;
disabled?: boolean;
+ /**
+ * Controls picker mode:
+ * - undefined / "elevenlabs" → dynamic fetch from /v1/voices (legacy behavior).
+ * - "openai" | "edge" | "minimax" → hardcoded list from catalog.
+ * - "" (empty string) → disabled; shows "Configure TTS provider first".
+ */
+ provider?: TtsProviderId | "";
+ placeholder?: string;
}
const LABEL_KEYS = ["gender", "accent", "age", "use_case"] as const;
@@ -44,7 +63,96 @@ function VoiceRow({ voice, selected, onSelect }: { voice: Voice; selected: boole
);
}
-export function VoicePicker({ value, onChange, disabled }: Props) {
+/**
+ * Top-level picker. Dispatches to one of three sub-components based on `provider`:
+ * - "" → disabled empty-state
+ * - non-dynamic provider (openai/edge/minimax) →