feat: better configuration
This commit is contained in:
@@ -1,772 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
"github.com/leonardotrapani/hyprvoice/internal/injection"
|
||||
"github.com/leonardotrapani/hyprvoice/internal/notify"
|
||||
"github.com/leonardotrapani/hyprvoice/internal/recording"
|
||||
"github.com/leonardotrapani/hyprvoice/internal/transcriber"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Recording RecordingConfig `toml:"recording"`
|
||||
Transcription TranscriptionConfig `toml:"transcription"`
|
||||
Injection InjectionConfig `toml:"injection"`
|
||||
Notifications NotificationsConfig `toml:"notifications"`
|
||||
Providers map[string]ProviderConfig `toml:"providers"`
|
||||
Keywords []string `toml:"keywords"`
|
||||
LLM LLMConfig `toml:"llm"`
|
||||
}
|
||||
|
||||
// ProviderConfig holds API key for a provider
|
||||
type ProviderConfig struct {
|
||||
APIKey string `toml:"api_key"`
|
||||
}
|
||||
|
||||
// LLMConfig configures the LLM post-processing phase
|
||||
type LLMConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
Provider string `toml:"provider"`
|
||||
Model string `toml:"model"`
|
||||
PostProcessing LLMPostProcessingConfig `toml:"post_processing"`
|
||||
CustomPrompt LLMCustomPromptConfig `toml:"custom_prompt"`
|
||||
}
|
||||
|
||||
// LLMPostProcessingConfig controls text cleanup options
|
||||
type LLMPostProcessingConfig struct {
|
||||
RemoveStutters bool `toml:"remove_stutters"`
|
||||
AddPunctuation bool `toml:"add_punctuation"`
|
||||
FixGrammar bool `toml:"fix_grammar"`
|
||||
RemoveFillerWords bool `toml:"remove_filler_words"`
|
||||
}
|
||||
|
||||
// LLMCustomPromptConfig allows custom prompts
|
||||
type LLMCustomPromptConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
Prompt string `toml:"prompt"`
|
||||
}
|
||||
|
||||
type RecordingConfig struct {
|
||||
SampleRate int `toml:"sample_rate"`
|
||||
Channels int `toml:"channels"`
|
||||
Format string `toml:"format"`
|
||||
BufferSize int `toml:"buffer_size"`
|
||||
Device string `toml:"device"`
|
||||
ChannelBufferSize int `toml:"channel_buffer_size"`
|
||||
Timeout time.Duration `toml:"timeout"`
|
||||
}
|
||||
|
||||
type TranscriptionConfig struct {
|
||||
Provider string `toml:"provider"`
|
||||
APIKey string `toml:"api_key"`
|
||||
Language string `toml:"language"`
|
||||
Model string `toml:"model"`
|
||||
}
|
||||
|
||||
type InjectionConfig struct {
|
||||
Backends []string `toml:"backends"`
|
||||
YdotoolTimeout time.Duration `toml:"ydotool_timeout"`
|
||||
WtypeTimeout time.Duration `toml:"wtype_timeout"`
|
||||
ClipboardTimeout time.Duration `toml:"clipboard_timeout"`
|
||||
}
|
||||
|
||||
type NotificationsConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
Type string `toml:"type"` // "desktop", "log", "none"
|
||||
Messages MessagesConfig `toml:"messages"`
|
||||
}
|
||||
|
||||
type MessageConfig struct {
|
||||
Title string `toml:"title"`
|
||||
Body string `toml:"body"`
|
||||
}
|
||||
|
||||
type MessagesConfig struct {
|
||||
RecordingStarted MessageConfig `toml:"recording_started"`
|
||||
Transcribing MessageConfig `toml:"transcribing"`
|
||||
LLMProcessing MessageConfig `toml:"llm_processing"`
|
||||
ConfigReloaded MessageConfig `toml:"config_reloaded"`
|
||||
OperationCancelled MessageConfig `toml:"operation_cancelled"`
|
||||
RecordingAborted MessageConfig `toml:"recording_aborted"`
|
||||
InjectionAborted MessageConfig `toml:"injection_aborted"`
|
||||
}
|
||||
|
||||
// Resolve merges user config with defaults from MessageDefs
|
||||
func (m *MessagesConfig) Resolve() map[notify.MessageType]notify.Message {
|
||||
result := make(map[notify.MessageType]notify.Message)
|
||||
|
||||
// Build toml tag → field index map
|
||||
v := reflect.ValueOf(m).Elem()
|
||||
t := v.Type()
|
||||
tagToField := make(map[string]int)
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
tagToField[t.Field(i).Tag.Get("toml")] = i
|
||||
}
|
||||
|
||||
for _, def := range notify.MessageDefs {
|
||||
msg := notify.Message{
|
||||
Title: def.DefaultTitle,
|
||||
Body: def.DefaultBody,
|
||||
IsError: def.IsError,
|
||||
}
|
||||
if idx, ok := tagToField[def.ConfigKey]; ok {
|
||||
userMsg := v.Field(idx).Interface().(MessageConfig)
|
||||
if userMsg.Title != "" {
|
||||
msg.Title = userMsg.Title
|
||||
}
|
||||
if userMsg.Body != "" {
|
||||
msg.Body = userMsg.Body
|
||||
}
|
||||
}
|
||||
result[def.Type] = msg
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (c *Config) ToRecordingConfig() recording.Config {
|
||||
return recording.Config{
|
||||
SampleRate: c.Recording.SampleRate,
|
||||
Channels: c.Recording.Channels,
|
||||
Format: c.Recording.Format,
|
||||
BufferSize: c.Recording.BufferSize,
|
||||
Device: c.Recording.Device,
|
||||
ChannelBufferSize: c.Recording.ChannelBufferSize,
|
||||
Timeout: c.Recording.Timeout,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) ToTranscriberConfig() transcriber.Config {
|
||||
config := transcriber.Config{
|
||||
Provider: c.Transcription.Provider,
|
||||
Language: c.Transcription.Language,
|
||||
Model: c.Transcription.Model,
|
||||
Keywords: c.Keywords,
|
||||
}
|
||||
|
||||
// Resolve API key: providers map -> legacy transcription.api_key -> environment variable
|
||||
config.APIKey = c.resolveAPIKeyForProvider(c.Transcription.Provider)
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
// resolveAPIKeyForProvider returns the API key for a provider from multiple sources
|
||||
func (c *Config) resolveAPIKeyForProvider(provider string) string {
|
||||
// Map transcription provider names to provider registry names
|
||||
providerName := provider
|
||||
envVar := ""
|
||||
switch provider {
|
||||
case "openai":
|
||||
providerName = "openai"
|
||||
envVar = "OPENAI_API_KEY"
|
||||
case "groq-transcription", "groq-translation":
|
||||
providerName = "groq"
|
||||
envVar = "GROQ_API_KEY"
|
||||
case "mistral-transcription":
|
||||
providerName = "mistral"
|
||||
envVar = "MISTRAL_API_KEY"
|
||||
case "elevenlabs":
|
||||
providerName = "elevenlabs"
|
||||
envVar = "ELEVENLABS_API_KEY"
|
||||
}
|
||||
|
||||
// 1. Check providers map
|
||||
if c.Providers != nil {
|
||||
if pc, ok := c.Providers[providerName]; ok && pc.APIKey != "" {
|
||||
return pc.APIKey
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check legacy transcription.api_key (backward compatibility)
|
||||
if c.Transcription.APIKey != "" {
|
||||
return c.Transcription.APIKey
|
||||
}
|
||||
|
||||
// 3. Check environment variable
|
||||
if envVar != "" {
|
||||
return os.Getenv(envVar)
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// LLMAdapterConfig is the configuration passed to the LLM adapter
|
||||
type LLMAdapterConfig struct {
|
||||
Provider string
|
||||
APIKey string
|
||||
Model string
|
||||
RemoveStutters bool
|
||||
AddPunctuation bool
|
||||
FixGrammar bool
|
||||
RemoveFillerWords bool
|
||||
CustomPrompt string
|
||||
Keywords []string
|
||||
}
|
||||
|
||||
// ToLLMConfig returns the LLM adapter configuration
|
||||
func (c *Config) ToLLMConfig() LLMAdapterConfig {
|
||||
config := LLMAdapterConfig{
|
||||
Provider: c.LLM.Provider,
|
||||
Model: c.LLM.Model,
|
||||
RemoveStutters: c.LLM.PostProcessing.RemoveStutters,
|
||||
AddPunctuation: c.LLM.PostProcessing.AddPunctuation,
|
||||
FixGrammar: c.LLM.PostProcessing.FixGrammar,
|
||||
RemoveFillerWords: c.LLM.PostProcessing.RemoveFillerWords,
|
||||
Keywords: c.Keywords,
|
||||
}
|
||||
|
||||
// Resolve API key for LLM provider
|
||||
if c.LLM.Provider != "" {
|
||||
config.APIKey = c.resolveAPIKeyForLLMProvider(c.LLM.Provider)
|
||||
}
|
||||
|
||||
// Add custom prompt if enabled
|
||||
if c.LLM.CustomPrompt.Enabled && c.LLM.CustomPrompt.Prompt != "" {
|
||||
config.CustomPrompt = c.LLM.CustomPrompt.Prompt
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
// resolveAPIKeyForLLMProvider returns the API key for an LLM provider
|
||||
func (c *Config) resolveAPIKeyForLLMProvider(provider string) string {
|
||||
envVar := ""
|
||||
switch provider {
|
||||
case "openai":
|
||||
envVar = "OPENAI_API_KEY"
|
||||
case "groq":
|
||||
envVar = "GROQ_API_KEY"
|
||||
}
|
||||
|
||||
// 1. Check providers map
|
||||
if c.Providers != nil {
|
||||
if pc, ok := c.Providers[provider]; ok && pc.APIKey != "" {
|
||||
return pc.APIKey
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check environment variable
|
||||
if envVar != "" {
|
||||
return os.Getenv(envVar)
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// IsLLMEnabled returns true if LLM post-processing is enabled and configured
|
||||
func (c *Config) IsLLMEnabled() bool {
|
||||
return c.LLM.Enabled && c.LLM.Provider != "" && c.LLM.Model != ""
|
||||
}
|
||||
|
||||
func (c *Config) ToInjectionConfig() injection.Config {
|
||||
return injection.Config{
|
||||
Backends: c.Injection.Backends,
|
||||
YdotoolTimeout: c.Injection.YdotoolTimeout,
|
||||
WtypeTimeout: c.Injection.WtypeTimeout,
|
||||
ClipboardTimeout: c.Injection.ClipboardTimeout,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) Validate() error {
|
||||
// Recording
|
||||
if c.Recording.SampleRate <= 0 {
|
||||
return fmt.Errorf("invalid recording.sample_rate: %d", c.Recording.SampleRate)
|
||||
}
|
||||
if c.Recording.Channels <= 0 {
|
||||
return fmt.Errorf("invalid recording.channels: %d", c.Recording.Channels)
|
||||
}
|
||||
if c.Recording.BufferSize <= 0 {
|
||||
return fmt.Errorf("invalid recording.buffer_size: %d", c.Recording.BufferSize)
|
||||
}
|
||||
if c.Recording.ChannelBufferSize <= 0 {
|
||||
return fmt.Errorf("invalid recording.channel_buffer_size: %d", c.Recording.ChannelBufferSize)
|
||||
}
|
||||
if c.Recording.Format == "" {
|
||||
return fmt.Errorf("invalid recording.format: empty")
|
||||
}
|
||||
if c.Recording.Timeout <= 0 {
|
||||
return fmt.Errorf("invalid recording.timeout: %v", c.Recording.Timeout)
|
||||
}
|
||||
|
||||
// Transcription
|
||||
if c.Transcription.Provider == "" {
|
||||
return fmt.Errorf("invalid transcription.provider: empty")
|
||||
}
|
||||
|
||||
// Validate provider-specific settings using unified API key resolution
|
||||
apiKey := c.resolveAPIKeyForProvider(c.Transcription.Provider)
|
||||
|
||||
switch c.Transcription.Provider {
|
||||
case "openai":
|
||||
if apiKey == "" {
|
||||
return fmt.Errorf("OpenAI API key required: not found in config (providers.openai.api_key, transcription.api_key) or environment variable (OPENAI_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)
|
||||
}
|
||||
|
||||
case "groq-transcription":
|
||||
if apiKey == "" {
|
||||
return fmt.Errorf("Groq API key required: not found in config (providers.groq.api_key, transcription.api_key) or environment variable (GROQ_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 Groq model
|
||||
validGroqModels := map[string]bool{"whisper-large-v3": true, "whisper-large-v3-turbo": true}
|
||||
if c.Transcription.Model != "" && !validGroqModels[c.Transcription.Model] {
|
||||
return fmt.Errorf("invalid model for groq-transcription: %s (must be whisper-large-v3 or whisper-large-v3-turbo)", c.Transcription.Model)
|
||||
}
|
||||
|
||||
case "groq-translation":
|
||||
if apiKey == "" {
|
||||
return fmt.Errorf("Groq API key required: not found in config (providers.groq.api_key, transcription.api_key) or environment variable (GROQ_API_KEY)")
|
||||
}
|
||||
|
||||
// For translation, language field hints at source language (output is always English)
|
||||
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 Groq translation model - only whisper-large-v3 is supported (no turbo)
|
||||
if c.Transcription.Model != "" && c.Transcription.Model != "whisper-large-v3" {
|
||||
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":
|
||||
if apiKey == "" {
|
||||
return fmt.Errorf("Mistral API key required: not found in config (providers.mistral.api_key, 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)
|
||||
}
|
||||
|
||||
case "elevenlabs":
|
||||
if apiKey == "" {
|
||||
return fmt.Errorf("ElevenLabs API key required: not found in config (providers.elevenlabs.api_key, transcription.api_key) or environment variable (ELEVENLABS_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', 'pt', 'es')", c.Transcription.Language)
|
||||
}
|
||||
|
||||
// Validate Eleven Labs model
|
||||
validModels := map[string]bool{"scribe_v1": true, "scribe_v2": true}
|
||||
if c.Transcription.Model != "" && !validModels[c.Transcription.Model] {
|
||||
return fmt.Errorf("invalid model for elevenlabs: %s (must be scribe_v1 or scribe_v2)", c.Transcription.Model)
|
||||
}
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unsupported transcription.provider: %s (must be openai, groq-transcription, groq-translation, mistral-transcription, or elevenlabs)", c.Transcription.Provider)
|
||||
}
|
||||
|
||||
if c.Transcription.Model == "" {
|
||||
return fmt.Errorf("invalid transcription.model: empty")
|
||||
}
|
||||
|
||||
// LLM (only validate if enabled)
|
||||
if c.LLM.Enabled {
|
||||
if c.LLM.Provider == "" {
|
||||
return fmt.Errorf("llm.provider required when llm.enabled = true")
|
||||
}
|
||||
if c.LLM.Model == "" {
|
||||
return fmt.Errorf("llm.model required when llm.enabled = true")
|
||||
}
|
||||
|
||||
// Validate LLM provider
|
||||
validLLMProviders := map[string]bool{"openai": true, "groq": true}
|
||||
if !validLLMProviders[c.LLM.Provider] {
|
||||
return fmt.Errorf("invalid llm.provider: %s (must be openai or groq)", c.LLM.Provider)
|
||||
}
|
||||
|
||||
// Check API key for LLM provider
|
||||
llmAPIKey := c.resolveAPIKeyForLLMProvider(c.LLM.Provider)
|
||||
if llmAPIKey == "" {
|
||||
switch c.LLM.Provider {
|
||||
case "openai":
|
||||
return fmt.Errorf("OpenAI API key required for LLM: not found in config (providers.openai.api_key) or environment variable (OPENAI_API_KEY)")
|
||||
case "groq":
|
||||
return fmt.Errorf("Groq API key required for LLM: not found in config (providers.groq.api_key) or environment variable (GROQ_API_KEY)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Injection
|
||||
if len(c.Injection.Backends) == 0 {
|
||||
return fmt.Errorf("invalid injection.backends: empty (must have at least one backend)")
|
||||
}
|
||||
validBackends := map[string]bool{"ydotool": true, "wtype": true, "clipboard": true}
|
||||
for _, backend := range c.Injection.Backends {
|
||||
if !validBackends[backend] {
|
||||
return fmt.Errorf("invalid injection.backends: unknown backend %q (must be ydotool, wtype, or clipboard)", backend)
|
||||
}
|
||||
}
|
||||
if c.Injection.YdotoolTimeout <= 0 {
|
||||
return fmt.Errorf("invalid injection.ydotool_timeout: %v", c.Injection.YdotoolTimeout)
|
||||
}
|
||||
if c.Injection.WtypeTimeout <= 0 {
|
||||
return fmt.Errorf("invalid injection.wtype_timeout: %v", c.Injection.WtypeTimeout)
|
||||
}
|
||||
if c.Injection.ClipboardTimeout <= 0 {
|
||||
return fmt.Errorf("invalid injection.clipboard_timeout: %v", c.Injection.ClipboardTimeout)
|
||||
}
|
||||
|
||||
// Notifications
|
||||
validTypes := map[string]bool{"desktop": true, "log": true, "none": true}
|
||||
if !validTypes[c.Notifications.Type] {
|
||||
return fmt.Errorf("invalid notifications.type: %s (must be desktop, log, or none)", c.Notifications.Type)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func isValidLanguageCode(code string) bool {
|
||||
validCodes := map[string]bool{
|
||||
"en": true, "es": true, "fr": true, "de": true, "it": true, "pt": true,
|
||||
"ru": true, "ja": true, "ko": true, "zh": true, "ar": true, "hi": true,
|
||||
"nl": true, "sv": true, "da": true, "no": true, "fi": true, "pl": true,
|
||||
"tr": true, "he": true, "th": true, "vi": true, "id": true, "ms": true,
|
||||
"uk": true, "cs": true, "hu": true, "ro": true, "bg": true, "hr": true,
|
||||
"sk": true, "sl": true, "et": true, "lv": true, "lt": true, "mt": true,
|
||||
"cy": true, "ga": true, "eu": true, "ca": true, "gl": true, "is": true,
|
||||
"mk": true, "sq": true, "az": true, "be": true, "ka": true, "hy": true,
|
||||
"kk": true, "ky": true, "tg": true, "uz": true, "mn": true, "ne": true,
|
||||
"si": true, "km": true, "lo": true, "my": true, "fa": true, "ps": true,
|
||||
"ur": true, "bn": true, "ta": true, "te": true, "ml": true, "kn": true,
|
||||
"gu": true, "pa": true, "or": true, "as": true, "mr": true, "sa": true,
|
||||
"sw": true, "yo": true, "ig": true, "ha": true, "zu": true, "xh": true,
|
||||
"af": true, "am": true, "mg": true, "so": true, "sn": true, "rw": true,
|
||||
}
|
||||
return validCodes[code]
|
||||
}
|
||||
|
||||
func GetConfigPath() (string, error) {
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get user config directory: %w", err)
|
||||
}
|
||||
|
||||
hyprvoiceDir := filepath.Join(configDir, "hyprvoice")
|
||||
if err := os.MkdirAll(hyprvoiceDir, 0755); err != nil {
|
||||
return "", fmt.Errorf("failed to create config directory: %w", err)
|
||||
}
|
||||
|
||||
return filepath.Join(hyprvoiceDir, "config.toml"), nil
|
||||
}
|
||||
|
||||
// legacyInjectionConfig for migration from old mode-based config
|
||||
type legacyInjectionConfig struct {
|
||||
Mode string `toml:"mode"`
|
||||
}
|
||||
|
||||
// legacyTranscriptionConfig for migration from old api_key in transcription
|
||||
type legacyTranscriptionConfig struct {
|
||||
APIKey string `toml:"api_key"`
|
||||
}
|
||||
|
||||
type legacyConfig struct {
|
||||
Injection legacyInjectionConfig `toml:"injection"`
|
||||
Transcription legacyTranscriptionConfig `toml:"transcription"`
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
configPath, err := GetConfigPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If config file doesn't exist, create it with defaults
|
||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||
log.Printf("Config: no config file found at %s, creating with defaults", configPath)
|
||||
if err := SaveDefaultConfig(); err != nil {
|
||||
return nil, fmt.Errorf("failed to create default config: %w", err)
|
||||
}
|
||||
log.Printf("Config: default configuration created successfully")
|
||||
return Load() // Recursively load the config, now file will exist
|
||||
}
|
||||
|
||||
log.Printf("Config: loading configuration from %s", configPath)
|
||||
var config Config
|
||||
if _, err := toml.DecodeFile(configPath, &config); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse config file %s: %w", configPath, err)
|
||||
}
|
||||
|
||||
// Parse legacy config for migrations
|
||||
var legacy legacyConfig
|
||||
toml.DecodeFile(configPath, &legacy)
|
||||
|
||||
// Migrate legacy mode-based config to backends
|
||||
if len(config.Injection.Backends) == 0 {
|
||||
config.migrateInjectionMode(legacy.Injection.Mode)
|
||||
}
|
||||
|
||||
// Migrate legacy transcription.api_key to providers map
|
||||
if legacy.Transcription.APIKey != "" && config.Providers == nil {
|
||||
config.migrateTranscriptionAPIKey(legacy.Transcription.APIKey)
|
||||
}
|
||||
|
||||
// Initialize providers map if nil
|
||||
if config.Providers == nil {
|
||||
config.Providers = make(map[string]ProviderConfig)
|
||||
}
|
||||
|
||||
// Set LLM defaults if not configured
|
||||
config.applyLLMDefaults()
|
||||
|
||||
log.Printf("Config: configuration loaded successfully")
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// migrateTranscriptionAPIKey migrates old transcription.api_key to providers map
|
||||
func (c *Config) migrateTranscriptionAPIKey(apiKey string) {
|
||||
if c.Providers == nil {
|
||||
c.Providers = make(map[string]ProviderConfig)
|
||||
}
|
||||
|
||||
// Determine which provider this key is for based on transcription.provider
|
||||
providerName := c.Transcription.Provider
|
||||
switch providerName {
|
||||
case "openai":
|
||||
c.Providers["openai"] = ProviderConfig{APIKey: apiKey}
|
||||
case "groq-transcription", "groq-translation":
|
||||
c.Providers["groq"] = ProviderConfig{APIKey: apiKey}
|
||||
case "mistral-transcription":
|
||||
c.Providers["mistral"] = ProviderConfig{APIKey: apiKey}
|
||||
case "elevenlabs":
|
||||
c.Providers["elevenlabs"] = ProviderConfig{APIKey: apiKey}
|
||||
default:
|
||||
// Unknown provider, try to guess based on key prefix
|
||||
if len(apiKey) > 3 && apiKey[:3] == "sk-" {
|
||||
c.Providers["openai"] = ProviderConfig{APIKey: apiKey}
|
||||
} else if len(apiKey) > 4 && apiKey[:4] == "gsk_" {
|
||||
c.Providers["groq"] = ProviderConfig{APIKey: apiKey}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("Config: migrated transcription.api_key to providers map. Run 'hyprvoice configure' to update config format.")
|
||||
}
|
||||
|
||||
// applyLLMDefaults sets default values for LLM config
|
||||
func (c *Config) applyLLMDefaults() {
|
||||
// Default post-processing options to true if LLM is enabled and not explicitly set
|
||||
// We detect "not set" by checking if all booleans are false (zero value)
|
||||
// Since the default behavior should be all true, we only apply if everything is false
|
||||
pp := &c.LLM.PostProcessing
|
||||
if !pp.RemoveStutters && !pp.AddPunctuation && !pp.FixGrammar && !pp.RemoveFillerWords {
|
||||
// Nothing was set, apply defaults
|
||||
pp.RemoveStutters = true
|
||||
pp.AddPunctuation = true
|
||||
pp.FixGrammar = true
|
||||
pp.RemoveFillerWords = true
|
||||
}
|
||||
}
|
||||
|
||||
// migrateInjectionMode converts old mode field to new backends array
|
||||
func (c *Config) migrateInjectionMode(mode string) {
|
||||
switch mode {
|
||||
case "clipboard":
|
||||
c.Injection.Backends = []string{"clipboard"}
|
||||
log.Printf("Config: migrated injection.mode='clipboard' to backends=['clipboard']")
|
||||
case "type":
|
||||
c.Injection.Backends = []string{"wtype"}
|
||||
log.Printf("Config: migrated injection.mode='type' to backends=['wtype']")
|
||||
case "fallback":
|
||||
c.Injection.Backends = []string{"wtype", "clipboard"}
|
||||
log.Printf("Config: migrated injection.mode='fallback' to backends=['wtype', 'clipboard']")
|
||||
default:
|
||||
// Default for new installs or unknown modes
|
||||
c.Injection.Backends = []string{"ydotool", "wtype", "clipboard"}
|
||||
if mode != "" {
|
||||
log.Printf("Config: unknown injection.mode='%s', using default backends", mode)
|
||||
}
|
||||
}
|
||||
|
||||
// Set default ydotool timeout if not set
|
||||
if c.Injection.YdotoolTimeout == 0 {
|
||||
c.Injection.YdotoolTimeout = 5 * time.Second
|
||||
}
|
||||
|
||||
log.Printf("Config: legacy 'mode' config detected - please update your config.toml to use 'backends' instead")
|
||||
}
|
||||
|
||||
func SaveDefaultConfig() error {
|
||||
configPath, err := GetConfigPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
file, err := os.Create(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create config file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
configContent := `# Hyprvoice Configuration
|
||||
# This file is automatically generated with defaults.
|
||||
# Edit values as needed - changes are applied immediately without daemon restart.
|
||||
#
|
||||
# MIGRATION NOTE: If upgrading from an older version, your transcription.api_key
|
||||
# will be automatically migrated to the new [providers.X] format. Run 'hyprvoice configure'
|
||||
# to update your config file structure.
|
||||
|
||||
# Keywords help both transcription and LLM understand domain-specific terms
|
||||
# Add names, technical terms, or brand names that might be misheard
|
||||
keywords = []
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Provider API Keys
|
||||
# Configure API keys for each provider you want to use.
|
||||
# Keys can also be set via environment variables: OPENAI_API_KEY, GROQ_API_KEY, etc.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[providers.openai]
|
||||
api_key = "" # OpenAI API key (or set OPENAI_API_KEY env var)
|
||||
|
||||
[providers.groq]
|
||||
api_key = "" # Groq API key (or set GROQ_API_KEY env var)
|
||||
|
||||
# Uncomment to configure additional providers:
|
||||
# [providers.mistral]
|
||||
# api_key = "" # Mistral API key (or set MISTRAL_API_KEY env var)
|
||||
# [providers.elevenlabs]
|
||||
# api_key = "" # ElevenLabs API key (or set ELEVENLABS_API_KEY env var)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Audio Recording
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[recording]
|
||||
sample_rate = 16000 # Audio sample rate in Hz (16000 recommended for speech)
|
||||
channels = 1 # Number of audio channels (1 = mono, 2 = stereo)
|
||||
format = "s16" # Audio format (s16 = 16-bit signed integers)
|
||||
buffer_size = 8192 # Internal buffer size in bytes (larger = less CPU, more latency)
|
||||
device = "" # PipeWire audio device (empty = use default microphone)
|
||||
channel_buffer_size = 30 # Audio frame buffer size (frames to buffer)
|
||||
timeout = "5m" # Maximum recording duration (e.g., "30s", "2m", "5m")
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Speech Transcription
|
||||
# Converts audio to text using speech-to-text APIs
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[transcription]
|
||||
provider = "openai" # "openai", "groq-transcription", "groq-translation", "mistral-transcription", "elevenlabs"
|
||||
language = "" # Language code (empty = auto-detect, "en", "it", "es", "fr", etc.)
|
||||
model = "whisper-1" # Model: OpenAI="whisper-1", Groq="whisper-large-v3", Mistral="voxtral-mini-latest", ElevenLabs="scribe_v1"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# LLM Post-Processing (Recommended)
|
||||
# Cleans up transcribed text: removes stutters, adds punctuation, fixes grammar
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[llm]
|
||||
enabled = true # Enable LLM post-processing (highly recommended)
|
||||
provider = "openai" # "openai" or "groq" (must have API key configured above)
|
||||
model = "gpt-4o-mini" # OpenAI: "gpt-4o-mini", Groq: "llama-3.3-70b-versatile"
|
||||
|
||||
[llm.post_processing]
|
||||
remove_stutters = true # Remove "um", "uh", repeated words
|
||||
add_punctuation = true # Add proper punctuation
|
||||
fix_grammar = true # Fix grammatical errors
|
||||
remove_filler_words = true # Remove "like", "you know", "basically"
|
||||
|
||||
[llm.custom_prompt]
|
||||
enabled = false # Enable custom instructions for LLM
|
||||
prompt = "" # Additional instructions (e.g., "Format as bullet points")
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Text Injection
|
||||
# How transcribed text is inserted into applications
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[injection]
|
||||
backends = ["ydotool", "wtype", "clipboard"] # Ordered fallback chain (tries each until one succeeds)
|
||||
ydotool_timeout = "5s" # Timeout for ydotool commands
|
||||
wtype_timeout = "5s" # Timeout for wtype commands
|
||||
clipboard_timeout = "3s" # Timeout for clipboard operations
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Desktop Notifications
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[notifications]
|
||||
enabled = true # Enable desktop notifications
|
||||
type = "desktop" # "desktop", "log", or "none"
|
||||
|
||||
# Custom notification messages (optional - defaults shown below)
|
||||
# Uncomment and modify to customize notification text
|
||||
# [notifications.messages]
|
||||
# [notifications.messages.recording_started]
|
||||
# title = "Hyprvoice"
|
||||
# body = "Recording Started"
|
||||
# [notifications.messages.transcribing]
|
||||
# title = "Hyprvoice"
|
||||
# body = "Recording Ended... Transcribing"
|
||||
# [notifications.messages.llm_processing]
|
||||
# title = "Hyprvoice"
|
||||
# body = "Processing..."
|
||||
# [notifications.messages.config_reloaded]
|
||||
# title = "Hyprvoice"
|
||||
# body = "Config Reloaded"
|
||||
# [notifications.messages.operation_cancelled]
|
||||
# title = "Hyprvoice"
|
||||
# body = "Operation Cancelled"
|
||||
# [notifications.messages.recording_aborted]
|
||||
# body = "Recording Aborted"
|
||||
# [notifications.messages.injection_aborted]
|
||||
# body = "Injection Aborted"
|
||||
#
|
||||
# Emoji-only example (for minimal pill-style notifications):
|
||||
# [notifications.messages.recording_started]
|
||||
# title = ""
|
||||
# body = "..."
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Reference: Provider Details
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
#
|
||||
# Transcription providers:
|
||||
# - "openai": OpenAI Whisper API (cloud-based, excellent accuracy)
|
||||
# - "groq-transcription": Groq Whisper API (very fast, models: whisper-large-v3, whisper-large-v3-turbo)
|
||||
# - "groq-translation": Groq translation to English (always outputs English text, model: whisper-large-v3)
|
||||
# - "mistral-transcription": Mistral Voxtral API (excellent for European languages, model: voxtral-mini-latest)
|
||||
# - "elevenlabs": ElevenLabs Scribe API (99 languages, models: scribe_v1, scribe_v2)
|
||||
#
|
||||
# LLM providers (for post-processing):
|
||||
# - "openai": GPT models (gpt-4o-mini recommended for cost/quality balance)
|
||||
# - "groq": Fast inference (llama-3.3-70b-versatile recommended)
|
||||
#
|
||||
# Injection backends:
|
||||
# - "ydotool": Uses ydotool (requires ydotoold daemon). Best for Chromium/Electron apps.
|
||||
# - "wtype": Uses wtype for Wayland. May have issues with some Chromium apps.
|
||||
# - "clipboard": Copies to clipboard only (most reliable, requires manual paste).
|
||||
#
|
||||
# Language codes: "" (auto-detect), "en", "it", "es", "fr", "de", "pt", etc.
|
||||
`
|
||||
|
||||
if _, err := file.WriteString(configContent); err != nil {
|
||||
return fmt.Errorf("failed to write config content: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/leonardotrapani/hyprvoice/internal/injection"
|
||||
"github.com/leonardotrapani/hyprvoice/internal/recording"
|
||||
"github.com/leonardotrapani/hyprvoice/internal/transcriber"
|
||||
)
|
||||
|
||||
func (c *Config) ToRecordingConfig() recording.Config {
|
||||
return recording.Config{
|
||||
SampleRate: c.Recording.SampleRate,
|
||||
Channels: c.Recording.Channels,
|
||||
Format: c.Recording.Format,
|
||||
BufferSize: c.Recording.BufferSize,
|
||||
Device: c.Recording.Device,
|
||||
ChannelBufferSize: c.Recording.ChannelBufferSize,
|
||||
Timeout: c.Recording.Timeout,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) ToTranscriberConfig() transcriber.Config {
|
||||
config := transcriber.Config{
|
||||
Provider: c.Transcription.Provider,
|
||||
Language: c.Transcription.Language,
|
||||
Model: c.Transcription.Model,
|
||||
Keywords: c.Keywords,
|
||||
}
|
||||
|
||||
config.APIKey = c.resolveAPIKeyForProvider(c.Transcription.Provider)
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
// resolveAPIKeyForProvider returns the API key for a provider from multiple sources
|
||||
func (c *Config) resolveAPIKeyForProvider(provider string) string {
|
||||
providerName := provider
|
||||
envVar := ""
|
||||
switch provider {
|
||||
case "openai":
|
||||
providerName = "openai"
|
||||
envVar = "OPENAI_API_KEY"
|
||||
case "groq-transcription", "groq-translation":
|
||||
providerName = "groq"
|
||||
envVar = "GROQ_API_KEY"
|
||||
case "mistral-transcription":
|
||||
providerName = "mistral"
|
||||
envVar = "MISTRAL_API_KEY"
|
||||
case "elevenlabs":
|
||||
providerName = "elevenlabs"
|
||||
envVar = "ELEVENLABS_API_KEY"
|
||||
}
|
||||
|
||||
if c.Providers != nil {
|
||||
if pc, ok := c.Providers[providerName]; ok && pc.APIKey != "" {
|
||||
return pc.APIKey
|
||||
}
|
||||
}
|
||||
|
||||
if c.Transcription.APIKey != "" {
|
||||
return c.Transcription.APIKey
|
||||
}
|
||||
|
||||
if envVar != "" {
|
||||
return os.Getenv(envVar)
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// ToLLMConfig returns the LLM adapter configuration
|
||||
func (c *Config) ToLLMConfig() LLMAdapterConfig {
|
||||
config := LLMAdapterConfig{
|
||||
Provider: c.LLM.Provider,
|
||||
Model: c.LLM.Model,
|
||||
RemoveStutters: c.LLM.PostProcessing.RemoveStutters,
|
||||
AddPunctuation: c.LLM.PostProcessing.AddPunctuation,
|
||||
FixGrammar: c.LLM.PostProcessing.FixGrammar,
|
||||
RemoveFillerWords: c.LLM.PostProcessing.RemoveFillerWords,
|
||||
Keywords: c.Keywords,
|
||||
}
|
||||
|
||||
if c.LLM.Provider != "" {
|
||||
config.APIKey = c.resolveAPIKeyForLLMProvider(c.LLM.Provider)
|
||||
}
|
||||
|
||||
if c.LLM.CustomPrompt.Enabled && c.LLM.CustomPrompt.Prompt != "" {
|
||||
config.CustomPrompt = c.LLM.CustomPrompt.Prompt
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
// resolveAPIKeyForLLMProvider returns the API key for an LLM provider
|
||||
func (c *Config) resolveAPIKeyForLLMProvider(provider string) string {
|
||||
envVar := ""
|
||||
switch provider {
|
||||
case "openai":
|
||||
envVar = "OPENAI_API_KEY"
|
||||
case "groq":
|
||||
envVar = "GROQ_API_KEY"
|
||||
}
|
||||
|
||||
if c.Providers != nil {
|
||||
if pc, ok := c.Providers[provider]; ok && pc.APIKey != "" {
|
||||
return pc.APIKey
|
||||
}
|
||||
}
|
||||
|
||||
if envVar != "" {
|
||||
return os.Getenv(envVar)
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// IsLLMEnabled returns true if LLM post-processing is enabled and configured
|
||||
func (c *Config) IsLLMEnabled() bool {
|
||||
return c.LLM.Enabled && c.LLM.Provider != "" && c.LLM.Model != ""
|
||||
}
|
||||
|
||||
func (c *Config) ToInjectionConfig() injection.Config {
|
||||
return injection.Config{
|
||||
Backends: c.Injection.Backends,
|
||||
YdotoolTimeout: c.Injection.YdotoolTimeout,
|
||||
WtypeTimeout: c.Injection.WtypeTimeout,
|
||||
ClipboardTimeout: c.Injection.ClipboardTimeout,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
func GetConfigPath() (string, error) {
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get user config directory: %w", err)
|
||||
}
|
||||
|
||||
hyprvoiceDir := filepath.Join(configDir, "hyprvoice")
|
||||
if err := os.MkdirAll(hyprvoiceDir, 0755); err != nil {
|
||||
return "", fmt.Errorf("failed to create config directory: %w", err)
|
||||
}
|
||||
|
||||
return filepath.Join(hyprvoiceDir, "config.toml"), nil
|
||||
}
|
||||
|
||||
// legacyInjectionConfig for migration from old mode-based config
|
||||
type legacyInjectionConfig struct {
|
||||
Mode string `toml:"mode"`
|
||||
}
|
||||
|
||||
// legacyTranscriptionConfig for migration from old api_key in transcription
|
||||
type legacyTranscriptionConfig struct {
|
||||
APIKey string `toml:"api_key"`
|
||||
}
|
||||
|
||||
type legacyConfig struct {
|
||||
Injection legacyInjectionConfig `toml:"injection"`
|
||||
Transcription legacyTranscriptionConfig `toml:"transcription"`
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
configPath, err := GetConfigPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||
log.Printf("Config: no config file found at %s, creating with defaults", configPath)
|
||||
if err := SaveDefaultConfig(); err != nil {
|
||||
return nil, fmt.Errorf("failed to create default config: %w", err)
|
||||
}
|
||||
log.Printf("Config: default configuration created successfully")
|
||||
return Load()
|
||||
}
|
||||
|
||||
log.Printf("Config: loading configuration from %s", configPath)
|
||||
var config Config
|
||||
if _, err := toml.DecodeFile(configPath, &config); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse config file %s: %w", configPath, err)
|
||||
}
|
||||
|
||||
var legacy legacyConfig
|
||||
toml.DecodeFile(configPath, &legacy)
|
||||
|
||||
if len(config.Injection.Backends) == 0 {
|
||||
config.migrateInjectionMode(legacy.Injection.Mode)
|
||||
}
|
||||
|
||||
if legacy.Transcription.APIKey != "" && config.Providers == nil {
|
||||
config.migrateTranscriptionAPIKey(legacy.Transcription.APIKey)
|
||||
}
|
||||
|
||||
if config.Providers == nil {
|
||||
config.Providers = make(map[string]ProviderConfig)
|
||||
}
|
||||
|
||||
config.applyLLMDefaults()
|
||||
|
||||
log.Printf("Config: configuration loaded successfully")
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// migrateTranscriptionAPIKey migrates old transcription.api_key to providers map
|
||||
func (c *Config) migrateTranscriptionAPIKey(apiKey string) {
|
||||
if c.Providers == nil {
|
||||
c.Providers = make(map[string]ProviderConfig)
|
||||
}
|
||||
|
||||
providerName := c.Transcription.Provider
|
||||
switch providerName {
|
||||
case "openai":
|
||||
c.Providers["openai"] = ProviderConfig{APIKey: apiKey}
|
||||
case "groq-transcription", "groq-translation":
|
||||
c.Providers["groq"] = ProviderConfig{APIKey: apiKey}
|
||||
case "mistral-transcription":
|
||||
c.Providers["mistral"] = ProviderConfig{APIKey: apiKey}
|
||||
case "elevenlabs":
|
||||
c.Providers["elevenlabs"] = ProviderConfig{APIKey: apiKey}
|
||||
default:
|
||||
if len(apiKey) > 3 && apiKey[:3] == "sk-" {
|
||||
c.Providers["openai"] = ProviderConfig{APIKey: apiKey}
|
||||
} else if len(apiKey) > 4 && apiKey[:4] == "gsk_" {
|
||||
c.Providers["groq"] = ProviderConfig{APIKey: apiKey}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("Config: migrated transcription.api_key to providers map. Run 'hyprvoice configure' to update config format.")
|
||||
}
|
||||
|
||||
// applyLLMDefaults sets default values for LLM config
|
||||
func (c *Config) applyLLMDefaults() {
|
||||
pp := &c.LLM.PostProcessing
|
||||
if !pp.RemoveStutters && !pp.AddPunctuation && !pp.FixGrammar && !pp.RemoveFillerWords {
|
||||
pp.RemoveStutters = true
|
||||
pp.AddPunctuation = true
|
||||
pp.FixGrammar = true
|
||||
pp.RemoveFillerWords = true
|
||||
}
|
||||
}
|
||||
|
||||
// migrateInjectionMode converts old mode field to new backends array
|
||||
func (c *Config) migrateInjectionMode(mode string) {
|
||||
switch mode {
|
||||
case "clipboard":
|
||||
c.Injection.Backends = []string{"clipboard"}
|
||||
log.Printf("Config: migrated injection.mode='clipboard' to backends=['clipboard']")
|
||||
case "type":
|
||||
c.Injection.Backends = []string{"wtype"}
|
||||
log.Printf("Config: migrated injection.mode='type' to backends=['wtype']")
|
||||
case "fallback":
|
||||
c.Injection.Backends = []string{"wtype", "clipboard"}
|
||||
log.Printf("Config: migrated injection.mode='fallback' to backends=['wtype', 'clipboard']")
|
||||
default:
|
||||
c.Injection.Backends = []string{"ydotool", "wtype", "clipboard"}
|
||||
if mode != "" {
|
||||
log.Printf("Config: unknown injection.mode='%s', using default backends", mode)
|
||||
}
|
||||
}
|
||||
|
||||
if c.Injection.YdotoolTimeout == 0 {
|
||||
c.Injection.YdotoolTimeout = 5 * time.Second
|
||||
}
|
||||
|
||||
log.Printf("Config: legacy 'mode' config detected - please update your config.toml to use 'backends' instead")
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func SaveDefaultConfig() error {
|
||||
configPath, err := GetConfigPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
file, err := os.Create(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create config file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
configContent := `# Hyprvoice Configuration
|
||||
# This file is automatically generated with defaults.
|
||||
# Edit values as needed - changes are applied immediately without daemon restart.
|
||||
#
|
||||
# MIGRATION NOTE: If upgrading from an older version, your transcription.api_key
|
||||
# will be automatically migrated to the new [providers.X] format. Run 'hyprvoice configure'
|
||||
# to update your config file structure.
|
||||
|
||||
# Keywords help both transcription and LLM understand domain-specific terms
|
||||
# Add names, technical terms, or brand names that might be misheard
|
||||
keywords = []
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Provider API Keys
|
||||
# Configure API keys for each provider you want to use.
|
||||
# Keys can also be set via environment variables: OPENAI_API_KEY, GROQ_API_KEY, etc.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[providers.openai]
|
||||
api_key = "" # OpenAI API key (or set OPENAI_API_KEY env var)
|
||||
|
||||
[providers.groq]
|
||||
api_key = "" # Groq API key (or set GROQ_API_KEY env var)
|
||||
|
||||
# Uncomment to configure additional providers:
|
||||
# [providers.mistral]
|
||||
# api_key = "" # Mistral API key (or set MISTRAL_API_KEY env var)
|
||||
# [providers.elevenlabs]
|
||||
# api_key = "" # ElevenLabs API key (or set ELEVENLABS_API_KEY env var)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Audio Recording
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[recording]
|
||||
sample_rate = 16000 # Audio sample rate in Hz (16000 recommended for speech)
|
||||
channels = 1 # Number of audio channels (1 = mono, 2 = stereo)
|
||||
format = "s16" # Audio format (s16 = 16-bit signed integers)
|
||||
buffer_size = 8192 # Internal buffer size in bytes (larger = less CPU, more latency)
|
||||
device = "" # PipeWire audio device (empty = use default microphone)
|
||||
channel_buffer_size = 30 # Audio frame buffer size (frames to buffer)
|
||||
timeout = "5m" # Maximum recording duration (e.g., "30s", "2m", "5m")
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Speech Transcription
|
||||
# Converts audio to text using speech-to-text APIs
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[transcription]
|
||||
provider = "openai" # "openai", "groq-transcription", "groq-translation", "mistral-transcription", "elevenlabs"
|
||||
language = "" # Language code (empty = auto-detect, "en", "it", "es", "fr", etc.)
|
||||
model = "whisper-1" # Model: OpenAI="whisper-1", Groq="whisper-large-v3", Mistral="voxtral-mini-latest", ElevenLabs="scribe_v1"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# LLM Post-Processing (Recommended)
|
||||
# Cleans up transcribed text: removes stutters, adds punctuation, fixes grammar
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[llm]
|
||||
enabled = true # Enable LLM post-processing (highly recommended)
|
||||
provider = "openai" # "openai" or "groq" (must have API key configured above)
|
||||
model = "gpt-4o-mini" # OpenAI: "gpt-4o-mini", Groq: "llama-3.3-70b-versatile"
|
||||
|
||||
[llm.post_processing]
|
||||
remove_stutters = true # Remove "um", "uh", repeated words
|
||||
add_punctuation = true # Add proper punctuation
|
||||
fix_grammar = true # Fix grammatical errors
|
||||
remove_filler_words = true # Remove "like", "you know", "basically"
|
||||
|
||||
[llm.custom_prompt]
|
||||
enabled = false # Enable custom instructions for LLM
|
||||
prompt = "" # Additional instructions (e.g., "Format as bullet points")
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Text Injection
|
||||
# How transcribed text is inserted into applications
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[injection]
|
||||
backends = ["ydotool", "wtype", "clipboard"] # Ordered fallback chain (tries each until one succeeds)
|
||||
ydotool_timeout = "5s" # Timeout for ydotool commands
|
||||
wtype_timeout = "5s" # Timeout for wtype commands
|
||||
clipboard_timeout = "3s" # Timeout for clipboard operations
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Desktop Notifications
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[notifications]
|
||||
enabled = true # Enable desktop notifications
|
||||
type = "desktop" # "desktop", "log", or "none"
|
||||
|
||||
# Custom notification messages (optional - defaults shown below)
|
||||
# Uncomment and modify to customize notification text
|
||||
# [notifications.messages]
|
||||
# [notifications.messages.recording_started]
|
||||
# title = "Hyprvoice"
|
||||
# body = "Recording Started"
|
||||
# [notifications.messages.transcribing]
|
||||
# title = "Hyprvoice"
|
||||
# body = "Recording Ended... Transcribing"
|
||||
# [notifications.messages.llm_processing]
|
||||
# title = "Hyprvoice"
|
||||
# body = "Processing..."
|
||||
# [notifications.messages.config_reloaded]
|
||||
# title = "Hyprvoice"
|
||||
# body = "Config Reloaded"
|
||||
# [notifications.messages.operation_cancelled]
|
||||
# title = "Hyprvoice"
|
||||
# body = "Operation Cancelled"
|
||||
# [notifications.messages.recording_aborted]
|
||||
# body = "Recording Aborted"
|
||||
# [notifications.messages.injection_aborted]
|
||||
# body = "Injection Aborted"
|
||||
#
|
||||
# Emoji-only example (for minimal pill-style notifications):
|
||||
# [notifications.messages.recording_started]
|
||||
# title = ""
|
||||
# body = "..."
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Reference: Provider Details
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
#
|
||||
# Transcription providers:
|
||||
# - "openai": OpenAI Whisper API (cloud-based, excellent accuracy)
|
||||
# - "groq-transcription": Groq Whisper API (very fast, models: whisper-large-v3, whisper-large-v3-turbo)
|
||||
# - "groq-translation": Groq translation to English (always outputs English text, model: whisper-large-v3)
|
||||
# - "mistral-transcription": Mistral Voxtral API (excellent for European languages, model: voxtral-mini-latest)
|
||||
# - "elevenlabs": ElevenLabs Scribe API (99 languages, models: scribe_v1, scribe_v2)
|
||||
#
|
||||
# LLM providers (for post-processing):
|
||||
# - "openai": GPT models (gpt-4o-mini recommended for cost/quality balance)
|
||||
# - "groq": Fast inference (llama-3.3-70b-versatile recommended)
|
||||
#
|
||||
# Injection backends:
|
||||
# - "ydotool": Uses ydotool (requires ydotoold daemon). Best for Chromium/Electron apps.
|
||||
# - "wtype": Uses wtype for Wayland. May have issues with some Chromium apps.
|
||||
# - "clipboard": Copies to clipboard only (most reliable, requires manual paste).
|
||||
#
|
||||
# Language codes: "" (auto-detect), "en", "it", "es", "fr", "de", "pt", etc.
|
||||
`
|
||||
|
||||
if _, err := file.WriteString(configContent); err != nil {
|
||||
return fmt.Errorf("failed to write config content: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
"github.com/leonardotrapani/hyprvoice/internal/notify"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Recording RecordingConfig `toml:"recording"`
|
||||
Transcription TranscriptionConfig `toml:"transcription"`
|
||||
Injection InjectionConfig `toml:"injection"`
|
||||
Notifications NotificationsConfig `toml:"notifications"`
|
||||
Providers map[string]ProviderConfig `toml:"providers"`
|
||||
Keywords []string `toml:"keywords"`
|
||||
LLM LLMConfig `toml:"llm"`
|
||||
}
|
||||
|
||||
// ProviderConfig holds API key for a provider
|
||||
type ProviderConfig struct {
|
||||
APIKey string `toml:"api_key"`
|
||||
}
|
||||
|
||||
// LLMConfig configures the LLM post-processing phase
|
||||
type LLMConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
Provider string `toml:"provider"`
|
||||
Model string `toml:"model"`
|
||||
PostProcessing LLMPostProcessingConfig `toml:"post_processing"`
|
||||
CustomPrompt LLMCustomPromptConfig `toml:"custom_prompt"`
|
||||
}
|
||||
|
||||
// LLMPostProcessingConfig controls text cleanup options
|
||||
type LLMPostProcessingConfig struct {
|
||||
RemoveStutters bool `toml:"remove_stutters"`
|
||||
AddPunctuation bool `toml:"add_punctuation"`
|
||||
FixGrammar bool `toml:"fix_grammar"`
|
||||
RemoveFillerWords bool `toml:"remove_filler_words"`
|
||||
}
|
||||
|
||||
// LLMCustomPromptConfig allows custom prompts
|
||||
type LLMCustomPromptConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
Prompt string `toml:"prompt"`
|
||||
}
|
||||
|
||||
type RecordingConfig struct {
|
||||
SampleRate int `toml:"sample_rate"`
|
||||
Channels int `toml:"channels"`
|
||||
Format string `toml:"format"`
|
||||
BufferSize int `toml:"buffer_size"`
|
||||
Device string `toml:"device"`
|
||||
ChannelBufferSize int `toml:"channel_buffer_size"`
|
||||
Timeout time.Duration `toml:"timeout"`
|
||||
}
|
||||
|
||||
type TranscriptionConfig struct {
|
||||
Provider string `toml:"provider"`
|
||||
APIKey string `toml:"api_key"`
|
||||
Language string `toml:"language"`
|
||||
Model string `toml:"model"`
|
||||
}
|
||||
|
||||
type InjectionConfig struct {
|
||||
Backends []string `toml:"backends"`
|
||||
YdotoolTimeout time.Duration `toml:"ydotool_timeout"`
|
||||
WtypeTimeout time.Duration `toml:"wtype_timeout"`
|
||||
ClipboardTimeout time.Duration `toml:"clipboard_timeout"`
|
||||
}
|
||||
|
||||
type NotificationsConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
Type string `toml:"type"` // "desktop", "log", "none"
|
||||
Messages MessagesConfig `toml:"messages"`
|
||||
}
|
||||
|
||||
type MessageConfig struct {
|
||||
Title string `toml:"title"`
|
||||
Body string `toml:"body"`
|
||||
}
|
||||
|
||||
type MessagesConfig struct {
|
||||
RecordingStarted MessageConfig `toml:"recording_started"`
|
||||
Transcribing MessageConfig `toml:"transcribing"`
|
||||
LLMProcessing MessageConfig `toml:"llm_processing"`
|
||||
ConfigReloaded MessageConfig `toml:"config_reloaded"`
|
||||
OperationCancelled MessageConfig `toml:"operation_cancelled"`
|
||||
RecordingAborted MessageConfig `toml:"recording_aborted"`
|
||||
InjectionAborted MessageConfig `toml:"injection_aborted"`
|
||||
}
|
||||
|
||||
// Resolve merges user config with defaults from MessageDefs
|
||||
func (m *MessagesConfig) Resolve() map[notify.MessageType]notify.Message {
|
||||
result := make(map[notify.MessageType]notify.Message)
|
||||
|
||||
v := reflect.ValueOf(m).Elem()
|
||||
t := v.Type()
|
||||
tagToField := make(map[string]int)
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
tagToField[t.Field(i).Tag.Get("toml")] = i
|
||||
}
|
||||
|
||||
for _, def := range notify.MessageDefs {
|
||||
msg := notify.Message{
|
||||
Title: def.DefaultTitle,
|
||||
Body: def.DefaultBody,
|
||||
IsError: def.IsError,
|
||||
}
|
||||
if idx, ok := tagToField[def.ConfigKey]; ok {
|
||||
userMsg := v.Field(idx).Interface().(MessageConfig)
|
||||
if userMsg.Title != "" {
|
||||
msg.Title = userMsg.Title
|
||||
}
|
||||
if userMsg.Body != "" {
|
||||
msg.Body = userMsg.Body
|
||||
}
|
||||
}
|
||||
result[def.Type] = msg
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// LLMAdapterConfig is the configuration passed to the LLM adapter
|
||||
type LLMAdapterConfig struct {
|
||||
Provider string
|
||||
APIKey string
|
||||
Model string
|
||||
RemoveStutters bool
|
||||
AddPunctuation bool
|
||||
FixGrammar bool
|
||||
RemoveFillerWords bool
|
||||
CustomPrompt string
|
||||
Keywords []string
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package config
|
||||
|
||||
import "fmt"
|
||||
|
||||
func (c *Config) Validate() error {
|
||||
if c.Recording.SampleRate <= 0 {
|
||||
return fmt.Errorf("invalid recording.sample_rate: %d", c.Recording.SampleRate)
|
||||
}
|
||||
if c.Recording.Channels <= 0 {
|
||||
return fmt.Errorf("invalid recording.channels: %d", c.Recording.Channels)
|
||||
}
|
||||
if c.Recording.BufferSize <= 0 {
|
||||
return fmt.Errorf("invalid recording.buffer_size: %d", c.Recording.BufferSize)
|
||||
}
|
||||
if c.Recording.ChannelBufferSize <= 0 {
|
||||
return fmt.Errorf("invalid recording.channel_buffer_size: %d", c.Recording.ChannelBufferSize)
|
||||
}
|
||||
if c.Recording.Format == "" {
|
||||
return fmt.Errorf("invalid recording.format: empty")
|
||||
}
|
||||
if c.Recording.Timeout <= 0 {
|
||||
return fmt.Errorf("invalid recording.timeout: %v", c.Recording.Timeout)
|
||||
}
|
||||
|
||||
if c.Transcription.Provider == "" {
|
||||
return fmt.Errorf("invalid transcription.provider: empty")
|
||||
}
|
||||
|
||||
apiKey := c.resolveAPIKeyForProvider(c.Transcription.Provider)
|
||||
|
||||
switch c.Transcription.Provider {
|
||||
case "openai":
|
||||
if apiKey == "" {
|
||||
return fmt.Errorf("OpenAI API key required: not found in config (providers.openai.api_key, transcription.api_key) or environment variable (OPENAI_API_KEY)")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
case "groq-transcription":
|
||||
if apiKey == "" {
|
||||
return fmt.Errorf("Groq API key required: not found in config (providers.groq.api_key, transcription.api_key) or environment variable (GROQ_API_KEY)")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
validGroqModels := map[string]bool{"whisper-large-v3": true, "whisper-large-v3-turbo": true}
|
||||
if c.Transcription.Model != "" && !validGroqModels[c.Transcription.Model] {
|
||||
return fmt.Errorf("invalid model for groq-transcription: %s (must be whisper-large-v3 or whisper-large-v3-turbo)", c.Transcription.Model)
|
||||
}
|
||||
|
||||
case "groq-translation":
|
||||
if apiKey == "" {
|
||||
return fmt.Errorf("Groq API key required: not found in config (providers.groq.api_key, transcription.api_key) or environment variable (GROQ_API_KEY)")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
if c.Transcription.Model != "" && c.Transcription.Model != "whisper-large-v3" {
|
||||
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":
|
||||
if apiKey == "" {
|
||||
return fmt.Errorf("Mistral API key required: not found in config (providers.mistral.api_key, transcription.api_key) or environment variable (MISTRAL_API_KEY)")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
case "elevenlabs":
|
||||
if apiKey == "" {
|
||||
return fmt.Errorf("ElevenLabs API key required: not found in config (providers.elevenlabs.api_key, transcription.api_key) or environment variable (ELEVENLABS_API_KEY)")
|
||||
}
|
||||
|
||||
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', 'pt', 'es')", c.Transcription.Language)
|
||||
}
|
||||
|
||||
validModels := map[string]bool{"scribe_v1": true, "scribe_v2": true}
|
||||
if c.Transcription.Model != "" && !validModels[c.Transcription.Model] {
|
||||
return fmt.Errorf("invalid model for elevenlabs: %s (must be scribe_v1 or scribe_v2)", c.Transcription.Model)
|
||||
}
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unsupported transcription.provider: %s (must be openai, groq-transcription, groq-translation, mistral-transcription, or elevenlabs)", c.Transcription.Provider)
|
||||
}
|
||||
|
||||
if c.Transcription.Model == "" {
|
||||
return fmt.Errorf("invalid transcription.model: empty")
|
||||
}
|
||||
|
||||
if c.LLM.Enabled {
|
||||
if c.LLM.Provider == "" {
|
||||
return fmt.Errorf("llm.provider required when llm.enabled = true")
|
||||
}
|
||||
if c.LLM.Model == "" {
|
||||
return fmt.Errorf("llm.model required when llm.enabled = true")
|
||||
}
|
||||
|
||||
validLLMProviders := map[string]bool{"openai": true, "groq": true}
|
||||
if !validLLMProviders[c.LLM.Provider] {
|
||||
return fmt.Errorf("invalid llm.provider: %s (must be openai or groq)", c.LLM.Provider)
|
||||
}
|
||||
|
||||
llmAPIKey := c.resolveAPIKeyForLLMProvider(c.LLM.Provider)
|
||||
if llmAPIKey == "" {
|
||||
switch c.LLM.Provider {
|
||||
case "openai":
|
||||
return fmt.Errorf("OpenAI API key required for LLM: not found in config (providers.openai.api_key) or environment variable (OPENAI_API_KEY)")
|
||||
case "groq":
|
||||
return fmt.Errorf("Groq API key required for LLM: not found in config (providers.groq.api_key) or environment variable (GROQ_API_KEY)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(c.Injection.Backends) == 0 {
|
||||
return fmt.Errorf("invalid injection.backends: empty (must have at least one backend)")
|
||||
}
|
||||
validBackends := map[string]bool{"ydotool": true, "wtype": true, "clipboard": true}
|
||||
for _, backend := range c.Injection.Backends {
|
||||
if !validBackends[backend] {
|
||||
return fmt.Errorf("invalid injection.backends: unknown backend %q (must be ydotool, wtype, or clipboard)", backend)
|
||||
}
|
||||
}
|
||||
if c.Injection.YdotoolTimeout <= 0 {
|
||||
return fmt.Errorf("invalid injection.ydotool_timeout: %v", c.Injection.YdotoolTimeout)
|
||||
}
|
||||
if c.Injection.WtypeTimeout <= 0 {
|
||||
return fmt.Errorf("invalid injection.wtype_timeout: %v", c.Injection.WtypeTimeout)
|
||||
}
|
||||
if c.Injection.ClipboardTimeout <= 0 {
|
||||
return fmt.Errorf("invalid injection.clipboard_timeout: %v", c.Injection.ClipboardTimeout)
|
||||
}
|
||||
|
||||
validTypes := map[string]bool{"desktop": true, "log": true, "none": true}
|
||||
if !validTypes[c.Notifications.Type] {
|
||||
return fmt.Errorf("invalid notifications.type: %s (must be desktop, log, or none)", c.Notifications.Type)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func isValidLanguageCode(code string) bool {
|
||||
validCodes := map[string]bool{
|
||||
"en": true, "es": true, "fr": true, "de": true, "it": true, "pt": true,
|
||||
"ru": true, "ja": true, "ko": true, "zh": true, "ar": true, "hi": true,
|
||||
"nl": true, "sv": true, "da": true, "no": true, "fi": true, "pl": true,
|
||||
"tr": true, "he": true, "th": true, "vi": true, "id": true, "ms": true,
|
||||
"uk": true, "cs": true, "hu": true, "ro": true, "bg": true, "hr": true,
|
||||
"sk": true, "sl": true, "et": true, "lv": true, "lt": true, "mt": true,
|
||||
"cy": true, "ga": true, "eu": true, "ca": true, "gl": true, "is": true,
|
||||
"mk": true, "sq": true, "az": true, "be": true, "ka": true, "hy": true,
|
||||
"kk": true, "ky": true, "tg": true, "uz": true, "mn": true, "ne": true,
|
||||
"si": true, "km": true, "lo": true, "my": true, "fa": true, "ps": true,
|
||||
"ur": true, "bn": true, "ta": true, "te": true, "ml": true, "kn": true,
|
||||
"gu": true, "pa": true, "or": true, "as": true, "mr": true, "sa": true,
|
||||
"sw": true, "yo": true, "ig": true, "ha": true, "zu": true, "xh": true,
|
||||
"af": true, "am": true, "mg": true, "so": true, "sn": true, "rw": true,
|
||||
}
|
||||
return validCodes[code]
|
||||
}
|
||||
+32
-1175
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,225 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/leonardotrapani/hyprvoice/internal/config"
|
||||
)
|
||||
|
||||
// AdvancedSection represents a section in the advanced settings menu
|
||||
type AdvancedSection string
|
||||
|
||||
const (
|
||||
AdvancedRecording AdvancedSection = "recording"
|
||||
AdvancedInjectionTimeout AdvancedSection = "injection_timeout"
|
||||
AdvancedBack AdvancedSection = "back"
|
||||
)
|
||||
|
||||
// editAdvanced handles the advanced settings submenu
|
||||
func editAdvanced(cfg *config.Config) error {
|
||||
for {
|
||||
options := []huh.Option[AdvancedSection]{
|
||||
huh.NewOption(formatAdvancedRecordingLabel(cfg), AdvancedRecording),
|
||||
huh.NewOption(formatAdvancedInjectionTimeoutLabel(cfg), AdvancedInjectionTimeout),
|
||||
huh.NewOption("Back to Main Menu", AdvancedBack),
|
||||
}
|
||||
|
||||
var selected AdvancedSection
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[AdvancedSection]().
|
||||
Title("Advanced Settings").
|
||||
Description("Configure low-level options").
|
||||
Options(options...).
|
||||
Value(&selected),
|
||||
),
|
||||
).WithTheme(getTheme())
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch selected {
|
||||
case AdvancedBack:
|
||||
return nil
|
||||
case AdvancedRecording:
|
||||
if err := editRecording(cfg); err != nil {
|
||||
continue
|
||||
}
|
||||
case AdvancedInjectionTimeout:
|
||||
if err := editInjectionTimeouts(cfg); err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func formatAdvancedRecordingLabel(cfg *config.Config) string {
|
||||
return fmt.Sprintf("Recording Settings (rate=%d, timeout=%s)", cfg.Recording.SampleRate, cfg.Recording.Timeout)
|
||||
}
|
||||
|
||||
func formatAdvancedInjectionTimeoutLabel(cfg *config.Config) string {
|
||||
return fmt.Sprintf("Injection Timeouts (ydotool=%s, wtype=%s, clipboard=%s)",
|
||||
cfg.Injection.YdotoolTimeout, cfg.Injection.WtypeTimeout, cfg.Injection.ClipboardTimeout)
|
||||
}
|
||||
|
||||
// editRecording handles the recording settings
|
||||
func editRecording(cfg *config.Config) error {
|
||||
sampleRate := strconv.Itoa(cfg.Recording.SampleRate)
|
||||
channels := strconv.Itoa(cfg.Recording.Channels)
|
||||
format := cfg.Recording.Format
|
||||
bufferSize := strconv.Itoa(cfg.Recording.BufferSize)
|
||||
device := cfg.Recording.Device
|
||||
channelBufferSize := strconv.Itoa(cfg.Recording.ChannelBufferSize)
|
||||
timeout := cfg.Recording.Timeout.String()
|
||||
|
||||
channelOptions := []huh.Option[string]{
|
||||
huh.NewOption("1 (Mono) - Recommended", "1"),
|
||||
huh.NewOption("2 (Stereo)", "2"),
|
||||
}
|
||||
|
||||
formatOptions := []huh.Option[string]{
|
||||
huh.NewOption("s16 (16-bit signed) - Recommended", "s16"),
|
||||
huh.NewOption("f32 (32-bit float)", "f32"),
|
||||
}
|
||||
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("Sample Rate (Hz)").
|
||||
Description("Audio sample rate. 16000 is optimal for speech recognition.").
|
||||
Placeholder("16000").
|
||||
Value(&sampleRate).
|
||||
Validate(func(s string) error {
|
||||
if _, err := strconv.Atoi(s); err != nil {
|
||||
return fmt.Errorf("must be a number")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
huh.NewSelect[string]().
|
||||
Title("Channels").
|
||||
Description("Number of audio channels").
|
||||
Options(channelOptions...).
|
||||
Value(&channels),
|
||||
huh.NewSelect[string]().
|
||||
Title("Audio Format").
|
||||
Description("Sample format").
|
||||
Options(formatOptions...).
|
||||
Value(&format),
|
||||
),
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("Buffer Size (bytes)").
|
||||
Description("Internal buffer size. Larger = less CPU, more latency.").
|
||||
Placeholder("8192").
|
||||
Value(&bufferSize).
|
||||
Validate(func(s string) error {
|
||||
if _, err := strconv.Atoi(s); err != nil {
|
||||
return fmt.Errorf("must be a number")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
huh.NewInput().
|
||||
Title("Channel Buffer Size").
|
||||
Description("Number of audio frames to buffer.").
|
||||
Placeholder("30").
|
||||
Value(&channelBufferSize).
|
||||
Validate(func(s string) error {
|
||||
if _, err := strconv.Atoi(s); err != nil {
|
||||
return fmt.Errorf("must be a number")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
),
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("Device").
|
||||
Description("PipeWire device name. Empty = default microphone.").
|
||||
Placeholder("(default)").
|
||||
Value(&device),
|
||||
huh.NewInput().
|
||||
Title("Recording Timeout").
|
||||
Description("Max recording duration (e.g., '30s', '2m', '5m'). Prevents runaway recordings.").
|
||||
Placeholder("5m").
|
||||
Value(&timeout).
|
||||
Validate(func(s string) error {
|
||||
if _, err := time.ParseDuration(s); err != nil {
|
||||
return fmt.Errorf("invalid duration format (use '30s', '2m', etc.)")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
),
|
||||
).WithTheme(getTheme())
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg.Recording.SampleRate, _ = strconv.Atoi(sampleRate)
|
||||
cfg.Recording.Channels, _ = strconv.Atoi(channels)
|
||||
cfg.Recording.Format = format
|
||||
cfg.Recording.BufferSize, _ = strconv.Atoi(bufferSize)
|
||||
cfg.Recording.Device = device
|
||||
cfg.Recording.ChannelBufferSize, _ = strconv.Atoi(channelBufferSize)
|
||||
cfg.Recording.Timeout, _ = time.ParseDuration(timeout)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// editInjectionTimeouts handles the injection timeout settings
|
||||
func editInjectionTimeouts(cfg *config.Config) error {
|
||||
ydotoolTimeout := cfg.Injection.YdotoolTimeout.String()
|
||||
wtypeTimeout := cfg.Injection.WtypeTimeout.String()
|
||||
clipboardTimeout := cfg.Injection.ClipboardTimeout.String()
|
||||
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("ydotool Timeout").
|
||||
Description("Timeout for ydotool commands (e.g., '5s', '10s')").
|
||||
Placeholder("5s").
|
||||
Value(&ydotoolTimeout).
|
||||
Validate(func(s string) error {
|
||||
if _, err := time.ParseDuration(s); err != nil {
|
||||
return fmt.Errorf("invalid duration format")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
huh.NewInput().
|
||||
Title("wtype Timeout").
|
||||
Description("Timeout for wtype commands (e.g., '5s', '10s')").
|
||||
Placeholder("5s").
|
||||
Value(&wtypeTimeout).
|
||||
Validate(func(s string) error {
|
||||
if _, err := time.ParseDuration(s); err != nil {
|
||||
return fmt.Errorf("invalid duration format")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
huh.NewInput().
|
||||
Title("Clipboard Timeout").
|
||||
Description("Timeout for clipboard operations (e.g., '3s', '5s')").
|
||||
Placeholder("3s").
|
||||
Value(&clipboardTimeout).
|
||||
Validate(func(s string) error {
|
||||
if _, err := time.ParseDuration(s); err != nil {
|
||||
return fmt.Errorf("invalid duration format")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
),
|
||||
).WithTheme(getTheme())
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg.Injection.YdotoolTimeout, _ = time.ParseDuration(ydotoolTimeout)
|
||||
cfg.Injection.WtypeTimeout, _ = time.ParseDuration(wtypeTimeout)
|
||||
cfg.Injection.ClipboardTimeout, _ = time.ParseDuration(clipboardTimeout)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/leonardotrapani/hyprvoice/internal/config"
|
||||
)
|
||||
|
||||
// formatProvidersLabel formats the providers menu option
|
||||
func formatProvidersLabel(cfg *config.Config) string {
|
||||
return "Providers"
|
||||
}
|
||||
|
||||
// formatTranscriptionLabel formats the transcription menu option
|
||||
func formatTranscriptionLabel(cfg *config.Config) string {
|
||||
return "Transcription"
|
||||
}
|
||||
|
||||
// formatLLMLabel formats the LLM menu option
|
||||
func formatLLMLabel(cfg *config.Config) string {
|
||||
return "LLM"
|
||||
}
|
||||
|
||||
// formatKeywordsLabel formats the keywords menu option
|
||||
func formatKeywordsLabel(cfg *config.Config) string {
|
||||
return "Keywords"
|
||||
}
|
||||
|
||||
// formatInjectionLabel formats the injection menu option
|
||||
func formatInjectionLabel(cfg *config.Config) string {
|
||||
return "Injection"
|
||||
}
|
||||
|
||||
// formatNotificationsLabel formats the notifications menu option
|
||||
func formatNotificationsLabel(cfg *config.Config) string {
|
||||
return "Notifications"
|
||||
}
|
||||
|
||||
func showSummary(cfg *config.Config) (bool, error) {
|
||||
fmt.Println()
|
||||
fmt.Println(StyleHeader.Render("Configuration Summary"))
|
||||
fmt.Println()
|
||||
|
||||
var providers []string
|
||||
for name := range cfg.Providers {
|
||||
providers = append(providers, name)
|
||||
}
|
||||
fmt.Printf(" %s %s\n", StyleLabel.Render("Providers:"), strings.Join(providers, ", "))
|
||||
|
||||
fmt.Printf(" %s %s (%s)\n", StyleLabel.Render("Transcription:"), cfg.Transcription.Provider, cfg.Transcription.Model)
|
||||
if cfg.Transcription.Language != "" {
|
||||
fmt.Printf(" %s %s\n", StyleLabel.Render("Language:"), cfg.Transcription.Language)
|
||||
}
|
||||
|
||||
if cfg.LLM.Enabled {
|
||||
fmt.Printf(" %s %s (%s)\n", StyleLabel.Render("LLM:"), cfg.LLM.Provider, cfg.LLM.Model)
|
||||
var ppOpts []string
|
||||
if cfg.LLM.PostProcessing.RemoveStutters {
|
||||
ppOpts = append(ppOpts, "remove stutters")
|
||||
}
|
||||
if cfg.LLM.PostProcessing.AddPunctuation {
|
||||
ppOpts = append(ppOpts, "add punctuation")
|
||||
}
|
||||
if cfg.LLM.PostProcessing.FixGrammar {
|
||||
ppOpts = append(ppOpts, "fix grammar")
|
||||
}
|
||||
if cfg.LLM.PostProcessing.RemoveFillerWords {
|
||||
ppOpts = append(ppOpts, "remove fillers")
|
||||
}
|
||||
if len(ppOpts) > 0 {
|
||||
fmt.Printf(" %s %s\n", StyleLabel.Render("Post-processing:"), strings.Join(ppOpts, ", "))
|
||||
}
|
||||
} else {
|
||||
fmt.Printf(" %s disabled\n", StyleLabel.Render("LLM:"))
|
||||
}
|
||||
|
||||
if len(cfg.Keywords) > 0 {
|
||||
fmt.Printf(" %s %s\n", StyleLabel.Render("Keywords:"), strings.Join(cfg.Keywords, ", "))
|
||||
}
|
||||
|
||||
fmt.Printf(" %s %s\n", StyleLabel.Render("Backends:"), strings.Join(cfg.Injection.Backends, " -> "))
|
||||
|
||||
if cfg.Notifications.Enabled {
|
||||
fmt.Printf(" %s enabled\n", StyleLabel.Render("Notifications:"))
|
||||
} else {
|
||||
fmt.Printf(" %s disabled\n", StyleLabel.Render("Notifications:"))
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
|
||||
var confirmed bool
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewConfirm().
|
||||
Title("Save this configuration?").
|
||||
Affirmative("Save").
|
||||
Negative("Cancel").
|
||||
Value(&confirmed),
|
||||
),
|
||||
).WithTheme(getTheme())
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return confirmed, nil
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/leonardotrapani/hyprvoice/internal/config"
|
||||
"github.com/leonardotrapani/hyprvoice/internal/provider"
|
||||
)
|
||||
|
||||
// editLLM handles the LLM section edit with smart provider detection
|
||||
func editLLM(cfg *config.Config, configuredProviders []string) ([]string, error) {
|
||||
var llmProviders []string
|
||||
for _, name := range configuredProviders {
|
||||
p := provider.GetProvider(name)
|
||||
if p != nil && p.SupportsLLM() {
|
||||
llmProviders = append(llmProviders, name)
|
||||
}
|
||||
}
|
||||
|
||||
postProcessing := cfg.LLM.PostProcessing
|
||||
if !postProcessing.RemoveStutters && !postProcessing.AddPunctuation &&
|
||||
!postProcessing.FixGrammar && !postProcessing.RemoveFillerWords {
|
||||
postProcessing = config.LLMPostProcessingConfig{
|
||||
RemoveStutters: true,
|
||||
AddPunctuation: true,
|
||||
FixGrammar: true,
|
||||
RemoveFillerWords: true,
|
||||
}
|
||||
}
|
||||
customPrompt := cfg.LLM.CustomPrompt
|
||||
|
||||
enableLLM := cfg.LLM.Enabled
|
||||
|
||||
enableDesc := "LLM improves transcription by fixing grammar, removing stutters, and cleaning up text"
|
||||
if cfg.LLM.Enabled {
|
||||
enableDesc = fmt.Sprintf("Currently: enabled (%s/%s). %s", cfg.LLM.Provider, cfg.LLM.Model, enableDesc)
|
||||
} else {
|
||||
enableDesc = "Currently: disabled. " + enableDesc
|
||||
}
|
||||
|
||||
enableForm := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewConfirm().
|
||||
Title("Enable LLM Post-Processing? (Recommended)").
|
||||
Description(enableDesc).
|
||||
Affirmative("Yes (Recommended)").
|
||||
Negative("No").
|
||||
Value(&enableLLM),
|
||||
),
|
||||
).WithTheme(getTheme())
|
||||
|
||||
if err := enableForm.Run(); err != nil {
|
||||
return configuredProviders, err
|
||||
}
|
||||
|
||||
if !enableLLM {
|
||||
cfg.LLM.Enabled = false
|
||||
return configuredProviders, nil
|
||||
}
|
||||
|
||||
var llmOptions []huh.Option[string]
|
||||
for _, name := range llmProviders {
|
||||
switch name {
|
||||
case "openai":
|
||||
llmOptions = append(llmOptions, huh.NewOption("OpenAI GPT", "openai"))
|
||||
case "groq":
|
||||
llmOptions = append(llmOptions, huh.NewOption("Groq Llama (fast)", "groq"))
|
||||
}
|
||||
}
|
||||
|
||||
unconfiguredLLM := getUnconfiguredLLMOptions(configuredProviders)
|
||||
if len(unconfiguredLLM) > 0 {
|
||||
llmOptions = append(llmOptions, unconfiguredLLM...)
|
||||
}
|
||||
|
||||
if len(llmOptions) == 0 {
|
||||
fmt.Println(StyleError.Render("No LLM providers available. Please configure OpenAI or Groq first."))
|
||||
cfg.LLM.Enabled = false
|
||||
return configuredProviders, nil
|
||||
}
|
||||
|
||||
selectedProvider := cfg.LLM.Provider
|
||||
if selectedProvider == "" && len(llmOptions) > 0 {
|
||||
selectedProvider = llmOptions[0].Value
|
||||
}
|
||||
|
||||
llmProviderDesc := "Choose which service to use for text post-processing"
|
||||
if cfg.LLM.Provider != "" {
|
||||
llmProviderDesc = fmt.Sprintf("Currently: %s/%s", cfg.LLM.Provider, cfg.LLM.Model)
|
||||
}
|
||||
|
||||
providerForm := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("LLM Provider").
|
||||
Description(llmProviderDesc).
|
||||
Options(llmOptions...).
|
||||
Value(&selectedProvider),
|
||||
),
|
||||
).WithTheme(getTheme())
|
||||
|
||||
if err := providerForm.Run(); err != nil {
|
||||
return configuredProviders, err
|
||||
}
|
||||
|
||||
configuredProviders = ensureProviderConfigured(cfg, selectedProvider, configuredProviders)
|
||||
cfg.LLM.Provider = selectedProvider
|
||||
|
||||
modelOptions := getLLMModelOptions(selectedProvider)
|
||||
selectedModel := cfg.LLM.Model
|
||||
if selectedModel == "" && len(modelOptions) > 0 {
|
||||
selectedModel = modelOptions[0].Value
|
||||
}
|
||||
|
||||
llmModelDesc := ""
|
||||
if cfg.LLM.Model != "" {
|
||||
llmModelDesc = fmt.Sprintf("Currently: %s", cfg.LLM.Model)
|
||||
}
|
||||
|
||||
modelForm := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("LLM Model").
|
||||
Description(llmModelDesc).
|
||||
Options(modelOptions...).
|
||||
Value(&selectedModel),
|
||||
),
|
||||
).WithTheme(getTheme())
|
||||
|
||||
if err := modelForm.Run(); err != nil {
|
||||
return configuredProviders, err
|
||||
}
|
||||
|
||||
cfg.LLM.Model = selectedModel
|
||||
|
||||
var ppErr error
|
||||
postProcessing, ppErr = selectPostProcessingOptions(postProcessing)
|
||||
if ppErr != nil {
|
||||
return configuredProviders, ppErr
|
||||
}
|
||||
|
||||
cfg.LLM.PostProcessing = postProcessing
|
||||
|
||||
enableCustomPrompt := customPrompt.Enabled
|
||||
customPromptText := customPrompt.Prompt
|
||||
|
||||
customPromptDesc := "Add extra instructions for the LLM"
|
||||
if customPrompt.Enabled && customPrompt.Prompt != "" {
|
||||
preview := customPrompt.Prompt
|
||||
if len(preview) > 40 {
|
||||
preview = preview[:40] + "..."
|
||||
}
|
||||
customPromptDesc = fmt.Sprintf("Currently: \"%s\"", preview)
|
||||
} else {
|
||||
customPromptDesc = "Currently: none. " + customPromptDesc
|
||||
}
|
||||
|
||||
customForm := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewConfirm().
|
||||
Title("Add custom prompt?").
|
||||
Description(customPromptDesc).
|
||||
Value(&enableCustomPrompt),
|
||||
),
|
||||
).WithTheme(getTheme())
|
||||
|
||||
if err := customForm.Run(); err != nil {
|
||||
return configuredProviders, err
|
||||
}
|
||||
|
||||
if enableCustomPrompt {
|
||||
promptForm := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewText().
|
||||
Title("Custom Prompt").
|
||||
Description("Additional instructions (e.g., 'Format as bullet points')").
|
||||
Value(&customPromptText).
|
||||
CharLimit(500),
|
||||
),
|
||||
).WithTheme(getTheme())
|
||||
|
||||
if err := promptForm.Run(); err != nil {
|
||||
return configuredProviders, err
|
||||
}
|
||||
cfg.LLM.CustomPrompt.Enabled = true
|
||||
cfg.LLM.CustomPrompt.Prompt = customPromptText
|
||||
} else {
|
||||
cfg.LLM.CustomPrompt.Enabled = false
|
||||
}
|
||||
|
||||
cfg.LLM.Enabled = true
|
||||
return configuredProviders, nil
|
||||
}
|
||||
|
||||
// getUnconfiguredLLMOptions returns options for LLM providers not yet configured
|
||||
func getUnconfiguredLLMOptions(configuredProviders []string) []huh.Option[string] {
|
||||
configured := make(map[string]bool)
|
||||
for _, p := range configuredProviders {
|
||||
configured[p] = true
|
||||
}
|
||||
|
||||
var options []huh.Option[string]
|
||||
if !configured["openai"] {
|
||||
options = append(options, huh.NewOption("OpenAI GPT (not configured)", "openai"))
|
||||
}
|
||||
if !configured["groq"] {
|
||||
options = append(options, huh.NewOption("Groq Llama (not configured)", "groq"))
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
func getLLMModelOptions(provider string) []huh.Option[string] {
|
||||
switch provider {
|
||||
case "openai":
|
||||
return []huh.Option[string]{
|
||||
huh.NewOption("gpt-4o-mini (recommended)", "gpt-4o-mini"),
|
||||
huh.NewOption("gpt-4o", "gpt-4o"),
|
||||
huh.NewOption("gpt-4-turbo", "gpt-4-turbo"),
|
||||
huh.NewOption("gpt-3.5-turbo", "gpt-3.5-turbo"),
|
||||
}
|
||||
case "groq":
|
||||
return []huh.Option[string]{
|
||||
huh.NewOption("llama-3.3-70b-versatile (recommended)", "llama-3.3-70b-versatile"),
|
||||
huh.NewOption("llama-3.1-8b-instant (faster)", "llama-3.1-8b-instant"),
|
||||
huh.NewOption("mixtral-8x7b-32768", "mixtral-8x7b-32768"),
|
||||
}
|
||||
default:
|
||||
return []huh.Option[string]{}
|
||||
}
|
||||
}
|
||||
|
||||
// selectPostProcessingOptions shows a multi-select for LLM post-processing toggles
|
||||
func selectPostProcessingOptions(current config.LLMPostProcessingConfig) (config.LLMPostProcessingConfig, error) {
|
||||
type ppOption string
|
||||
const (
|
||||
optRemoveStutters ppOption = "stutters"
|
||||
optAddPunctuation ppOption = "punctuation"
|
||||
optFixGrammar ppOption = "grammar"
|
||||
optRemoveFillerWords ppOption = "fillers"
|
||||
)
|
||||
|
||||
options := []huh.Option[ppOption]{
|
||||
huh.NewOption("Remove stutters (repeated words)", optRemoveStutters),
|
||||
huh.NewOption("Add punctuation", optAddPunctuation),
|
||||
huh.NewOption("Fix grammar", optFixGrammar),
|
||||
huh.NewOption("Remove filler words (um, uh, like)", optRemoveFillerWords),
|
||||
}
|
||||
|
||||
var selected []ppOption
|
||||
if current.RemoveStutters {
|
||||
selected = append(selected, optRemoveStutters)
|
||||
}
|
||||
if current.AddPunctuation {
|
||||
selected = append(selected, optAddPunctuation)
|
||||
}
|
||||
if current.FixGrammar {
|
||||
selected = append(selected, optFixGrammar)
|
||||
}
|
||||
if current.RemoveFillerWords {
|
||||
selected = append(selected, optRemoveFillerWords)
|
||||
}
|
||||
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewMultiSelect[ppOption]().
|
||||
Title("Post-Processing Options").
|
||||
Description("Select which improvements to apply").
|
||||
Options(options...).
|
||||
Value(&selected),
|
||||
),
|
||||
).WithTheme(getTheme())
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return current, err
|
||||
}
|
||||
|
||||
result := config.LLMPostProcessingConfig{}
|
||||
for _, opt := range selected {
|
||||
switch opt {
|
||||
case optRemoveStutters:
|
||||
result.RemoveStutters = true
|
||||
case optAddPunctuation:
|
||||
result.AddPunctuation = true
|
||||
case optFixGrammar:
|
||||
result.FixGrammar = true
|
||||
case optRemoveFillerWords:
|
||||
result.RemoveFillerWords = true
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func configureLLM(configuredProviders []string, cfg *config.Config) (bool, string, string, config.LLMPostProcessingConfig, config.LLMCustomPromptConfig, error) {
|
||||
var llmProviders []string
|
||||
for _, name := range configuredProviders {
|
||||
p := provider.GetProvider(name)
|
||||
if p != nil && p.SupportsLLM() {
|
||||
llmProviders = append(llmProviders, name)
|
||||
}
|
||||
}
|
||||
|
||||
postProcessing := config.LLMPostProcessingConfig{
|
||||
RemoveStutters: true,
|
||||
AddPunctuation: true,
|
||||
FixGrammar: true,
|
||||
RemoveFillerWords: true,
|
||||
}
|
||||
customPrompt := config.LLMCustomPromptConfig{
|
||||
Enabled: false,
|
||||
Prompt: "",
|
||||
}
|
||||
|
||||
if len(llmProviders) == 0 {
|
||||
return false, "", "", postProcessing, customPrompt, nil
|
||||
}
|
||||
|
||||
var enableLLM bool = true
|
||||
enableForm := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewConfirm().
|
||||
Title("Enable LLM Post-Processing? (Recommended)").
|
||||
Description("LLM improves transcription by fixing grammar, removing stutters, and cleaning up text").
|
||||
Affirmative("Yes (Recommended)").
|
||||
Negative("No").
|
||||
Value(&enableLLM),
|
||||
),
|
||||
).WithTheme(getTheme())
|
||||
|
||||
if err := enableForm.Run(); err != nil {
|
||||
return false, "", "", postProcessing, customPrompt, err
|
||||
}
|
||||
|
||||
if !enableLLM {
|
||||
return false, "", "", postProcessing, customPrompt, nil
|
||||
}
|
||||
|
||||
var llmOptions []huh.Option[string]
|
||||
for _, name := range llmProviders {
|
||||
p := provider.GetProvider(name)
|
||||
if p != nil {
|
||||
switch name {
|
||||
case "openai":
|
||||
llmOptions = append(llmOptions, huh.NewOption("OpenAI GPT", "openai"))
|
||||
case "groq":
|
||||
llmOptions = append(llmOptions, huh.NewOption("Groq Llama (fast)", "groq"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var selectedProvider string
|
||||
if cfg.LLM.Provider != "" {
|
||||
selectedProvider = cfg.LLM.Provider
|
||||
} else if len(llmOptions) > 0 {
|
||||
selectedProvider = llmOptions[0].Value
|
||||
}
|
||||
|
||||
providerForm := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("LLM Provider").
|
||||
Description("Choose which service to use for text post-processing").
|
||||
Options(llmOptions...).
|
||||
Value(&selectedProvider),
|
||||
),
|
||||
).WithTheme(getTheme())
|
||||
|
||||
if err := providerForm.Run(); err != nil {
|
||||
return false, "", "", postProcessing, customPrompt, err
|
||||
}
|
||||
|
||||
modelOptions := getLLMModelOptions(selectedProvider)
|
||||
var selectedModel string
|
||||
if cfg.LLM.Model != "" {
|
||||
selectedModel = cfg.LLM.Model
|
||||
} else if len(modelOptions) > 0 {
|
||||
selectedModel = modelOptions[0].Value
|
||||
}
|
||||
|
||||
modelForm := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("LLM Model").
|
||||
Options(modelOptions...).
|
||||
Value(&selectedModel),
|
||||
),
|
||||
).WithTheme(getTheme())
|
||||
|
||||
if err := modelForm.Run(); err != nil {
|
||||
return false, "", "", postProcessing, customPrompt, err
|
||||
}
|
||||
|
||||
if cfg.LLM.PostProcessing.RemoveStutters || cfg.LLM.PostProcessing.AddPunctuation ||
|
||||
cfg.LLM.PostProcessing.FixGrammar || cfg.LLM.PostProcessing.RemoveFillerWords {
|
||||
postProcessing = cfg.LLM.PostProcessing
|
||||
}
|
||||
|
||||
var ppErr error
|
||||
postProcessing, ppErr = selectPostProcessingOptions(postProcessing)
|
||||
if ppErr != nil {
|
||||
return false, "", "", postProcessing, customPrompt, ppErr
|
||||
}
|
||||
|
||||
var enableCustomPrompt bool
|
||||
var customPromptText string
|
||||
if cfg.LLM.CustomPrompt.Enabled {
|
||||
enableCustomPrompt = true
|
||||
customPromptText = cfg.LLM.CustomPrompt.Prompt
|
||||
}
|
||||
|
||||
customForm := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewConfirm().
|
||||
Title("Add custom prompt?").
|
||||
Description("Add extra instructions for the LLM").
|
||||
Value(&enableCustomPrompt),
|
||||
),
|
||||
).WithTheme(getTheme())
|
||||
|
||||
if err := customForm.Run(); err != nil {
|
||||
return false, "", "", postProcessing, customPrompt, err
|
||||
}
|
||||
|
||||
if enableCustomPrompt {
|
||||
promptForm := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewText().
|
||||
Title("Custom Prompt").
|
||||
Description("Additional instructions (e.g., 'Format as bullet points')").
|
||||
Value(&customPromptText).
|
||||
CharLimit(500),
|
||||
),
|
||||
).WithTheme(getTheme())
|
||||
|
||||
if err := promptForm.Run(); err != nil {
|
||||
return false, "", "", postProcessing, customPrompt, err
|
||||
}
|
||||
customPrompt.Enabled = true
|
||||
customPrompt.Prompt = customPromptText
|
||||
}
|
||||
|
||||
return true, selectedProvider, selectedModel, postProcessing, customPrompt, nil
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/leonardotrapani/hyprvoice/internal/config"
|
||||
"github.com/leonardotrapani/hyprvoice/internal/notify"
|
||||
)
|
||||
|
||||
// editNotifications handles the notifications section edit with type and custom messages
|
||||
func editNotifications(cfg *config.Config) error {
|
||||
enabled := cfg.Notifications.Enabled
|
||||
|
||||
desc := "Show notifications for recording status changes"
|
||||
if cfg.Notifications.Enabled {
|
||||
desc = fmt.Sprintf("Currently: enabled (%s). %s", cfg.Notifications.Type, desc)
|
||||
} else {
|
||||
desc = "Currently: disabled. " + desc
|
||||
}
|
||||
|
||||
enableForm := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewConfirm().
|
||||
Title("Enable desktop notifications?").
|
||||
Description(desc).
|
||||
Value(&enabled),
|
||||
),
|
||||
).WithTheme(getTheme())
|
||||
|
||||
if err := enableForm.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg.Notifications.Enabled = enabled
|
||||
|
||||
if !enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
notifType := cfg.Notifications.Type
|
||||
if notifType == "" {
|
||||
notifType = "desktop"
|
||||
}
|
||||
|
||||
typeOptions := []huh.Option[string]{
|
||||
huh.NewOption("Desktop notifications (notify-send)", "desktop"),
|
||||
huh.NewOption("Log to console only", "log"),
|
||||
huh.NewOption("None (silent)", "none"),
|
||||
}
|
||||
|
||||
typeForm := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("Notification Type").
|
||||
Description("How should notifications be displayed?").
|
||||
Options(typeOptions...).
|
||||
Value(¬ifType),
|
||||
),
|
||||
).WithTheme(getTheme())
|
||||
|
||||
if err := typeForm.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg.Notifications.Type = notifType
|
||||
|
||||
var configureMessages bool
|
||||
msgForm := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewConfirm().
|
||||
Title("Configure custom notification messages?").
|
||||
Description("Customize the text shown in notifications").
|
||||
Affirmative("Yes").
|
||||
Negative("No, use defaults").
|
||||
Value(&configureMessages),
|
||||
),
|
||||
).WithTheme(getTheme())
|
||||
|
||||
if err := msgForm.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if configureMessages {
|
||||
if err := editNotificationMessages(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// editNotificationMessages allows editing individual notification messages
|
||||
func editNotificationMessages(cfg *config.Config) error {
|
||||
for {
|
||||
var options []huh.Option[string]
|
||||
for _, def := range notify.MessageDefs {
|
||||
currentBody := def.DefaultBody
|
||||
switch def.ConfigKey {
|
||||
case "recording_started":
|
||||
if cfg.Notifications.Messages.RecordingStarted.Body != "" {
|
||||
currentBody = cfg.Notifications.Messages.RecordingStarted.Body
|
||||
}
|
||||
case "transcribing":
|
||||
if cfg.Notifications.Messages.Transcribing.Body != "" {
|
||||
currentBody = cfg.Notifications.Messages.Transcribing.Body
|
||||
}
|
||||
case "llm_processing":
|
||||
if cfg.Notifications.Messages.LLMProcessing.Body != "" {
|
||||
currentBody = cfg.Notifications.Messages.LLMProcessing.Body
|
||||
}
|
||||
case "config_reloaded":
|
||||
if cfg.Notifications.Messages.ConfigReloaded.Body != "" {
|
||||
currentBody = cfg.Notifications.Messages.ConfigReloaded.Body
|
||||
}
|
||||
case "operation_cancelled":
|
||||
if cfg.Notifications.Messages.OperationCancelled.Body != "" {
|
||||
currentBody = cfg.Notifications.Messages.OperationCancelled.Body
|
||||
}
|
||||
case "recording_aborted":
|
||||
if cfg.Notifications.Messages.RecordingAborted.Body != "" {
|
||||
currentBody = cfg.Notifications.Messages.RecordingAborted.Body
|
||||
}
|
||||
case "injection_aborted":
|
||||
if cfg.Notifications.Messages.InjectionAborted.Body != "" {
|
||||
currentBody = cfg.Notifications.Messages.InjectionAborted.Body
|
||||
}
|
||||
}
|
||||
|
||||
displayBody := currentBody
|
||||
if len(displayBody) > 30 {
|
||||
displayBody = displayBody[:30] + "..."
|
||||
}
|
||||
|
||||
label := fmt.Sprintf("%s: \"%s\"", def.ConfigKey, displayBody)
|
||||
options = append(options, huh.NewOption(label, def.ConfigKey))
|
||||
}
|
||||
options = append(options, huh.NewOption("Back", "back"))
|
||||
|
||||
var selected string
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("Notification Messages").
|
||||
Description("Select a message to edit").
|
||||
Options(options...).
|
||||
Value(&selected),
|
||||
),
|
||||
).WithTheme(getTheme())
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if selected == "back" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := editSingleMessage(cfg, selected); err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// editSingleMessage edits a single notification message
|
||||
func editSingleMessage(cfg *config.Config, configKey string) error {
|
||||
var def notify.MessageDef
|
||||
for _, d := range notify.MessageDefs {
|
||||
if d.ConfigKey == configKey {
|
||||
def = d
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var currentTitle, currentBody string
|
||||
switch configKey {
|
||||
case "recording_started":
|
||||
currentTitle = cfg.Notifications.Messages.RecordingStarted.Title
|
||||
currentBody = cfg.Notifications.Messages.RecordingStarted.Body
|
||||
case "transcribing":
|
||||
currentTitle = cfg.Notifications.Messages.Transcribing.Title
|
||||
currentBody = cfg.Notifications.Messages.Transcribing.Body
|
||||
case "llm_processing":
|
||||
currentTitle = cfg.Notifications.Messages.LLMProcessing.Title
|
||||
currentBody = cfg.Notifications.Messages.LLMProcessing.Body
|
||||
case "config_reloaded":
|
||||
currentTitle = cfg.Notifications.Messages.ConfigReloaded.Title
|
||||
currentBody = cfg.Notifications.Messages.ConfigReloaded.Body
|
||||
case "operation_cancelled":
|
||||
currentTitle = cfg.Notifications.Messages.OperationCancelled.Title
|
||||
currentBody = cfg.Notifications.Messages.OperationCancelled.Body
|
||||
case "recording_aborted":
|
||||
currentTitle = cfg.Notifications.Messages.RecordingAborted.Title
|
||||
currentBody = cfg.Notifications.Messages.RecordingAborted.Body
|
||||
case "injection_aborted":
|
||||
currentTitle = cfg.Notifications.Messages.InjectionAborted.Title
|
||||
currentBody = cfg.Notifications.Messages.InjectionAborted.Body
|
||||
}
|
||||
|
||||
if currentTitle == "" {
|
||||
currentTitle = def.DefaultTitle
|
||||
}
|
||||
if currentBody == "" {
|
||||
currentBody = def.DefaultBody
|
||||
}
|
||||
|
||||
title := currentTitle
|
||||
body := currentBody
|
||||
|
||||
var fields []huh.Field
|
||||
if !def.IsError {
|
||||
fields = append(fields, huh.NewInput().
|
||||
Title("Title").
|
||||
Description(fmt.Sprintf("Default: %s", def.DefaultTitle)).
|
||||
Placeholder(def.DefaultTitle).
|
||||
Value(&title))
|
||||
}
|
||||
fields = append(fields, huh.NewInput().
|
||||
Title("Body").
|
||||
Description(fmt.Sprintf("Default: %s", def.DefaultBody)).
|
||||
Placeholder(def.DefaultBody).
|
||||
Value(&body))
|
||||
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(fields...),
|
||||
).WithTheme(getTheme())
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
msgConfig := config.MessageConfig{Title: title, Body: body}
|
||||
switch configKey {
|
||||
case "recording_started":
|
||||
cfg.Notifications.Messages.RecordingStarted = msgConfig
|
||||
case "transcribing":
|
||||
cfg.Notifications.Messages.Transcribing = msgConfig
|
||||
case "llm_processing":
|
||||
cfg.Notifications.Messages.LLMProcessing = msgConfig
|
||||
case "config_reloaded":
|
||||
cfg.Notifications.Messages.ConfigReloaded = msgConfig
|
||||
case "operation_cancelled":
|
||||
cfg.Notifications.Messages.OperationCancelled = msgConfig
|
||||
case "recording_aborted":
|
||||
cfg.Notifications.Messages.RecordingAborted = msgConfig
|
||||
case "injection_aborted":
|
||||
cfg.Notifications.Messages.InjectionAborted = msgConfig
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/leonardotrapani/hyprvoice/internal/config"
|
||||
"github.com/leonardotrapani/hyprvoice/internal/provider"
|
||||
)
|
||||
|
||||
// getProviderDisplayName returns the display name for a provider
|
||||
func getProviderDisplayName(providerName string) string {
|
||||
if name, ok := providerDisplayNames[providerName]; ok {
|
||||
return name
|
||||
}
|
||||
return providerName
|
||||
}
|
||||
|
||||
// maskAPIKey returns a masked version of an API key for display
|
||||
func maskAPIKey(key string) string {
|
||||
if len(key) <= 8 {
|
||||
return "***"
|
||||
}
|
||||
return key[:7] + "..." + key[len(key)-4:]
|
||||
}
|
||||
|
||||
// getConfiguredProviders returns list of providers with API keys
|
||||
func getConfiguredProviders(cfg *config.Config) []string {
|
||||
var providers []string
|
||||
for name, pc := range cfg.Providers {
|
||||
if pc.APIKey != "" {
|
||||
providers = append(providers, name)
|
||||
}
|
||||
}
|
||||
return providers
|
||||
}
|
||||
|
||||
// editProviders handles the providers section edit with submenu
|
||||
func editProviders(cfg *config.Config) error {
|
||||
for {
|
||||
var options []huh.Option[string]
|
||||
for _, name := range AllProviders {
|
||||
options = append(options, huh.NewOption(formatProviderOption(cfg, name), name))
|
||||
}
|
||||
options = append(options, huh.NewOption("Back", "back"))
|
||||
|
||||
var selected string
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("Provider Settings").
|
||||
Description("Select a provider to configure API key").
|
||||
Options(options...).
|
||||
Value(&selected),
|
||||
),
|
||||
).WithTheme(getTheme())
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if selected == "back" {
|
||||
return nil
|
||||
}
|
||||
|
||||
apiKey, err := configureSingleProvider(cfg, selected)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if apiKey != "" {
|
||||
if cfg.Providers == nil {
|
||||
cfg.Providers = make(map[string]config.ProviderConfig)
|
||||
}
|
||||
cfg.Providers[selected] = config.ProviderConfig{APIKey: apiKey}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// formatProviderOption formats a provider menu option with status
|
||||
func formatProviderOption(cfg *config.Config, name string) string {
|
||||
var status string
|
||||
if pc, exists := cfg.Providers[name]; exists && pc.APIKey != "" {
|
||||
status = "(configured)"
|
||||
} else {
|
||||
status = "(not configured)"
|
||||
}
|
||||
|
||||
switch name {
|
||||
case "openai":
|
||||
return fmt.Sprintf("OpenAI - Whisper + GPT %s", status)
|
||||
case "groq":
|
||||
return fmt.Sprintf("Groq - Whisper + Llama %s", status)
|
||||
case "mistral":
|
||||
return fmt.Sprintf("Mistral - Voxtral %s", status)
|
||||
case "elevenlabs":
|
||||
return fmt.Sprintf("ElevenLabs - Scribe %s", status)
|
||||
default:
|
||||
return fmt.Sprintf("%s %s", name, status)
|
||||
}
|
||||
}
|
||||
|
||||
// configureSingleProvider handles the complete flow for configuring a single provider's API key.
|
||||
// Shows confirm dialog if key exists, then prompts for new key if needed.
|
||||
// Returns the new API key (empty if user kept current) and any error.
|
||||
func configureSingleProvider(cfg *config.Config, providerName string) (string, error) {
|
||||
var existingKey string
|
||||
if pc, exists := cfg.Providers[providerName]; exists && pc.APIKey != "" {
|
||||
existingKey = pc.APIKey
|
||||
}
|
||||
|
||||
if existingKey != "" {
|
||||
displayName := getProviderDisplayName(providerName)
|
||||
masked := maskAPIKey(existingKey)
|
||||
|
||||
var update bool
|
||||
confirmForm := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewConfirm().
|
||||
Title(fmt.Sprintf("%s API Key", displayName)).
|
||||
Description(fmt.Sprintf("Current: %s", masked)).
|
||||
Affirmative("Update key").
|
||||
Negative("Keep current").
|
||||
Value(&update),
|
||||
),
|
||||
).WithTheme(getTheme())
|
||||
|
||||
if err := confirmForm.Run(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if !update {
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
return inputAPIKey(providerName)
|
||||
}
|
||||
|
||||
func inputAPIKey(providerName string) (string, error) {
|
||||
p := provider.GetProvider(providerName)
|
||||
displayName := getProviderDisplayName(providerName)
|
||||
if p != nil {
|
||||
if name, ok := providerDisplayNames[p.Name()]; ok {
|
||||
displayName = name
|
||||
}
|
||||
}
|
||||
|
||||
var apiKey string
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title(fmt.Sprintf("%s API Key", displayName)).
|
||||
Description(fmt.Sprintf("Enter your %s API key", displayName)).
|
||||
EchoMode(huh.EchoModePassword).
|
||||
Value(&apiKey).
|
||||
Validate(func(s string) error {
|
||||
if s == "" {
|
||||
return fmt.Errorf("API key is required")
|
||||
}
|
||||
if p != nil && !p.ValidateAPIKey(s) {
|
||||
return fmt.Errorf("invalid API key format for %s", displayName)
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
),
|
||||
).WithTheme(getTheme())
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return apiKey, nil
|
||||
}
|
||||
|
||||
// ensureProviderConfigured prompts for API key if provider not configured
|
||||
func ensureProviderConfigured(cfg *config.Config, selectedProvider string, configuredProviders []string) []string {
|
||||
providerName := selectedProvider
|
||||
switch selectedProvider {
|
||||
case "groq-transcription", "groq-translation":
|
||||
providerName = "groq"
|
||||
case "mistral-transcription":
|
||||
providerName = "mistral"
|
||||
}
|
||||
|
||||
for _, p := range configuredProviders {
|
||||
if p == providerName {
|
||||
return configuredProviders
|
||||
}
|
||||
}
|
||||
|
||||
apiKey, err := configureSingleProvider(cfg, providerName)
|
||||
if err != nil || apiKey == "" {
|
||||
return configuredProviders
|
||||
}
|
||||
|
||||
if cfg.Providers == nil {
|
||||
cfg.Providers = make(map[string]config.ProviderConfig)
|
||||
}
|
||||
cfg.Providers[providerName] = config.ProviderConfig{APIKey: apiKey}
|
||||
|
||||
return append(configuredProviders, providerName)
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/leonardotrapani/hyprvoice/internal/config"
|
||||
"github.com/leonardotrapani/hyprvoice/internal/provider"
|
||||
)
|
||||
|
||||
// editTranscription handles the transcription section edit with smart provider detection
|
||||
func editTranscription(cfg *config.Config, configuredProviders []string) ([]string, error) {
|
||||
var transcriptionOptions []huh.Option[string]
|
||||
for _, name := range configuredProviders {
|
||||
p := provider.GetProvider(name)
|
||||
if p != nil && p.SupportsTranscription() {
|
||||
switch name {
|
||||
case "openai":
|
||||
transcriptionOptions = append(transcriptionOptions,
|
||||
huh.NewOption("OpenAI Whisper", "openai"))
|
||||
case "groq":
|
||||
transcriptionOptions = append(transcriptionOptions,
|
||||
huh.NewOption("Groq Whisper (transcription)", "groq-transcription"),
|
||||
huh.NewOption("Groq Whisper (translate to English)", "groq-translation"))
|
||||
case "mistral":
|
||||
transcriptionOptions = append(transcriptionOptions,
|
||||
huh.NewOption("Mistral Voxtral", "mistral-transcription"))
|
||||
case "elevenlabs":
|
||||
transcriptionOptions = append(transcriptionOptions,
|
||||
huh.NewOption("ElevenLabs Scribe", "elevenlabs"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unconfiguredOptions := getUnconfiguredTranscriptionOptions(configuredProviders)
|
||||
if len(unconfiguredOptions) > 0 {
|
||||
transcriptionOptions = append(transcriptionOptions, unconfiguredOptions...)
|
||||
}
|
||||
|
||||
if len(transcriptionOptions) == 0 {
|
||||
return configuredProviders, fmt.Errorf("no transcription providers available")
|
||||
}
|
||||
|
||||
selectedProvider := cfg.Transcription.Provider
|
||||
if selectedProvider == "" && len(transcriptionOptions) > 0 {
|
||||
selectedProvider = transcriptionOptions[0].Value
|
||||
}
|
||||
|
||||
providerDesc := "Choose which service to use for speech-to-text"
|
||||
if cfg.Transcription.Provider != "" {
|
||||
providerDesc = fmt.Sprintf("Currently: %s/%s", cfg.Transcription.Provider, cfg.Transcription.Model)
|
||||
}
|
||||
|
||||
providerForm := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("Transcription Provider").
|
||||
Description(providerDesc).
|
||||
Options(transcriptionOptions...).
|
||||
Value(&selectedProvider),
|
||||
),
|
||||
).WithTheme(getTheme())
|
||||
|
||||
if err := providerForm.Run(); err != nil {
|
||||
return configuredProviders, err
|
||||
}
|
||||
|
||||
configuredProviders = ensureProviderConfigured(cfg, selectedProvider, configuredProviders)
|
||||
cfg.Transcription.Provider = selectedProvider
|
||||
|
||||
modelOptions := getTranscriptionModelOptions(selectedProvider)
|
||||
selectedModel := cfg.Transcription.Model
|
||||
if selectedModel == "" && len(modelOptions) > 0 {
|
||||
selectedModel = modelOptions[0].Value
|
||||
}
|
||||
|
||||
modelDesc := ""
|
||||
if cfg.Transcription.Model != "" {
|
||||
modelDesc = fmt.Sprintf("Currently: %s", cfg.Transcription.Model)
|
||||
}
|
||||
|
||||
language := cfg.Transcription.Language
|
||||
|
||||
langDesc := "ISO-639-1 code (e.g., 'en', 'es', 'fr') or empty for auto-detect"
|
||||
if cfg.Transcription.Language != "" {
|
||||
langDesc = fmt.Sprintf("Currently: %s. %s", cfg.Transcription.Language, langDesc)
|
||||
}
|
||||
|
||||
modelForm := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("Transcription Model").
|
||||
Description(modelDesc).
|
||||
Options(modelOptions...).
|
||||
Value(&selectedModel),
|
||||
huh.NewInput().
|
||||
Title("Language").
|
||||
Description(langDesc).
|
||||
Placeholder("auto-detect").
|
||||
Value(&language),
|
||||
),
|
||||
).WithTheme(getTheme())
|
||||
|
||||
if err := modelForm.Run(); err != nil {
|
||||
return configuredProviders, err
|
||||
}
|
||||
|
||||
cfg.Transcription.Model = selectedModel
|
||||
cfg.Transcription.Language = language
|
||||
|
||||
return configuredProviders, nil
|
||||
}
|
||||
|
||||
// getUnconfiguredTranscriptionOptions returns options for providers not yet configured
|
||||
func getUnconfiguredTranscriptionOptions(configuredProviders []string) []huh.Option[string] {
|
||||
configured := make(map[string]bool)
|
||||
for _, p := range configuredProviders {
|
||||
configured[p] = true
|
||||
}
|
||||
|
||||
var options []huh.Option[string]
|
||||
if !configured["openai"] {
|
||||
options = append(options, huh.NewOption("OpenAI Whisper (not configured)", "openai"))
|
||||
}
|
||||
if !configured["groq"] {
|
||||
options = append(options,
|
||||
huh.NewOption("Groq Whisper transcription (not configured)", "groq-transcription"),
|
||||
huh.NewOption("Groq Whisper translation (not configured)", "groq-translation"))
|
||||
}
|
||||
if !configured["mistral"] {
|
||||
options = append(options, huh.NewOption("Mistral Voxtral (not configured)", "mistral-transcription"))
|
||||
}
|
||||
if !configured["elevenlabs"] {
|
||||
options = append(options, huh.NewOption("ElevenLabs Scribe (not configured)", "elevenlabs"))
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
func getTranscriptionModelOptions(provider string) []huh.Option[string] {
|
||||
switch provider {
|
||||
case "openai":
|
||||
return []huh.Option[string]{
|
||||
huh.NewOption("whisper-1", "whisper-1"),
|
||||
}
|
||||
case "groq-transcription":
|
||||
return []huh.Option[string]{
|
||||
huh.NewOption("whisper-large-v3-turbo (faster)", "whisper-large-v3-turbo"),
|
||||
huh.NewOption("whisper-large-v3 (standard)", "whisper-large-v3"),
|
||||
}
|
||||
case "groq-translation":
|
||||
return []huh.Option[string]{
|
||||
huh.NewOption("whisper-large-v3 (only option)", "whisper-large-v3"),
|
||||
}
|
||||
case "mistral-transcription":
|
||||
return []huh.Option[string]{
|
||||
huh.NewOption("voxtral-mini-latest (recommended)", "voxtral-mini-latest"),
|
||||
huh.NewOption("voxtral-mini-2507", "voxtral-mini-2507"),
|
||||
}
|
||||
case "elevenlabs":
|
||||
return []huh.Option[string]{
|
||||
huh.NewOption("scribe_v1 (99 languages, best accuracy)", "scribe_v1"),
|
||||
huh.NewOption("scribe_v2 (real-time, lower latency)", "scribe_v2"),
|
||||
}
|
||||
default:
|
||||
return []huh.Option[string]{}
|
||||
}
|
||||
}
|
||||
|
||||
func configureTranscription(configuredProviders []string, cfg *config.Config) (string, string, string, error) {
|
||||
var transcriptionOptions []huh.Option[string]
|
||||
for _, name := range configuredProviders {
|
||||
p := provider.GetProvider(name)
|
||||
if p != nil && p.SupportsTranscription() {
|
||||
switch name {
|
||||
case "openai":
|
||||
transcriptionOptions = append(transcriptionOptions,
|
||||
huh.NewOption("OpenAI Whisper", "openai"))
|
||||
case "groq":
|
||||
transcriptionOptions = append(transcriptionOptions,
|
||||
huh.NewOption("Groq Whisper (transcription)", "groq-transcription"),
|
||||
huh.NewOption("Groq Whisper (translate to English)", "groq-translation"))
|
||||
case "mistral":
|
||||
transcriptionOptions = append(transcriptionOptions,
|
||||
huh.NewOption("Mistral Voxtral", "mistral-transcription"))
|
||||
case "elevenlabs":
|
||||
transcriptionOptions = append(transcriptionOptions,
|
||||
huh.NewOption("ElevenLabs Scribe", "elevenlabs"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(transcriptionOptions) == 0 {
|
||||
return "", "", "", fmt.Errorf("no transcription-capable providers configured")
|
||||
}
|
||||
|
||||
var selectedProvider string
|
||||
if cfg.Transcription.Provider != "" {
|
||||
selectedProvider = cfg.Transcription.Provider
|
||||
} else if len(transcriptionOptions) > 0 {
|
||||
selectedProvider = transcriptionOptions[0].Value
|
||||
}
|
||||
|
||||
providerForm := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("Transcription Provider").
|
||||
Description("Choose which service to use for speech-to-text").
|
||||
Options(transcriptionOptions...).
|
||||
Value(&selectedProvider),
|
||||
),
|
||||
).WithTheme(getTheme())
|
||||
|
||||
if err := providerForm.Run(); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
modelOptions := getTranscriptionModelOptions(selectedProvider)
|
||||
var selectedModel string
|
||||
if cfg.Transcription.Model != "" {
|
||||
selectedModel = cfg.Transcription.Model
|
||||
} else if len(modelOptions) > 0 {
|
||||
selectedModel = modelOptions[0].Value
|
||||
}
|
||||
|
||||
var language string
|
||||
if cfg.Transcription.Language != "" {
|
||||
language = cfg.Transcription.Language
|
||||
}
|
||||
|
||||
modelForm := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("Transcription Model").
|
||||
Options(modelOptions...).
|
||||
Value(&selectedModel),
|
||||
huh.NewInput().
|
||||
Title("Language").
|
||||
Description("ISO-639-1 code (e.g., 'en', 'es', 'fr') or empty for auto-detect").
|
||||
Placeholder("auto-detect").
|
||||
Value(&language),
|
||||
),
|
||||
).WithTheme(getTheme())
|
||||
|
||||
if err := modelForm.Run(); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
return selectedProvider, selectedModel, language, nil
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/leonardotrapani/hyprvoice/internal/config"
|
||||
)
|
||||
|
||||
// runFreshInstall runs the full configuration wizard for fresh installs
|
||||
func runFreshInstall(cfg *config.Config) (*ConfigureResult, error) {
|
||||
fmt.Println(Logo())
|
||||
fmt.Println()
|
||||
fmt.Println(StyleMuted.Render("Voice-powered typing for Wayland/Hyprland"))
|
||||
fmt.Println()
|
||||
|
||||
selectedProviders, err := selectProviders()
|
||||
if err != nil {
|
||||
return &ConfigureResult{Cancelled: true}, nil
|
||||
}
|
||||
if len(selectedProviders) == 0 {
|
||||
return &ConfigureResult{Cancelled: true}, fmt.Errorf("no providers selected")
|
||||
}
|
||||
|
||||
if cfg.Providers == nil {
|
||||
cfg.Providers = make(map[string]config.ProviderConfig)
|
||||
}
|
||||
|
||||
for _, providerName := range selectedProviders {
|
||||
apiKey, err := inputAPIKey(providerName)
|
||||
if err != nil {
|
||||
return &ConfigureResult{Cancelled: true}, nil
|
||||
}
|
||||
cfg.Providers[providerName] = config.ProviderConfig{APIKey: apiKey}
|
||||
}
|
||||
|
||||
transcriptionProvider, transcriptionModel, language, err := configureTranscription(selectedProviders, cfg)
|
||||
if err != nil {
|
||||
return &ConfigureResult{Cancelled: true}, nil
|
||||
}
|
||||
cfg.Transcription.Provider = transcriptionProvider
|
||||
cfg.Transcription.Model = transcriptionModel
|
||||
cfg.Transcription.Language = language
|
||||
|
||||
llmEnabled, llmProvider, llmModel, postProcessing, customPrompt, err := configureLLM(selectedProviders, cfg)
|
||||
if err != nil {
|
||||
return &ConfigureResult{Cancelled: true}, nil
|
||||
}
|
||||
cfg.LLM.Enabled = llmEnabled
|
||||
cfg.LLM.Provider = llmProvider
|
||||
cfg.LLM.Model = llmModel
|
||||
cfg.LLM.PostProcessing = postProcessing
|
||||
cfg.LLM.CustomPrompt = customPrompt
|
||||
|
||||
keywords, err := inputKeywords(cfg.Keywords)
|
||||
if err != nil {
|
||||
return &ConfigureResult{Cancelled: true}, nil
|
||||
}
|
||||
cfg.Keywords = keywords
|
||||
|
||||
backends, err := selectBackends(cfg.Injection.Backends)
|
||||
if err != nil {
|
||||
return &ConfigureResult{Cancelled: true}, nil
|
||||
}
|
||||
cfg.Injection.Backends = backends
|
||||
|
||||
notificationsEnabled, err := configureNotifications(cfg.Notifications.Enabled)
|
||||
if err != nil {
|
||||
return &ConfigureResult{Cancelled: true}, nil
|
||||
}
|
||||
cfg.Notifications.Enabled = notificationsEnabled
|
||||
|
||||
confirmed, err := showSummary(cfg)
|
||||
if err != nil || !confirmed {
|
||||
return &ConfigureResult{Cancelled: true}, nil
|
||||
}
|
||||
|
||||
return &ConfigureResult{Config: cfg, Cancelled: false}, nil
|
||||
}
|
||||
|
||||
func selectProviders() ([]string, error) {
|
||||
options := []huh.Option[string]{
|
||||
huh.NewOption("OpenAI - Whisper transcription + GPT for LLM", "openai"),
|
||||
huh.NewOption("Groq - Fast Whisper transcription + Llama for LLM", "groq"),
|
||||
huh.NewOption("Mistral - Voxtral transcription (European languages)", "mistral"),
|
||||
huh.NewOption("ElevenLabs - Scribe transcription (99 languages)", "elevenlabs"),
|
||||
}
|
||||
|
||||
var selected []string
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewMultiSelect[string]().
|
||||
Title("Which providers do you want to configure?").
|
||||
Description("Select all providers you have API keys for").
|
||||
Options(options...).
|
||||
Value(&selected),
|
||||
),
|
||||
).WithTheme(getTheme())
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
valid := make([]string, 0)
|
||||
for _, s := range selected {
|
||||
for _, p := range AllProviders {
|
||||
if s == p {
|
||||
valid = append(valid, s)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return valid, nil
|
||||
}
|
||||
|
||||
func inputKeywords(existingKeywords []string) ([]string, error) {
|
||||
var keywordsInput string
|
||||
if len(existingKeywords) > 0 {
|
||||
keywordsInput = strings.Join(existingKeywords, ", ")
|
||||
}
|
||||
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("Keywords").
|
||||
Description("Comma-separated words to help with spelling (names, technical terms, etc.)").
|
||||
Placeholder("e.g., Kubernetes, PostgreSQL, John Smith").
|
||||
Value(&keywordsInput),
|
||||
),
|
||||
).WithTheme(getTheme())
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if keywordsInput == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
parts := strings.Split(keywordsInput, ",")
|
||||
keywords := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
keywords = append(keywords, p)
|
||||
}
|
||||
}
|
||||
|
||||
return keywords, nil
|
||||
}
|
||||
|
||||
func selectBackends(existingBackends []string) ([]string, error) {
|
||||
options := []huh.Option[string]{
|
||||
huh.NewOption("ydotool - Best for Chromium/Electron (needs ydotoold)", "ydotool"),
|
||||
huh.NewOption("wtype - Native Wayland typing", "wtype"),
|
||||
huh.NewOption("clipboard - Copy to clipboard only", "clipboard"),
|
||||
}
|
||||
|
||||
var selected []string
|
||||
if len(existingBackends) > 0 {
|
||||
selected = existingBackends
|
||||
} else {
|
||||
selected = []string{"ydotool", "wtype", "clipboard"}
|
||||
}
|
||||
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewMultiSelect[string]().
|
||||
Title("Text Injection Backends").
|
||||
Description("Backends are tried in order until one succeeds (fallback chain)").
|
||||
Options(options...).
|
||||
Value(&selected),
|
||||
),
|
||||
).WithTheme(getTheme())
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(selected) == 0 {
|
||||
return nil, fmt.Errorf("at least one backend required")
|
||||
}
|
||||
|
||||
return selected, nil
|
||||
}
|
||||
|
||||
func configureNotifications(existingEnabled bool) (bool, error) {
|
||||
enabled := existingEnabled
|
||||
|
||||
desc := "Show notifications for recording status changes"
|
||||
if existingEnabled {
|
||||
desc = "Currently: enabled. " + desc
|
||||
} else {
|
||||
desc = "Currently: disabled. " + desc
|
||||
}
|
||||
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewConfirm().
|
||||
Title("Enable desktop notifications?").
|
||||
Description(desc).
|
||||
Value(&enabled),
|
||||
),
|
||||
).WithTheme(getTheme())
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return enabled, nil
|
||||
}
|
||||
Reference in New Issue
Block a user