package config import ( "fmt" "os" "strings" ) // Save writes the config to the config file with formatted TOML output func Save(cfg *Config) 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() var sb strings.Builder // Header sb.WriteString(`# Hyprvoice Configuration # Generated by hyprvoice onboarding or configure # Changes are applied immediately without daemon restart. `) // Keywords (must be before any table definitions in TOML) if len(cfg.Keywords) > 0 { sb.WriteString("# Keywords help transcription and LLM spell names/terms correctly\n") sb.WriteString("keywords = [") for i, kw := range cfg.Keywords { if i > 0 { sb.WriteString(", ") } sb.WriteString(fmt.Sprintf("%q", kw)) } sb.WriteString("]\n\n") } // Providers section if len(cfg.Providers) > 0 { sb.WriteString("# API Keys for providers\n") for name, pc := range cfg.Providers { sb.WriteString(fmt.Sprintf("[providers.%s]\n", name)) sb.WriteString(fmt.Sprintf(" api_key = %q\n", pc.APIKey)) sb.WriteString("\n") } } // Recording sb.WriteString(`# Audio Recording Configuration [recording] `) sb.WriteString(fmt.Sprintf(" sample_rate = %d\n", cfg.Recording.SampleRate)) sb.WriteString(fmt.Sprintf(" channels = %d\n", cfg.Recording.Channels)) sb.WriteString(fmt.Sprintf(" format = %q\n", cfg.Recording.Format)) sb.WriteString(fmt.Sprintf(" buffer_size = %d\n", cfg.Recording.BufferSize)) sb.WriteString(fmt.Sprintf(" device = %q\n", cfg.Recording.Device)) sb.WriteString(fmt.Sprintf(" channel_buffer_size = %d\n", cfg.Recording.ChannelBufferSize)) sb.WriteString(fmt.Sprintf(" timeout = %q\n", cfg.Recording.Timeout.String())) sb.WriteString("\n") // Transcription sb.WriteString(`# Speech Transcription Configuration [transcription] `) sb.WriteString(fmt.Sprintf(" provider = %q\n", cfg.Transcription.Provider)) sb.WriteString(fmt.Sprintf(" language = %q\n", cfg.Transcription.Language)) sb.WriteString(fmt.Sprintf(" model = %q\n", cfg.Transcription.Model)) sb.WriteString(fmt.Sprintf(" streaming = %v\n", cfg.Transcription.Streaming)) sb.WriteString(fmt.Sprintf(" threads = %d\n", cfg.Transcription.Threads)) sb.WriteString("\n") // LLM sb.WriteString(`# LLM Post-Processing Configuration [llm] `) sb.WriteString(fmt.Sprintf(" enabled = %v\n", cfg.LLM.Enabled)) if cfg.LLM.Provider != "" { sb.WriteString(fmt.Sprintf(" provider = %q\n", cfg.LLM.Provider)) } if cfg.LLM.Model != "" { sb.WriteString(fmt.Sprintf(" model = %q\n", cfg.LLM.Model)) } sb.WriteString("\n") sb.WriteString(" [llm.post_processing]\n") sb.WriteString(fmt.Sprintf(" remove_stutters = %v\n", cfg.LLM.PostProcessing.RemoveStutters)) sb.WriteString(fmt.Sprintf(" add_punctuation = %v\n", cfg.LLM.PostProcessing.AddPunctuation)) sb.WriteString(fmt.Sprintf(" fix_grammar = %v\n", cfg.LLM.PostProcessing.FixGrammar)) sb.WriteString(fmt.Sprintf(" remove_filler_words = %v\n", cfg.LLM.PostProcessing.RemoveFillerWords)) sb.WriteString("\n") sb.WriteString(" [llm.custom_prompt]\n") sb.WriteString(fmt.Sprintf(" enabled = %v\n", cfg.LLM.CustomPrompt.Enabled)) if cfg.LLM.CustomPrompt.Prompt != "" { sb.WriteString(fmt.Sprintf(" prompt = %q\n", cfg.LLM.CustomPrompt.Prompt)) } sb.WriteString("\n") // Injection sb.WriteString(`# Text Injection Configuration [injection] `) sb.WriteString(" backends = [") for i, b := range cfg.Injection.Backends { if i > 0 { sb.WriteString(", ") } sb.WriteString(fmt.Sprintf("%q", b)) } sb.WriteString("]\n") sb.WriteString(fmt.Sprintf(" ydotool_timeout = %q\n", cfg.Injection.YdotoolTimeout.String())) sb.WriteString(fmt.Sprintf(" wtype_timeout = %q\n", cfg.Injection.WtypeTimeout.String())) sb.WriteString(fmt.Sprintf(" clipboard_timeout = %q\n", cfg.Injection.ClipboardTimeout.String())) sb.WriteString("\n") // Notifications sb.WriteString(`# Desktop Notification Configuration [notifications] `) sb.WriteString(fmt.Sprintf(" enabled = %v\n", cfg.Notifications.Enabled)) sb.WriteString(fmt.Sprintf(" type = %q\n", cfg.Notifications.Type)) // Write custom messages if any msgs := cfg.Notifications.Messages if hasCustomMessages(msgs) { sb.WriteString("\n [notifications.messages]\n") if msgs.RecordingStarted.Title != "" || msgs.RecordingStarted.Body != "" { sb.WriteString(" [notifications.messages.recording_started]\n") sb.WriteString(fmt.Sprintf(" title = %q\n", msgs.RecordingStarted.Title)) sb.WriteString(fmt.Sprintf(" body = %q\n", msgs.RecordingStarted.Body)) } if msgs.Transcribing.Title != "" || msgs.Transcribing.Body != "" { sb.WriteString(" [notifications.messages.transcribing]\n") sb.WriteString(fmt.Sprintf(" title = %q\n", msgs.Transcribing.Title)) sb.WriteString(fmt.Sprintf(" body = %q\n", msgs.Transcribing.Body)) } if msgs.LLMProcessing.Title != "" || msgs.LLMProcessing.Body != "" { sb.WriteString(" [notifications.messages.llm_processing]\n") sb.WriteString(fmt.Sprintf(" title = %q\n", msgs.LLMProcessing.Title)) sb.WriteString(fmt.Sprintf(" body = %q\n", msgs.LLMProcessing.Body)) } if msgs.ConfigReloaded.Title != "" || msgs.ConfigReloaded.Body != "" { sb.WriteString(" [notifications.messages.config_reloaded]\n") sb.WriteString(fmt.Sprintf(" title = %q\n", msgs.ConfigReloaded.Title)) sb.WriteString(fmt.Sprintf(" body = %q\n", msgs.ConfigReloaded.Body)) } if msgs.OperationCancelled.Title != "" || msgs.OperationCancelled.Body != "" { sb.WriteString(" [notifications.messages.operation_cancelled]\n") sb.WriteString(fmt.Sprintf(" title = %q\n", msgs.OperationCancelled.Title)) sb.WriteString(fmt.Sprintf(" body = %q\n", msgs.OperationCancelled.Body)) } if msgs.RecordingAborted.Body != "" { sb.WriteString(" [notifications.messages.recording_aborted]\n") sb.WriteString(fmt.Sprintf(" body = %q\n", msgs.RecordingAborted.Body)) } if msgs.InjectionAborted.Body != "" { sb.WriteString(" [notifications.messages.injection_aborted]\n") sb.WriteString(fmt.Sprintf(" body = %q\n", msgs.InjectionAborted.Body)) } } if _, err := file.WriteString(sb.String()); err != nil { return fmt.Errorf("failed to write config content: %w", err) } return nil } func hasCustomMessages(msgs MessagesConfig) bool { return msgs.RecordingStarted.Title != "" || msgs.RecordingStarted.Body != "" || msgs.Transcribing.Title != "" || msgs.Transcribing.Body != "" || msgs.LLMProcessing.Title != "" || msgs.LLMProcessing.Body != "" || msgs.ConfigReloaded.Title != "" || msgs.ConfigReloaded.Body != "" || msgs.OperationCancelled.Title != "" || msgs.OperationCancelled.Body != "" || msgs.RecordingAborted.Body != "" || msgs.InjectionAborted.Body != "" } // SaveDefaultConfig writes the default config template to the config file 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. # 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) # [providers.deepgram] # api_key = "" # Deepgram API key (or set DEEPGRAM_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", "mistral-transcription", "elevenlabs", "whisper-cpp" model = "whisper-1" # Model: OpenAI="whisper-1", Groq="whisper-large-v3", Mistral="voxtral-mini-latest", ElevenLabs="scribe_v1" language = "" # ISO 639-1 code (e.g., en, es, de). Empty for auto-detect. threads = 0 # CPU threads for local transcription (0 = auto: uses NumCPU-1) # ───────────────────────────────────────────────────────────────────────────── # 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) # - "mistral-transcription": Mistral Voxtral API (excellent for European languages, model: voxtral-mini-latest) # - "elevenlabs": ElevenLabs Scribe API (99 languages, models: scribe_v1, scribe_v2, scribe_v2_realtime) # # 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. # Language is configured per transcription model - only supported languages are shown during setup. ` if _, err := file.WriteString(configContent); err != nil { return fmt.Errorf("failed to write config content: %w", err) } return nil }