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