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:
parent
fd26fa7459
commit
8983a543ea
7 changed files with 162 additions and 12 deletions
|
|
@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
125
pkg/voice/deepgram.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue