From 4b6fd66e045620a3592df4a9e422f8ebbd7cb936 Mon Sep 17 00:00:00 2001 From: Huaaudio Date: Mon, 23 Mar 2026 14:15:51 +0100 Subject: [PATCH] add tts tool --- config/config.example.json | 3 + pkg/agent/loop.go | 17 ++++++ pkg/config/config.go | 3 + pkg/config/defaults.go | 3 + pkg/tools/tts_send.go | 115 +++++++++++++++++++++++++++++++++++++ 5 files changed, 141 insertions(+) create mode 100644 pkg/tools/tts_send.go diff --git a/config/config.example.json b/config/config.example.json index 88578701a..94cd58a19 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -522,6 +522,9 @@ "read_file": { "enabled": true }, + "send_tts": { + "enabled": false + }, "spawn": { "enabled": true }, diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 39032156a..22b9bf3d7 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -31,6 +31,7 @@ import ( "github.com/sipeed/picoclaw/pkg/skills" "github.com/sipeed/picoclaw/pkg/state" "github.com/sipeed/picoclaw/pkg/tools" + "github.com/sipeed/picoclaw/pkg/tts" "github.com/sipeed/picoclaw/pkg/utils" ) @@ -155,6 +156,13 @@ func registerSharedTools( provider providers.LLMProvider, ) { allowReadPaths := buildAllowReadPatterns(cfg) + var ttsProvider tts.TTSProvider + if cfg.Tools.IsToolEnabled("send_tts") { + ttsProvider = tts.DetectTTS(cfg) + if ttsProvider == nil { + logger.WarnCF("voice-tts", "send_tts enabled but no TTS provider configured", nil) + } + } for _, agentID := range registry.ListAgentIDs() { agent, ok := registry.GetAgent(agentID) @@ -251,6 +259,10 @@ func registerSharedTools( agent.Tools.Register(sendFileTool) } + if ttsProvider != nil { + agent.Tools.Register(tools.NewSendTTSTool(ttsProvider, nil)) + } + // Skill discovery and installation tools skills_enabled := cfg.Tools.IsToolEnabled("skills") find_skills_enable := cfg.Tools.IsToolEnabled("find_skills") @@ -1038,6 +1050,11 @@ func (al *AgentLoop) SetMediaStore(s media.MediaStore) { sf.SetMediaStore(s) } }) + registry.ForEachTool("send_tts", func(t tools.Tool) { + if st, ok := t.(*tools.SendTTSTool); ok { + st.SetMediaStore(s) + } + }) } // SetTranscriber injects a voice transcriber for agent-level audio transcription. diff --git a/pkg/config/config.go b/pkg/config/config.go index 33919d9d7..9c4e842b4 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -1218,6 +1218,7 @@ type ToolsConfig struct { Message ToolConfig `json:"message" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"` ReadFile ReadFileToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"` SendFile ToolConfig `json:"send_file" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"` + SendTTS ToolConfig `json:"send_tts" envPrefix:"PICOCLAW_TOOLS_SEND_TTS_"` Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"` SpawnStatus ToolConfig `json:"spawn_status" envPrefix:"PICOCLAW_TOOLS_SPAWN_STATUS_"` SPI ToolConfig `json:"spi" envPrefix:"PICOCLAW_TOOLS_SPI_"` @@ -2156,6 +2157,8 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool { return t.WebFetch.Enabled case "send_file": return t.SendFile.Enabled + case "send_tts": + return t.SendTTS.Enabled case "write_file": return t.WriteFile.Enabled case "mcp": diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index ccfd5732a..2901c3687 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -460,6 +460,9 @@ func DefaultConfig() *Config { SendFile: ToolConfig{ Enabled: true, }, + SendTTS: ToolConfig{ + Enabled: false, + }, MCP: MCPConfig{ ToolConfig: ToolConfig{ Enabled: false, diff --git a/pkg/tools/tts_send.go b/pkg/tools/tts_send.go new file mode 100644 index 000000000..4fae77a50 --- /dev/null +++ b/pkg/tools/tts_send.go @@ -0,0 +1,115 @@ +package tools + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/media" + "github.com/sipeed/picoclaw/pkg/tts" +) + +type SendTTSTool struct { + provider tts.TTSProvider + mediaStore media.MediaStore +} + +func NewSendTTSTool(provider tts.TTSProvider, store media.MediaStore) *SendTTSTool { + return &SendTTSTool{ + provider: provider, + mediaStore: store, + } +} + +func (t *SendTTSTool) Name() string { return "send_tts" } + +func (t *SendTTSTool) Description() string { + return "Synthesize speech from text and send it as an audio file to the user." +} + +func (t *SendTTSTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "text": map[string]any{ + "type": "string", + "description": "The text to synthesize into speech.", + }, + "filename": map[string]any{ + "type": "string", + "description": "Optional filename for the audio file (e.g., response.ogg).", + }, + }, + "required": []string{"text"}, + } +} + +func (t *SendTTSTool) SetMediaStore(store media.MediaStore) { + t.mediaStore = store +} + +func (t *SendTTSTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + text, _ := args["text"].(string) + text = strings.TrimSpace(text) + if text == "" { + return ErrorResult("text is required") + } + + if t.provider == nil { + return ErrorResult("tts provider is not configured") + } + if t.mediaStore == nil { + return ErrorResult("media store not configured") + } + + channel := ToolChannel(ctx) + chatID := ToolChatID(ctx) + if channel == "" || chatID == "" { + return ErrorResult("no target channel/chat available") + } + + stream, err := t.provider.Synthesize(ctx, text) + if err != nil { + return ErrorResult(fmt.Sprintf("tts synthesize failed: %v", err)).WithError(err) + } + defer stream.Close() + + if err := os.MkdirAll(media.TempDir(), 0o755); err != nil { + return ErrorResult(fmt.Sprintf("failed to create media temp dir: %v", err)).WithError(err) + } + + file, err := os.CreateTemp(media.TempDir(), "tts-*.ogg") + if err != nil { + return ErrorResult(fmt.Sprintf("failed to create temp file: %v", err)).WithError(err) + } + defer file.Close() + + if _, err := io.Copy(file, stream); err != nil { + return ErrorResult(fmt.Sprintf("failed to write tts audio: %v", err)).WithError(err) + } + + filename, _ := args["filename"].(string) + filename = strings.TrimSpace(filename) + if filename == "" { + filename = fmt.Sprintf("tts-%d.ogg", time.Now().Unix()) + } + if filepath.Ext(filename) == "" { + filename += ".ogg" + } + + scope := fmt.Sprintf("tool:send_tts:%s:%s:%d", channel, chatID, time.Now().UnixNano()) + ref, err := t.mediaStore.Store(file.Name(), media.MediaMeta{ + Filename: filename, + ContentType: "audio/ogg", + Source: "tool:send_tts", + }, scope) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to register audio: %v", err)).WithError(err) + } + + return MediaResult("TTS audio sent", []string{ref}) +}