From 1e68aa12b0b100440a2445d454726e3af664362d Mon Sep 17 00:00:00 2001 From: Myka Date: Tue, 17 Feb 2026 11:21:49 +0300 Subject: [PATCH] feat(voice/tts): add full voice profile to TTSConfig Expand TTSConfig with model, format, and speed so the agent's voice is fully configurable from config.json without touching code. - TTSConfig gains: Model, Format, Speed fields (all env-overridable) - KokoroSynthesizer: add TTSProfile struct + NewKokoroSynthesizerFromProfile() NewKokoroSynthesizer() is kept as a convenience wrapper (backward-compat) - kokoroRequest: pass format and speed through to the API - Temp file extension follows configured format (mp3/wav/ogg/etc.) - main.go: wire all profile fields from config - config.example.json: updated with model/format/speed examples Full profile example: "tts": { "enabled": true, "api_base": "http://localhost:8100", "voice": "af_nova", "model": "kokoro", "format": "mp3", "speed": 1.0 } --- cmd/picoclaw/main.go | 8 +++- config/config.example.json | 14 ++++++- pkg/config/config.go | 12 ++++-- pkg/voice/kokoro.go | 75 ++++++++++++++++++++++++++++---------- 4 files changed, 83 insertions(+), 26 deletions(-) diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index a00302cb0..52f82d84f 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -636,7 +636,13 @@ func gatewayCmd() { // Attach TTS synthesis callbacks to the message tool (enables voice=true). if cfg.Tools.TTS.Enabled { - synthesizer := voice.NewKokoroSynthesizer(cfg.Tools.TTS.APIBase, cfg.Tools.TTS.Voice) + synthesizer := voice.NewKokoroSynthesizerFromProfile(voice.TTSProfile{ + APIBase: cfg.Tools.TTS.APIBase, + Voice: cfg.Tools.TTS.Voice, + Model: cfg.Tools.TTS.Model, + Format: cfg.Tools.TTS.Format, + Speed: cfg.Tools.TTS.Speed, + }) if synthesizer.IsAvailable() { logger.InfoCF("voice", "TTS enabled — voice=true supported in message tool", map[string]interface{}{ "api_base": cfg.Tools.TTS.APIBase, diff --git a/config/config.example.json b/config/config.example.json index 3c9158e9c..d7883532a 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -14,7 +14,9 @@ "enabled": false, "token": "YOUR_TELEGRAM_BOT_TOKEN", "proxy": "", - "allow_from": ["YOUR_USER_ID"] + "allow_from": [ + "YOUR_USER_ID" + ] }, "discord": { "enabled": false, @@ -119,6 +121,14 @@ "api_key": "YOUR_BRAVE_API_KEY", "max_results": 5 } + }, + "tts": { + "enabled": false, + "api_base": "http://localhost:8100", + "voice": "en_us-lessac-medium", + "model": "tts-1", + "format": "mp3", + "speed": 1.0 } }, "heartbeat": { @@ -133,4 +143,4 @@ "host": "0.0.0.0", "port": 18790 } -} +} \ No newline at end of file diff --git a/pkg/config/config.go b/pkg/config/config.go index 2755ed573..00cd00a2e 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -217,9 +217,12 @@ type WhisperConfig struct { } type TTSConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_TTS_ENABLED"` - APIBase string `json:"api_base" env:"PICOCLAW_TOOLS_TTS_API_BASE"` - Voice string `json:"voice" env:"PICOCLAW_TOOLS_TTS_VOICE"` + Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_TTS_ENABLED"` + APIBase string `json:"api_base" env:"PICOCLAW_TOOLS_TTS_API_BASE"` + Voice string `json:"voice" env:"PICOCLAW_TOOLS_TTS_VOICE"` + Model string `json:"model" env:"PICOCLAW_TOOLS_TTS_MODEL"` + Format string `json:"format" env:"PICOCLAW_TOOLS_TTS_FORMAT"` + Speed float64 `json:"speed" env:"PICOCLAW_TOOLS_TTS_SPEED"` } type ToolsConfig struct { @@ -343,6 +346,9 @@ func DefaultConfig() *Config { Enabled: false, APIBase: "http://localhost:8100", Voice: "en_us-lessac-medium", + Model: "tts-1", + Format: "mp3", + Speed: 1.0, }, }, Heartbeat: HeartbeatConfig{ diff --git a/pkg/voice/kokoro.go b/pkg/voice/kokoro.go index c1e79e364..a73d80f31 100644 --- a/pkg/voice/kokoro.go +++ b/pkg/voice/kokoro.go @@ -13,41 +13,75 @@ import ( "github.com/sipeed/picoclaw/pkg/logger" ) -// KokoroSynthesizer uses a Kokoro TTS server (OpenAI-compatible /v1/audio/speech API). +// KokoroSynthesizer uses any OpenAI-compatible /v1/audio/speech endpoint +// (Kokoro, Piper, Chatterbox, OpenAI, etc.). type KokoroSynthesizer struct { apiBase string voice string model string + format string + speed float64 httpClient *http.Client } type kokoroRequest struct { - Model string `json:"model"` - Input string `json:"input"` - Voice string `json:"voice"` - Format string `json:"response_format,omitempty"` + Model string `json:"model"` + Input string `json:"input"` + Voice string `json:"voice"` + Format string `json:"response_format,omitempty"` + Speed float64 `json:"speed,omitempty"` } -// NewKokoroSynthesizer creates a Kokoro TTS client. -// apiBase defaults to "http://localhost:8102". -// voice defaults to "af_nova". +// TTSProfile holds the full voice profile for the synthesizer. +type TTSProfile struct { + APIBase string + Voice string + Model string + Format string + Speed float64 +} + +// NewKokoroSynthesizer creates a TTS client from a voice profile. +// Sensible defaults are applied for any zero-value field. func NewKokoroSynthesizer(apiBase, voice string) *KokoroSynthesizer { - if apiBase == "" { - apiBase = "http://localhost:8102" + return NewKokoroSynthesizerFromProfile(TTSProfile{ + APIBase: apiBase, + Voice: voice, + }) +} + +// NewKokoroSynthesizerFromProfile creates a TTS client with full profile control. +func NewKokoroSynthesizerFromProfile(p TTSProfile) *KokoroSynthesizer { + if p.APIBase == "" { + p.APIBase = "http://localhost:8100" } - if voice == "" { - voice = "af_nova" + if p.Voice == "" { + p.Voice = "en_us-lessac-medium" + } + if p.Model == "" { + p.Model = "tts-1" + } + if p.Format == "" { + p.Format = "mp3" + } + if p.Speed == 0 { + p.Speed = 1.0 } - logger.InfoCF("voice", "Creating Kokoro TTS synthesizer", map[string]interface{}{ - "api_base": apiBase, - "voice": voice, + logger.InfoCF("voice", "Creating TTS synthesizer", map[string]interface{}{ + "api_base": p.APIBase, + "voice": p.Voice, + "model": p.Model, + "format": p.Format, + "speed": p.Speed, }) return &KokoroSynthesizer{ - apiBase: apiBase, - voice: voice, - model: "kokoro", + apiBase: p.APIBase, + voice: p.Voice, + model: p.Model, + format: p.Format, + speed: p.Speed, httpClient: &http.Client{ Timeout: 60 * time.Second, }, @@ -66,7 +100,8 @@ func (s *KokoroSynthesizer) Synthesize(ctx context.Context, text string) (string Model: s.model, Input: text, Voice: s.voice, - Format: "mp3", + Format: s.format, + Speed: s.speed, } bodyBytes, err := json.Marshal(reqBody) @@ -93,7 +128,7 @@ func (s *KokoroSynthesizer) Synthesize(ctx context.Context, text string) (string } // Write audio to temp file - tmpFile, err := os.CreateTemp("", "picoclaw-tts-*.mp3") + tmpFile, err := os.CreateTemp("", "picoclaw-tts-*."+s.format) if err != nil { return "", fmt.Errorf("failed to create temp audio file: %w", err) }