feat: refactor

This commit is contained in:
leonardotrapani
2026-02-01 17:24:43 +01:00
parent 13b1de4e04
commit 8df3021a9d
33 changed files with 1290 additions and 1577 deletions
+14 -18
View File
@@ -689,22 +689,22 @@ func TestValidateModelLanguageCompatibility(t *testing.T) {
}{
{
name: "auto language always passes",
provider: "groq",
model: "distil-whisper-large-v3-en",
provider: "whisper-cpp",
model: "base.en",
langCode: "",
wantErr: false,
},
{
name: "english model supports english",
provider: "groq",
model: "distil-whisper-large-v3-en",
provider: "whisper-cpp",
model: "base.en",
langCode: "en",
wantErr: false,
},
{
name: "english model rejects spanish",
provider: "groq",
model: "distil-whisper-large-v3-en",
provider: "whisper-cpp",
model: "base.en",
langCode: "es",
wantErr: true,
errContains: "does not support Spanish (es)",
@@ -2079,9 +2079,8 @@ func TestConfig_Validate_GeneralLanguage(t *testing.T) {
t.Run("general.language validated against model", func(t *testing.T) {
config := baseConfig()
config.General.Language = "es"
config.Transcription.Provider = "groq-transcription"
config.Transcription.Model = "distil-whisper-large-v3-en" // english-only model
config.Transcription.APIKey = "gsk-test-key"
config.Transcription.Provider = "whisper-cpp"
config.Transcription.Model = "base.en" // english-only model
err := config.Validate()
if err == nil {
@@ -2096,9 +2095,8 @@ func TestConfig_Validate_GeneralLanguage(t *testing.T) {
config := baseConfig()
config.General.Language = "en" // compatible
config.Transcription.Language = "es" // override with incompatible
config.Transcription.Provider = "groq-transcription"
config.Transcription.Model = "distil-whisper-large-v3-en" // english-only model
config.Transcription.APIKey = "gsk-test-key"
config.Transcription.Provider = "whisper-cpp"
config.Transcription.Model = "base.en" // english-only model
err := config.Validate()
if err == nil {
@@ -2113,9 +2111,8 @@ func TestConfig_Validate_GeneralLanguage(t *testing.T) {
config := baseConfig()
config.General.Language = "es" // would be incompatible
config.Transcription.Language = "en" // override with compatible
config.Transcription.Provider = "groq-transcription"
config.Transcription.Model = "distil-whisper-large-v3-en" // english-only model
config.Transcription.APIKey = "gsk-test-key"
config.Transcription.Provider = "whisper-cpp"
config.Transcription.Model = "base.en" // english-only model
err := config.Validate()
if err != nil {
@@ -2126,9 +2123,8 @@ func TestConfig_Validate_GeneralLanguage(t *testing.T) {
t.Run("auto language always passes", func(t *testing.T) {
config := baseConfig()
config.General.Language = "" // auto
config.Transcription.Provider = "groq-transcription"
config.Transcription.Model = "distil-whisper-large-v3-en" // english-only model
config.Transcription.APIKey = "gsk-test-key"
config.Transcription.Provider = "whisper-cpp"
config.Transcription.Model = "base.en" // english-only model
err := config.Validate()
if err != nil {
+14 -35
View File
@@ -4,6 +4,7 @@ import (
"os"
"github.com/leonardotrapani/hyprvoice/internal/injection"
"github.com/leonardotrapani/hyprvoice/internal/provider"
"github.com/leonardotrapani/hyprvoice/internal/recording"
"github.com/leonardotrapani/hyprvoice/internal/transcriber"
)
@@ -22,11 +23,12 @@ func (c *Config) ToRecordingConfig() recording.Config {
func (c *Config) ToTranscriberConfig() transcriber.Config {
config := transcriber.Config{
Provider: c.Transcription.Provider,
Language: c.resolveEffectiveLanguage(),
Model: c.Transcription.Model,
Keywords: c.Keywords,
Threads: c.Transcription.Threads,
Provider: c.Transcription.Provider,
Language: c.resolveEffectiveLanguage(),
Model: c.Transcription.Model,
Keywords: c.Keywords,
Threads: c.Transcription.Threads,
Streaming: c.Transcription.Streaming,
}
config.APIKey = c.resolveAPIKeyForProvider(c.Transcription.Provider)
@@ -44,29 +46,12 @@ func (c *Config) resolveEffectiveLanguage() string {
}
// 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"
case "deepgram":
providerName = "deepgram"
envVar = "DEEPGRAM_API_KEY"
}
func (c *Config) resolveAPIKeyForProvider(providerName string) string {
baseName := provider.BaseProviderName(providerName)
envVar := provider.EnvVarForProvider(providerName)
if c.Providers != nil {
if pc, ok := c.Providers[providerName]; ok && pc.APIKey != "" {
if pc, ok := c.Providers[baseName]; ok && pc.APIKey != "" {
return pc.APIKey
}
}
@@ -106,17 +91,11 @@ func (c *Config) ToLLMConfig() LLMAdapterConfig {
}
// 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"
}
func (c *Config) resolveAPIKeyForLLMProvider(providerName string) string {
envVar := provider.EnvVarForProvider(providerName)
if c.Providers != nil {
if pc, ok := c.Providers[provider]; ok && pc.APIKey != "" {
if pc, ok := c.Providers[providerName]; ok && pc.APIKey != "" {
return pc.APIKey
}
}
+185
View File
@@ -3,8 +3,193 @@ 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 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")
}
// General section
sb.WriteString(`# General Settings
[general]
`)
sb.WriteString(fmt.Sprintf(" language = %q\n", cfg.General.Language))
sb.WriteString("\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 {
+6 -5
View File
@@ -62,11 +62,12 @@ type RecordingConfig struct {
}
type TranscriptionConfig struct {
Provider string `toml:"provider"`
APIKey string `toml:"api_key"`
Language string `toml:"language"`
Model string `toml:"model"`
Threads int `toml:"threads"` // CPU threads for local transcription (0 = auto: NumCPU-1)
Provider string `toml:"provider"`
APIKey string `toml:"api_key"`
Language string `toml:"language"`
Model string `toml:"model"`
Streaming bool `toml:"streaming"` // use streaming mode if model supports it
Threads int `toml:"threads"` // CPU threads for local transcription (0 = auto: NumCPU-1)
}
type InjectionConfig struct {