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)
}
var transcriber *voice.GroqTranscriber
if cfg.Providers.Groq.APIKey != "" {
var transcriber voice.Transcriber
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)
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 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")
}
}
}

View file

@ -23,7 +23,7 @@ type DiscordChannel struct {
*BaseChannel
session *discordgo.Session
config config.DiscordConfig
transcriber *voice.GroqTranscriber
transcriber voice.Transcriber
ctx context.Context
}
@ -44,7 +44,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
}

View file

@ -25,7 +25,7 @@ type SlackChannel struct {
api *slack.Client
socketClient *socketmode.Client
botUserID string
transcriber *voice.GroqTranscriber
transcriber voice.Transcriber
ctx context.Context
cancel context.CancelFunc
pendingAcks sync.Map
@ -58,7 +58,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
}

View file

@ -26,7 +26,7 @@ type TelegramChannel struct {
bot *telego.Bot
config config.TelegramConfig
chatIDs map[string]int64
transcriber *voice.GroqTranscriber
transcriber voice.Transcriber
placeholders sync.Map // chatID -> messageID
stopThinking sync.Map // chatID -> thinkingCancel
}
@ -74,7 +74,7 @@ func NewTelegramChannel(cfg config.TelegramConfig, bus *bus.MessageBus) (*Telegr
}, nil
}
func (c *TelegramChannel) SetTranscriber(transcriber *voice.GroqTranscriber) {
func (c *TelegramChannel) SetTranscriber(transcriber voice.Transcriber) {
c.transcriber = transcriber
}

View file

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

View file

@ -3,6 +3,7 @@ package voice
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
@ -10,16 +11,24 @@ import (
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/utils"
)
type GroqTranscriber struct {
apiKey string
apiBase string
httpClient *http.Client
type Transcriber interface {
Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error)
IsAvailable() bool
}
type whisperTranscriber struct {
apiKey string
apiBase string
model string
providerName string
httpClient *http.Client
}
type TranscriptionResponse struct {
@ -28,20 +37,173 @@ type TranscriptionResponse struct {
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 != ""})
apiBase := "https://api.groq.com/openai/v1"
return &GroqTranscriber{
apiKey: apiKey,
apiBase: apiBase,
return &whisperTranscriber{
apiKey: apiKey,
apiBase: "https://api.groq.com/openai/v1",
model: "whisper-large-v3",
providerName: "Groq",
httpClient: &http.Client{
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})
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})
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})
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("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,
"request_size_bytes": requestBody.Len(),
"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))
}
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,
"response_size_bytes": len(body),
})
@ -152,7 +314,7 @@ func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string)
return &result, nil
}
func (t *GroqTranscriber) IsAvailable() bool {
func (t *whisperTranscriber) IsAvailable() bool {
available := t.apiKey != ""
logger.DebugCF("voice", "Checking transcriber availability", map[string]interface{}{"available": available})
return available