add support for multiple transcriber providers

This commit is contained in:
sysradium 2026-02-15 22:25:51 +01:00
parent 1d748fb742
commit ebd2538adc
No known key found for this signature in database
6 changed files with 203 additions and 25 deletions

View file

@ -592,29 +592,39 @@ func gatewayCmd() {
os.Exit(1) os.Exit(1)
} }
var transcriber *voice.GroqTranscriber var transcriber voice.Transcriber
if cfg.Providers.Groq.APIKey != "" { 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) transcriber = voice.NewGroqTranscriber(cfg.Providers.Groq.APIKey)
logger.InfoC("voice", "Groq voice transcription enabled") 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 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

@ -23,7 +23,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
} }
@ -44,7 +44,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

@ -25,7 +25,7 @@ type SlackChannel struct {
api *slack.Client api *slack.Client
socketClient *socketmode.Client socketClient *socketmode.Client
botUserID string botUserID 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
@ -58,7 +58,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

@ -26,7 +26,7 @@ type TelegramChannel struct {
bot *telego.Bot bot *telego.Bot
config config.TelegramConfig config config.TelegramConfig
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
} }
@ -74,7 +74,7 @@ func NewTelegramChannel(cfg config.TelegramConfig, bus *bus.MessageBus) (*Telegr
}, nil }, nil
} }
func (c *TelegramChannel) SetTranscriber(transcriber *voice.GroqTranscriber) { func (c *TelegramChannel) SetTranscriber(transcriber voice.Transcriber) {
c.transcriber = transcriber c.transcriber = transcriber
} }

View file

@ -43,6 +43,11 @@ func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
return nil 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 { type Config struct {
Agents AgentsConfig `json:"agents"` Agents AgentsConfig `json:"agents"`
Channels ChannelsConfig `json:"channels"` Channels ChannelsConfig `json:"channels"`
@ -51,6 +56,7 @@ type Config struct {
Tools ToolsConfig `json:"tools"` Tools ToolsConfig `json:"tools"`
Heartbeat HeartbeatConfig `json:"heartbeat"` Heartbeat HeartbeatConfig `json:"heartbeat"`
Devices DevicesConfig `json:"devices"` Devices DevicesConfig `json:"devices"`
Voice VoiceConfig `json:"voice"`
mu sync.RWMutex mu sync.RWMutex
} }

View file

@ -3,6 +3,7 @@ package voice
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@ -10,15 +11,23 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/utils" "github.com/sipeed/picoclaw/pkg/utils"
) )
type GroqTranscriber struct { type Transcriber interface {
Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error)
IsAvailable() bool
}
type whisperTranscriber struct {
apiKey string apiKey string
apiBase string apiBase string
model string
providerName string
httpClient *http.Client httpClient *http.Client
} }
@ -28,20 +37,173 @@ type TranscriptionResponse struct {
Duration float64 `json:"duration,omitempty"` 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 != ""}) logger.DebugCF("voice", "Creating Groq transcriber", map[string]interface{}{"has_api_key": apiKey != ""})
apiBase := "https://api.groq.com/openai/v1" return &whisperTranscriber{
return &GroqTranscriber{
apiKey: apiKey, apiKey: apiKey,
apiBase: apiBase, apiBase: "https://api.groq.com/openai/v1",
model: "whisper-large-v3",
providerName: "Groq",
httpClient: &http.Client{ httpClient: &http.Client{
Timeout: 60 * time.Second, 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}) logger.InfoCF("voice", "Starting transcription", map[string]interface{}{"audio_file": audioFilePath})
audioFile, err := os.Open(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}) 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}) logger.ErrorCF("voice", "Failed to write model field", map[string]interface{}{"error": err})
return nil, fmt.Errorf("failed to write model field: %w", 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("Content-Type", writer.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+t.apiKey) 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, "url": url,
"request_size_bytes": requestBody.Len(), "request_size_bytes": requestBody.Len(),
"file_size_bytes": fileInfo.Size(), "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)) 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, "status_code": resp.StatusCode,
"response_size_bytes": len(body), "response_size_bytes": len(body),
}) })
@ -152,7 +314,7 @@ func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string)
return &result, nil return &result, nil
} }
func (t *GroqTranscriber) IsAvailable() bool { func (t *whisperTranscriber) IsAvailable() bool {
available := t.apiKey != "" available := t.apiKey != ""
logger.DebugCF("voice", "Checking transcriber availability", map[string]interface{}{"available": available}) logger.DebugCF("voice", "Checking transcriber availability", map[string]interface{}{"available": available})
return available return available