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
  }
This commit is contained in:
Myka 2026-02-17 11:21:49 +03:00
parent c3a7629343
commit 1e68aa12b0
4 changed files with 83 additions and 26 deletions

View file

@ -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,

View file

@ -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": {

View file

@ -220,6 +220,9 @@ 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"`
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{

View file

@ -13,11 +13,14 @@ 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
}
@ -26,28 +29,59 @@ type kokoroRequest struct {
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"
}
if voice == "" {
voice = "af_nova"
return NewKokoroSynthesizerFromProfile(TTSProfile{
APIBase: apiBase,
Voice: voice,
})
}
logger.InfoCF("voice", "Creating Kokoro TTS synthesizer", map[string]interface{}{
"api_base": 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 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 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)
}