diff --git a/cmd/hyprvoice/main.go b/cmd/hyprvoice/main.go index 15b1c75..1f6117d 100644 --- a/cmd/hyprvoice/main.go +++ b/cmd/hyprvoice/main.go @@ -132,7 +132,7 @@ func configureCmd() *cobra.Command { Short: "Interactive configuration setup", Long: `Interactive configuration wizard for hyprvoice. This will guide you through setting up: -- Transcription provider (OpenAI or Groq) +- Transcription provider (OpenAI, Groq, or Mistral) - API keys and model selection - Audio and text injection preferences - Notification settings`, @@ -162,10 +162,11 @@ func runInteractiveConfig() error { // Provider selection for { fmt.Println("Select transcription provider:") - fmt.Println(" 1. openai - OpenAI Whisper API (cloud-based)") - fmt.Println(" 2. groq-transcription - Groq Whisper API (fast transcription)") - fmt.Println(" 3. groq-translation - Groq Whisper API (translate to English)") - fmt.Printf("Provider [1-3] (current: %s): ", cfg.Transcription.Provider) + fmt.Println(" 1. openai - OpenAI Whisper API (cloud-based)") + fmt.Println(" 2. groq-transcription - Groq Whisper API (fast transcription)") + fmt.Println(" 3. groq-translation - Groq Whisper API (translate to English)") + fmt.Println(" 4. mistral-transcription - Mistral Voxtral API (excellent for European languages)") + fmt.Printf("Provider [1-4] (current: %s): ", cfg.Transcription.Provider) if !scanner.Scan() { break } @@ -180,10 +181,12 @@ func runInteractiveConfig() error { cfg.Transcription.Provider = "groq-transcription" case "3": cfg.Transcription.Provider = "groq-translation" - case "openai", "groq-transcription", "groq-translation": + case "4": + cfg.Transcription.Provider = "mistral-transcription" + case "openai", "groq-transcription", "groq-translation", "mistral-transcription": cfg.Transcription.Provider = input default: - fmt.Println("❌ Error: invalid provider. Please enter 1, 2, 3 or provider name.") + fmt.Println("❌ Error: invalid provider. Please enter 1-4 or provider name.") fmt.Println() continue } @@ -245,13 +248,43 @@ func runInteractiveConfig() error { } fmt.Println("❌ Error: only whisper-large-v3 is supported for translation.") } + case "mistral-transcription": + for { + fmt.Println("\nMistral Voxtral Model:") + fmt.Println(" 1. voxtral-mini-latest - Recommended (latest version)") + fmt.Println(" 2. voxtral-mini-2507 - Pinned version") + fmt.Printf("Model [1-2] (current: %s): ", cfg.Transcription.Model) + if !scanner.Scan() { + break + } + input := strings.TrimSpace(scanner.Text()) + switch input { + case "1": + cfg.Transcription.Model = "voxtral-mini-latest" + case "2": + cfg.Transcription.Model = "voxtral-mini-2507" + case "voxtral-mini-latest", "voxtral-mini-2507": + cfg.Transcription.Model = input + case "": + if cfg.Transcription.Model == "" || !strings.HasPrefix(cfg.Transcription.Model, "voxtral") { + cfg.Transcription.Model = "voxtral-mini-latest" + } + default: + fmt.Println("❌ Error: invalid model. Please enter 1, 2 or model name.") + continue + } + break + } } // API Key (provider-aware) var envVarName string - if cfg.Transcription.Provider == "openai" { + switch cfg.Transcription.Provider { + case "openai": envVarName = "OPENAI_API_KEY" - } else { + case "mistral-transcription": + envVarName = "MISTRAL_API_KEY" + default: envVarName = "GROQ_API_KEY" } fmt.Printf("\nAPI Key (current: %s, leave empty to use %s env var): ", maskAPIKey(cfg.Transcription.APIKey), envVarName) @@ -575,10 +608,10 @@ func saveConfig(cfg *config.Config) error { # Speech Transcription Configuration [transcription] - provider = "%s" # Transcription service: "openai", "groq-transcription", or "groq-translation" - api_key = "%s" # API key (or set OPENAI_API_KEY/GROQ_API_KEY environment variable) + provider = "%s" # Transcription service: "openai", "groq-transcription", "groq-translation", or "mistral-transcription" + api_key = "%s" # API key (or set OPENAI_API_KEY/GROQ_API_KEY/MISTRAL_API_KEY environment variable) language = "%s" # Language code (empty for auto-detect, "en", "it", "es", "fr", etc.) - model = "%s" # Model: OpenAI="whisper-1", Groq="whisper-large-v3" or "whisper-large-v3-turbo" + model = "%s" # Model: OpenAI="whisper-1", Groq="whisper-large-v3", Mistral="voxtral-mini-latest" # Text Injection Configuration [injection] @@ -600,6 +633,8 @@ func saveConfig(cfg *config.Config) error { # Models: whisper-large-v3 or whisper-large-v3-turbo # - "groq-translation": Groq Whisper API for translation to English (always outputs English text) # Models: whisper-large-v3 only (turbo not supported for translation) +# - "mistral-transcription": Mistral Voxtral API (excellent for European languages, requires MISTRAL_API_KEY) +# Models: voxtral-mini-latest or voxtral-mini-2507 # # Language codes: Use empty string ("") for automatic detection, or specific codes like: # "en" (English), "it" (Italian), "es" (Spanish), "fr" (French), "de" (German), etc. diff --git a/internal/config/config.go b/internal/config/config.go index 44775e8..9ad9e1c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -125,6 +125,8 @@ func (c *Config) ToTranscriberConfig() transcriber.Config { config.APIKey = os.Getenv("OPENAI_API_KEY") case "groq-transcription", "groq-translation": config.APIKey = os.Getenv("GROQ_API_KEY") + case "mistral-transcription": + config.APIKey = os.Getenv("MISTRAL_API_KEY") } } @@ -221,8 +223,28 @@ func (c *Config) Validate() error { return fmt.Errorf("invalid model for groq-translation: %s (must be whisper-large-v3, turbo version not supported for translation)", c.Transcription.Model) } + case "mistral-transcription": + apiKey := c.Transcription.APIKey + if apiKey == "" { + apiKey = os.Getenv("MISTRAL_API_KEY") + } + if apiKey == "" { + return fmt.Errorf("Mistral API key required: not found in config (transcription.api_key) or environment variable (MISTRAL_API_KEY)") + } + + // Validate language code if provided (empty string means auto-detect) + if c.Transcription.Language != "" && !isValidLanguageCode(c.Transcription.Language) { + return fmt.Errorf("invalid transcription.language: %s (use empty string for auto-detect or ISO-639-1 codes like 'en', 'es', 'fr')", c.Transcription.Language) + } + + // Validate Mistral model + validMistralModels := map[string]bool{"voxtral-mini-latest": true, "voxtral-mini-2507": true} + if c.Transcription.Model != "" && !validMistralModels[c.Transcription.Model] { + return fmt.Errorf("invalid model for mistral-transcription: %s (must be voxtral-mini-latest or voxtral-mini-2507)", c.Transcription.Model) + } + default: - return fmt.Errorf("unsupported transcription.provider: %s (must be openai, groq-transcription, or groq-translation)", c.Transcription.Provider) + return fmt.Errorf("unsupported transcription.provider: %s (must be openai, groq-transcription, groq-translation, or mistral-transcription)", c.Transcription.Provider) } if c.Transcription.Model == "" { @@ -390,10 +412,10 @@ func SaveDefaultConfig() error { # Speech Transcription Configuration [transcription] - provider = "openai" # Transcription service: "openai", "groq-transcription", or "groq-translation" - api_key = "" # API key (or set OPENAI_API_KEY/GROQ_API_KEY environment variable) + provider = "openai" # Transcription service: "openai", "groq-transcription", "groq-translation", or "mistral-transcription" + api_key = "" # API key (or set OPENAI_API_KEY/GROQ_API_KEY/MISTRAL_API_KEY environment variable) language = "" # Language code (empty for auto-detect, "en", "it", "es", "fr", etc.) - model = "whisper-1" # Model: OpenAI="whisper-1", Groq="whisper-large-v3" or "whisper-large-v3-turbo" + model = "whisper-1" # Model: OpenAI="whisper-1", Groq="whisper-large-v3", Mistral="voxtral-mini-latest" # Text Injection Configuration [injection] @@ -455,6 +477,8 @@ func SaveDefaultConfig() error { # Models: whisper-large-v3 or whisper-large-v3-turbo # - "groq-translation": Groq Whisper API for translation to English (always outputs English text) # Models: whisper-large-v3 only (turbo not supported for translation) +# - "mistral-transcription": Mistral Voxtral API (excellent for European languages, requires MISTRAL_API_KEY) +# Models: voxtral-mini-latest or voxtral-mini-2507 # # Language codes: Use empty string ("") for automatic detection, or specific codes like: # "en" (English), "it" (Italian), "es" (Spanish), "fr" (French), "de" (German), etc. diff --git a/internal/transcriber/adapter_mistral.go b/internal/transcriber/adapter_mistral.go new file mode 100644 index 0000000..518a30d --- /dev/null +++ b/internal/transcriber/adapter_mistral.go @@ -0,0 +1,60 @@ +package transcriber + +import ( + "bytes" + "context" + "fmt" + "log" + "time" + + "github.com/sashabaranov/go-openai" +) + +// MistralAdapter implements TranscriptionAdapter for Mistral Voxtral API +type MistralAdapter struct { + client *openai.Client + config Config +} + +func NewMistralAdapter(config Config) *MistralAdapter { + clientConfig := openai.DefaultConfig(config.APIKey) + clientConfig.BaseURL = "https://api.mistral.ai/v1" + client := openai.NewClientWithConfig(clientConfig) + + return &MistralAdapter{ + client: client, + config: config, + } +} + +func (a *MistralAdapter) Transcribe(ctx context.Context, audioData []byte) (string, error) { + if len(audioData) == 0 { + return "", nil + } + + // Convert raw PCM to WAV format + wavData, err := convertToWAV(audioData) + if err != nil { + return "", fmt.Errorf("convert to WAV: %w", err) + } + + // Create transcription request + req := openai.AudioRequest{ + Model: a.config.Model, + Reader: bytes.NewReader(wavData), + FilePath: "audio.wav", + Language: a.config.Language, + } + + start := time.Now() + resp, err := a.client.CreateTranscription(ctx, req) + duration := time.Since(start) + + if err != nil { + log.Printf("mistral-adapter: API call failed after %v: %v", duration, err) + return "", fmt.Errorf("mistral transcription: %w", err) + } + + log.Printf("mistral-adapter: transcribed %d bytes in %v: %q", len(audioData), duration, resp.Text) + return resp.Text, nil +} diff --git a/internal/transcriber/transcriber.go b/internal/transcriber/transcriber.go index 3e04656..70a69b7 100644 --- a/internal/transcriber/transcriber.go +++ b/internal/transcriber/transcriber.go @@ -51,6 +51,12 @@ func NewTranscriber(config Config) (Transcriber, error) { } adapter = NewGroqTranslationAdapter(config) + case "mistral-transcription": + if config.APIKey == "" { + return nil, fmt.Errorf("Mistral API key required") + } + adapter = NewMistralAdapter(config) + default: return nil, fmt.Errorf("unsupported provider: %s", config.Provider) } diff --git a/internal/transcriber/transcriber_test.go b/internal/transcriber/transcriber_test.go index 51d5db9..700fd3c 100644 --- a/internal/transcriber/transcriber_test.go +++ b/internal/transcriber/transcriber_test.go @@ -75,6 +75,26 @@ func TestNewTranscriber(t *testing.T) { }, wantErr: true, }, + { + name: "valid mistral-transcription config", + config: Config{ + Provider: "mistral-transcription", + APIKey: "test-key", + Language: "de", + Model: "voxtral-mini-latest", + }, + wantErr: false, + }, + { + name: "mistral-transcription config without api key", + config: Config{ + Provider: "mistral-transcription", + APIKey: "", + Language: "de", + Model: "voxtral-mini-latest", + }, + wantErr: true, + }, { name: "unsupported provider", config: Config{