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
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
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"
|
||||
)
|
||||
|
||||
// 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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue