add support for multiple transcriber providers
This commit is contained in:
parent
1d748fb742
commit
ebd2538adc
6 changed files with 203 additions and 25 deletions
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package voice
|
|||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
|
|
@ -10,15 +11,23 @@ import (
|
|||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"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
|
||||
apiBase string
|
||||
model string
|
||||
providerName string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
|
|
@ -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{
|
||||
return &whisperTranscriber{
|
||||
apiKey: apiKey,
|
||||
apiBase: apiBase,
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue