From 8983a543ea2e7ce9de3c682878423857771fcdc9 Mon Sep 17 00:00:00 2001 From: Nikita Nafranets Date: Mon, 23 Feb 2026 17:05:23 +0300 Subject: [PATCH] feat: add Deepgram as alternative STT provider for voice messages Extract Transcriber interface from GroqTranscriber so channels accept any STT backend. Add DeepgramTranscriber (nova-2 model via REST API) as a fallback when no Groq API key is configured. Deepgram key is resolved from providers.deepgram.api_key or model_list entries with the deepgram/ prefix. --- cmd/picoclaw/cmd_gateway.go | 24 +++++-- pkg/channels/discord.go | 4 +- pkg/channels/slack.go | 4 +- pkg/channels/telegram.go | 4 +- pkg/config/config.go | 7 +- pkg/voice/deepgram.go | 125 ++++++++++++++++++++++++++++++++++++ pkg/voice/transcriber.go | 6 ++ 7 files changed, 162 insertions(+), 12 deletions(-) create mode 100644 pkg/voice/deepgram.go diff --git a/cmd/picoclaw/cmd_gateway.go b/cmd/picoclaw/cmd_gateway.go index 3010c1451..44d7d30c4 100644 --- a/cmd/picoclaw/cmd_gateway.go +++ b/cmd/picoclaw/cmd_gateway.go @@ -121,7 +121,7 @@ func gatewayCmd() { // Inject channel manager into agent loop for command handling agentLoop.SetChannelManager(channelManager) - var transcriber *voice.GroqTranscriber + var transcriber voice.Transcriber groqAPIKey := cfg.Providers.Groq.APIKey if groqAPIKey == "" { for _, mc := range cfg.ModelList { @@ -136,23 +136,39 @@ func gatewayCmd() { logger.InfoC("voice", "Groq voice transcription enabled") } + if transcriber == nil { + deepgramAPIKey := cfg.Providers.Deepgram.APIKey + if deepgramAPIKey == "" { + for _, mc := range cfg.ModelList { + if strings.HasPrefix(mc.Model, "deepgram/") && mc.APIKey != "" { + deepgramAPIKey = mc.APIKey + break + } + } + } + if deepgramAPIKey != "" { + transcriber = voice.NewDeepgramTranscriber(deepgramAPIKey) + logger.InfoC("voice", "Deepgram voice transcription enabled") + } + } + 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 20f3b267c..64983be20 100644 --- a/pkg/channels/discord.go +++ b/pkg/channels/discord.go @@ -26,7 +26,7 @@ type DiscordChannel struct { *BaseChannel session *discordgo.Session config config.DiscordConfig - transcriber *voice.GroqTranscriber + transcriber voice.Transcriber ctx context.Context typingMu sync.Mutex typingStop map[string]chan struct{} // chatID → stop signal @@ -51,7 +51,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 f087aa8da..7fd681f7c 100644 --- a/pkg/channels/slack.go +++ b/pkg/channels/slack.go @@ -26,7 +26,7 @@ type SlackChannel struct { socketClient *socketmode.Client botUserID string teamID string - transcriber *voice.GroqTranscriber + transcriber voice.Transcriber ctx context.Context cancel context.CancelFunc pendingAcks sync.Map @@ -59,7 +59,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 5cd51e8bc..e3aea5b34 100644 --- a/pkg/channels/telegram.go +++ b/pkg/channels/telegram.go @@ -29,7 +29,7 @@ type TelegramChannel struct { commands TelegramCommander config *config.Config chatIDs map[string]int64 - transcriber *voice.GroqTranscriber + transcriber voice.Transcriber placeholders sync.Map // chatID -> messageID stopThinking sync.Map // chatID -> thinkingCancel } @@ -86,7 +86,7 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann }, 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 6f76614cf..d982c6fec 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -335,6 +335,7 @@ type ProvidersConfig struct { Antigravity ProviderConfig `json:"antigravity"` Qwen ProviderConfig `json:"qwen"` Mistral ProviderConfig `json:"mistral"` + Deepgram ProviderConfig `json:"deepgram"` } // IsEmpty checks if all provider configs are empty (no API keys or API bases set) @@ -357,7 +358,8 @@ func (p ProvidersConfig) IsEmpty() bool { p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" && p.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" && p.Qwen.APIKey == "" && p.Qwen.APIBase == "" && - p.Mistral.APIKey == "" && p.Mistral.APIBase == "" + p.Mistral.APIKey == "" && p.Mistral.APIBase == "" && + p.Deepgram.APIKey == "" && p.Deepgram.APIBase == "" } // MarshalJSON implements custom JSON marshaling for ProvidersConfig @@ -674,7 +676,8 @@ func (c *Config) HasProvidersConfig() bool { v.GitHubCopilot.APIKey != "" || v.GitHubCopilot.APIBase != "" || v.Antigravity.APIKey != "" || v.Antigravity.APIBase != "" || v.Qwen.APIKey != "" || v.Qwen.APIBase != "" || - v.Mistral.APIKey != "" || v.Mistral.APIBase != "" + v.Mistral.APIKey != "" || v.Mistral.APIBase != "" || + v.Deepgram.APIKey != "" || v.Deepgram.APIBase != "" } // ValidateModelList validates all ModelConfig entries in the model_list. diff --git a/pkg/voice/deepgram.go b/pkg/voice/deepgram.go new file mode 100644 index 000000000..f763b27d5 --- /dev/null +++ b/pkg/voice/deepgram.go @@ -0,0 +1,125 @@ +package voice + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "time" + + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/utils" +) + +type DeepgramTranscriber struct { + apiKey string + httpClient *http.Client +} + +type deepgramResponse struct { + Results struct { + Channels []struct { + Alternatives []struct { + Transcript string `json:"transcript"` + } `json:"alternatives"` + DetectedLanguage string `json:"detected_language"` + } `json:"channels"` + } `json:"results"` + Metadata struct { + Duration float64 `json:"duration"` + } `json:"metadata"` +} + +func NewDeepgramTranscriber(apiKey string) *DeepgramTranscriber { + logger.DebugCF("voice", "Creating Deepgram transcriber", map[string]any{"has_api_key": apiKey != ""}) + + return &DeepgramTranscriber{ + apiKey: apiKey, + httpClient: &http.Client{ + Timeout: 60 * time.Second, + }, + } +} + +func (t *DeepgramTranscriber) Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) { + logger.InfoCF("voice", "Starting Deepgram transcription", map[string]any{"audio_file": audioFilePath}) + + audioFile, err := os.Open(audioFilePath) + if err != nil { + logger.ErrorCF("voice", "Failed to open audio file", map[string]any{"path": audioFilePath, "error": err}) + return nil, fmt.Errorf("failed to open audio file: %w", err) + } + defer audioFile.Close() + + url := "https://api.deepgram.com/v1/listen?model=nova-2&smart_format=true&detect_language=true" + req, err := http.NewRequestWithContext(ctx, "POST", url, audioFile) + if err != nil { + logger.ErrorCF("voice", "Failed to create request", map[string]any{"error": err}) + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "Token "+t.apiKey) + req.Header.Set("Content-Type", "audio/ogg") + + logger.DebugCF("voice", "Sending transcription request to Deepgram API", map[string]any{"url": url}) + + resp, err := t.httpClient.Do(req) + if err != nil { + logger.ErrorCF("voice", "Failed to send request", map[string]any{"error": err}) + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + logger.ErrorCF("voice", "Failed to read response", map[string]any{"error": err}) + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + logger.ErrorCF("voice", "Deepgram API error", map[string]any{ + "status_code": resp.StatusCode, + "response": string(body), + }) + return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) + } + + var dgResp deepgramResponse + if err := json.Unmarshal(body, &dgResp); err != nil { + logger.ErrorCF("voice", "Failed to unmarshal Deepgram response", map[string]any{"error": err}) + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + transcript := "" + language := "" + if len(dgResp.Results.Channels) > 0 { + ch := dgResp.Results.Channels[0] + if len(ch.Alternatives) > 0 { + transcript = ch.Alternatives[0].Transcript + } + language = ch.DetectedLanguage + } + + result := &TranscriptionResponse{ + Text: transcript, + Language: language, + Duration: dgResp.Metadata.Duration, + } + + logger.InfoCF("voice", "Deepgram transcription completed", map[string]any{ + "text_length": len(result.Text), + "language": result.Language, + "duration_seconds": result.Duration, + "transcription_preview": utils.Truncate(result.Text, 50), + }) + + return result, nil +} + +func (t *DeepgramTranscriber) IsAvailable() bool { + available := t.apiKey != "" + logger.DebugCF("voice", "Checking Deepgram transcriber availability", map[string]any{"available": available}) + return available +} diff --git a/pkg/voice/transcriber.go b/pkg/voice/transcriber.go index f973e77fe..269b0964d 100644 --- a/pkg/voice/transcriber.go +++ b/pkg/voice/transcriber.go @@ -16,6 +16,12 @@ import ( "github.com/sipeed/picoclaw/pkg/utils" ) +// Transcriber is the interface for speech-to-text providers. +type Transcriber interface { + Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) + IsAvailable() bool +} + type GroqTranscriber struct { apiKey string apiBase string