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.
This commit is contained in:
Nikita Nafranets 2026-02-23 17:05:23 +03:00
parent fd26fa7459
commit 8983a543ea
7 changed files with 162 additions and 12 deletions

View file

@ -121,7 +121,7 @@ func gatewayCmd() {
// Inject channel manager into agent loop for command handling // Inject channel manager into agent loop for command handling
agentLoop.SetChannelManager(channelManager) agentLoop.SetChannelManager(channelManager)
var transcriber *voice.GroqTranscriber var transcriber voice.Transcriber
groqAPIKey := cfg.Providers.Groq.APIKey groqAPIKey := cfg.Providers.Groq.APIKey
if groqAPIKey == "" { if groqAPIKey == "" {
for _, mc := range cfg.ModelList { for _, mc := range cfg.ModelList {
@ -136,23 +136,39 @@ func gatewayCmd() {
logger.InfoC("voice", "Groq voice transcription enabled") 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 transcriber != nil {
if telegramChannel, ok := channelManager.GetChannel("telegram"); ok { if telegramChannel, ok := channelManager.GetChannel("telegram"); ok {
if tc, ok := telegramChannel.(*channels.TelegramChannel); ok { if tc, ok := telegramChannel.(*channels.TelegramChannel); ok {
tc.SetTranscriber(transcriber) 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 discordChannel, ok := channelManager.GetChannel("discord"); ok {
if dc, ok := discordChannel.(*channels.DiscordChannel); ok { if dc, ok := discordChannel.(*channels.DiscordChannel); ok {
dc.SetTranscriber(transcriber) 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 slackChannel, ok := channelManager.GetChannel("slack"); ok {
if sc, ok := slackChannel.(*channels.SlackChannel); ok { if sc, ok := slackChannel.(*channels.SlackChannel); ok {
sc.SetTranscriber(transcriber) sc.SetTranscriber(transcriber)
logger.InfoC("voice", "Groq transcription attached to Slack channel") logger.InfoC("voice", "Voice transcription attached to Slack channel")
} }
} }
} }

View file

@ -26,7 +26,7 @@ type DiscordChannel struct {
*BaseChannel *BaseChannel
session *discordgo.Session session *discordgo.Session
config config.DiscordConfig config config.DiscordConfig
transcriber *voice.GroqTranscriber transcriber voice.Transcriber
ctx context.Context ctx context.Context
typingMu sync.Mutex typingMu sync.Mutex
typingStop map[string]chan struct{} // chatID → stop signal typingStop map[string]chan struct{} // chatID → stop signal
@ -51,7 +51,7 @@ func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordC
}, nil }, nil
} }
func (c *DiscordChannel) SetTranscriber(transcriber *voice.GroqTranscriber) { func (c *DiscordChannel) SetTranscriber(transcriber voice.Transcriber) {
c.transcriber = transcriber c.transcriber = transcriber
} }

View file

@ -26,7 +26,7 @@ type SlackChannel struct {
socketClient *socketmode.Client socketClient *socketmode.Client
botUserID string botUserID string
teamID string teamID string
transcriber *voice.GroqTranscriber transcriber voice.Transcriber
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
pendingAcks sync.Map pendingAcks sync.Map
@ -59,7 +59,7 @@ func NewSlackChannel(cfg config.SlackConfig, messageBus *bus.MessageBus) (*Slack
}, nil }, nil
} }
func (c *SlackChannel) SetTranscriber(transcriber *voice.GroqTranscriber) { func (c *SlackChannel) SetTranscriber(transcriber voice.Transcriber) {
c.transcriber = transcriber c.transcriber = transcriber
} }

View file

@ -29,7 +29,7 @@ type TelegramChannel struct {
commands TelegramCommander commands TelegramCommander
config *config.Config config *config.Config
chatIDs map[string]int64 chatIDs map[string]int64
transcriber *voice.GroqTranscriber transcriber voice.Transcriber
placeholders sync.Map // chatID -> messageID placeholders sync.Map // chatID -> messageID
stopThinking sync.Map // chatID -> thinkingCancel stopThinking sync.Map // chatID -> thinkingCancel
} }
@ -86,7 +86,7 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann
}, nil }, nil
} }
func (c *TelegramChannel) SetTranscriber(transcriber *voice.GroqTranscriber) { func (c *TelegramChannel) SetTranscriber(transcriber voice.Transcriber) {
c.transcriber = transcriber c.transcriber = transcriber
} }

View file

@ -335,6 +335,7 @@ type ProvidersConfig struct {
Antigravity ProviderConfig `json:"antigravity"` Antigravity ProviderConfig `json:"antigravity"`
Qwen ProviderConfig `json:"qwen"` Qwen ProviderConfig `json:"qwen"`
Mistral ProviderConfig `json:"mistral"` Mistral ProviderConfig `json:"mistral"`
Deepgram ProviderConfig `json:"deepgram"`
} }
// IsEmpty checks if all provider configs are empty (no API keys or API bases set) // 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.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" &&
p.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" && p.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" &&
p.Qwen.APIKey == "" && p.Qwen.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 // MarshalJSON implements custom JSON marshaling for ProvidersConfig
@ -674,7 +676,8 @@ func (c *Config) HasProvidersConfig() bool {
v.GitHubCopilot.APIKey != "" || v.GitHubCopilot.APIBase != "" || v.GitHubCopilot.APIKey != "" || v.GitHubCopilot.APIBase != "" ||
v.Antigravity.APIKey != "" || v.Antigravity.APIBase != "" || v.Antigravity.APIKey != "" || v.Antigravity.APIBase != "" ||
v.Qwen.APIKey != "" || v.Qwen.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. // ValidateModelList validates all ModelConfig entries in the model_list.

125
pkg/voice/deepgram.go Normal file
View file

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

View file

@ -16,6 +16,12 @@ import (
"github.com/sipeed/picoclaw/pkg/utils" "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 { type GroqTranscriber struct {
apiKey string apiKey string
apiBase string apiBase string