diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 2129662d7..635881210 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -592,29 +592,39 @@ func gatewayCmd() { os.Exit(1) } - var transcriber *voice.GroqTranscriber - if cfg.Providers.Groq.APIKey != "" { + var transcriber voice.Transcriber + switch { + case cfg.Voice.Provider == "openrouter" && cfg.Providers.OpenRouter.APIKey != "": + transcriber = voice.NewOpenRouterTranscriber(cfg.Providers.OpenRouter.APIKey, cfg.Voice.Model) + logger.InfoC("voice", "OpenRouter voice transcription enabled") + case cfg.Voice.Provider == "groq" && cfg.Providers.Groq.APIKey != "": transcriber = voice.NewGroqTranscriber(cfg.Providers.Groq.APIKey) logger.InfoC("voice", "Groq voice transcription enabled") + case cfg.Providers.Groq.APIKey != "": + transcriber = voice.NewGroqTranscriber(cfg.Providers.Groq.APIKey) + logger.InfoC("voice", "Groq voice transcription enabled (auto)") + case cfg.Providers.OpenRouter.APIKey != "": + transcriber = voice.NewOpenRouterTranscriber(cfg.Providers.OpenRouter.APIKey, cfg.Voice.Model) + logger.InfoC("voice", "OpenRouter voice transcription enabled (auto)") } if transcriber != nil { if telegramChannel, ok := channelManager.GetChannel("telegram"); ok { if tc, ok := telegramChannel.(*channels.TelegramChannel); ok { tc.SetTranscriber(transcriber) - logger.InfoC("voice", "Groq transcription attached to Telegram channel") + logger.InfoC("voice", "Voice transcription attached to Telegram channel") } } if discordChannel, ok := channelManager.GetChannel("discord"); ok { if dc, ok := discordChannel.(*channels.DiscordChannel); ok { dc.SetTranscriber(transcriber) - logger.InfoC("voice", "Groq transcription attached to Discord channel") + logger.InfoC("voice", "Voice transcription attached to Discord channel") } } if slackChannel, ok := channelManager.GetChannel("slack"); ok { if sc, ok := slackChannel.(*channels.SlackChannel); ok { sc.SetTranscriber(transcriber) - logger.InfoC("voice", "Groq transcription attached to Slack channel") + logger.InfoC("voice", "Voice transcription attached to Slack channel") } } } diff --git a/pkg/channels/discord.go b/pkg/channels/discord.go index cb8014086..40717b396 100644 --- a/pkg/channels/discord.go +++ b/pkg/channels/discord.go @@ -23,7 +23,7 @@ type DiscordChannel struct { *BaseChannel session *discordgo.Session config config.DiscordConfig - transcriber *voice.GroqTranscriber + transcriber voice.Transcriber ctx context.Context } @@ -44,7 +44,7 @@ func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordC }, nil } -func (c *DiscordChannel) SetTranscriber(transcriber *voice.GroqTranscriber) { +func (c *DiscordChannel) SetTranscriber(transcriber voice.Transcriber) { c.transcriber = transcriber } diff --git a/pkg/channels/slack.go b/pkg/channels/slack.go index d86d08a9d..07e786c75 100644 --- a/pkg/channels/slack.go +++ b/pkg/channels/slack.go @@ -25,7 +25,7 @@ type SlackChannel struct { api *slack.Client socketClient *socketmode.Client botUserID string - transcriber *voice.GroqTranscriber + transcriber voice.Transcriber ctx context.Context cancel context.CancelFunc pendingAcks sync.Map @@ -58,7 +58,7 @@ func NewSlackChannel(cfg config.SlackConfig, messageBus *bus.MessageBus) (*Slack }, nil } -func (c *SlackChannel) SetTranscriber(transcriber *voice.GroqTranscriber) { +func (c *SlackChannel) SetTranscriber(transcriber voice.Transcriber) { c.transcriber = transcriber } diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go index b14b1632e..a9f3a08c4 100644 --- a/pkg/channels/telegram.go +++ b/pkg/channels/telegram.go @@ -26,7 +26,7 @@ type TelegramChannel struct { bot *telego.Bot config config.TelegramConfig chatIDs map[string]int64 - transcriber *voice.GroqTranscriber + transcriber voice.Transcriber placeholders sync.Map // chatID -> messageID stopThinking sync.Map // chatID -> thinkingCancel } @@ -74,7 +74,7 @@ func NewTelegramChannel(cfg config.TelegramConfig, bus *bus.MessageBus) (*Telegr }, nil } -func (c *TelegramChannel) SetTranscriber(transcriber *voice.GroqTranscriber) { +func (c *TelegramChannel) SetTranscriber(transcriber voice.Transcriber) { c.transcriber = transcriber } diff --git a/pkg/config/config.go b/pkg/config/config.go index d76ec8095..152f51986 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -43,6 +43,11 @@ func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error { return nil } +type VoiceConfig struct { + Provider string `json:"provider" env:"PICOCLAW_VOICE_PROVIDER"` // "groq", "openrouter", or "" (auto) + Model string `json:"model" env:"PICOCLAW_VOICE_MODEL"` +} + type Config struct { Agents AgentsConfig `json:"agents"` Channels ChannelsConfig `json:"channels"` @@ -51,6 +56,7 @@ type Config struct { Tools ToolsConfig `json:"tools"` Heartbeat HeartbeatConfig `json:"heartbeat"` Devices DevicesConfig `json:"devices"` + Voice VoiceConfig `json:"voice"` mu sync.RWMutex } diff --git a/pkg/voice/transcriber.go b/pkg/voice/transcriber.go index 9af2ea6bb..54664e86e 100644 --- a/pkg/voice/transcriber.go +++ b/pkg/voice/transcriber.go @@ -3,6 +3,7 @@ package voice import ( "bytes" "context" + "encoding/base64" "encoding/json" "fmt" "io" @@ -10,16 +11,24 @@ import ( "net/http" "os" "path/filepath" + "strings" "time" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/utils" ) -type GroqTranscriber struct { - apiKey string - apiBase string - httpClient *http.Client +type Transcriber interface { + Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) + IsAvailable() bool +} + +type whisperTranscriber struct { + apiKey string + apiBase string + model string + providerName string + httpClient *http.Client } type TranscriptionResponse struct { @@ -28,20 +37,173 @@ type TranscriptionResponse struct { Duration float64 `json:"duration,omitempty"` } -func NewGroqTranscriber(apiKey string) *GroqTranscriber { +func NewGroqTranscriber(apiKey string) Transcriber { logger.DebugCF("voice", "Creating Groq transcriber", map[string]interface{}{"has_api_key": apiKey != ""}) - apiBase := "https://api.groq.com/openai/v1" - return &GroqTranscriber{ - apiKey: apiKey, - apiBase: apiBase, + return &whisperTranscriber{ + apiKey: apiKey, + apiBase: "https://api.groq.com/openai/v1", + model: "whisper-large-v3", + providerName: "Groq", httpClient: &http.Client{ Timeout: 60 * time.Second, }, } } -func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) { +type openRouterTranscriber struct { + apiKey string + model string + httpClient *http.Client +} + +func NewOpenRouterTranscriber(apiKey, model string) Transcriber { + if model == "" { + model = "google/gemini-2.5-flash" + } + logger.DebugCF("voice", "Creating OpenRouter transcriber", map[string]interface{}{ + "has_api_key": apiKey != "", + "model": model, + }) + + return &openRouterTranscriber{ + apiKey: apiKey, + model: model, + httpClient: &http.Client{ + Timeout: 120 * time.Second, + }, + } +} + +func audioFormatFromExt(filePath string) string { + ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filePath), ".")) + switch ext { + case "ogg", "oga": + return "ogg" + case "mp3": + return "mp3" + case "wav": + return "wav" + case "flac": + return "flac" + case "m4a", "aac": + return "m4a" + case "webm": + return "webm" + default: + return "ogg" + } +} + +func (t *openRouterTranscriber) Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) { + logger.InfoCF("voice", "Starting OpenRouter transcription", map[string]interface{}{ + "audio_file": audioFilePath, + "model": t.model, + }) + + audioData, err := os.ReadFile(audioFilePath) + if err != nil { + logger.ErrorCF("voice", "Failed to read audio file", map[string]interface{}{"path": audioFilePath, "error": err}) + return nil, fmt.Errorf("failed to read audio file: %w", err) + } + + b64Data := base64.StdEncoding.EncodeToString(audioData) + audioFormat := audioFormatFromExt(audioFilePath) + + logger.DebugCF("voice", "Audio file details", map[string]interface{}{ + "size_bytes": len(audioData), + "format": audioFormat, + "file_name": filepath.Base(audioFilePath), + }) + + reqBody := map[string]interface{}{ + "model": t.model, + "messages": []map[string]interface{}{ + { + "role": "user", + "content": []map[string]interface{}{ + {"type": "text", "text": "Transcribe this audio. Return only the transcription text, nothing else."}, + {"type": "input_audio", "input_audio": map[string]string{ + "data": b64Data, + "format": audioFormat, + }}, + }, + }, + }, + } + + jsonBody, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + url := "https://openrouter.ai/api/v1/chat/completions" + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(jsonBody)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+t.apiKey) + + logger.DebugCF("voice", "Sending transcription request to OpenRouter", map[string]interface{}{ + "url": url, + "model": t.model, + "request_size_bytes": len(jsonBody), + }) + + resp, err := t.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + logger.ErrorCF("voice", "OpenRouter API error", map[string]interface{}{ + "status_code": resp.StatusCode, + "response": string(body), + }) + return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) + } + + var chatResp struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + } + + if err := json.Unmarshal(body, &chatResp); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + if len(chatResp.Choices) == 0 { + return nil, fmt.Errorf("no choices in response") + } + + text := strings.TrimSpace(chatResp.Choices[0].Message.Content) + + logger.InfoCF("voice", "Transcription completed successfully", map[string]interface{}{ + "text_length": len(text), + "transcription_preview": utils.Truncate(text, 50), + }) + + return &TranscriptionResponse{Text: text}, nil +} + +func (t *openRouterTranscriber) IsAvailable() bool { + available := t.apiKey != "" + logger.DebugCF("voice", "Checking OpenRouter transcriber availability", map[string]interface{}{"available": available}) + return available +} + +func (t *whisperTranscriber) Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) { logger.InfoCF("voice", "Starting transcription", map[string]interface{}{"audio_file": audioFilePath}) audioFile, err := os.Open(audioFilePath) @@ -79,7 +241,7 @@ func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string) logger.DebugCF("voice", "File copied to request", map[string]interface{}{"bytes_copied": copied}) - if err := writer.WriteField("model", "whisper-large-v3"); err != nil { + if err := writer.WriteField("model", t.model); err != nil { logger.ErrorCF("voice", "Failed to write model field", map[string]interface{}{"error": err}) return nil, fmt.Errorf("failed to write model field: %w", err) } @@ -104,7 +266,7 @@ func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string) req.Header.Set("Content-Type", writer.FormDataContentType()) req.Header.Set("Authorization", "Bearer "+t.apiKey) - logger.DebugCF("voice", "Sending transcription request to Groq API", map[string]interface{}{ + logger.DebugCF("voice", fmt.Sprintf("Sending transcription request to %s API", t.providerName), map[string]interface{}{ "url": url, "request_size_bytes": requestBody.Len(), "file_size_bytes": fileInfo.Size(), @@ -131,7 +293,7 @@ func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string) return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) } - logger.DebugCF("voice", "Received response from Groq API", map[string]interface{}{ + logger.DebugCF("voice", fmt.Sprintf("Received response from %s API", t.providerName), map[string]interface{}{ "status_code": resp.StatusCode, "response_size_bytes": len(body), }) @@ -152,7 +314,7 @@ func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string) return &result, nil } -func (t *GroqTranscriber) IsAvailable() bool { +func (t *whisperTranscriber) IsAvailable() bool { available := t.apiKey != "" logger.DebugCF("voice", "Checking transcriber availability", map[string]interface{}{"available": available}) return available