From b8a22662f3d54414ecd29da492923ac717f8cf30 Mon Sep 17 00:00:00 2001 From: LeonardoTrapani Date: Mon, 18 Aug 2025 22:22:26 +0200 Subject: [PATCH] configuration file --- README.md | 109 +++++++++--- cmd/hyprvoice/main.go | 7 +- go.mod | 3 + go.sum | 6 + internal/config/config.go | 265 ++++++++++++++++++++++++++++ internal/config/manager.go | 139 +++++++++++++++ internal/daemon/daemon.go | 49 +++-- internal/injection/clipboard.go | 5 - internal/injection/injection.go | 37 +--- internal/injection/typing.go | 3 - internal/pipeline/pipeline.go | 11 +- internal/recording/recording.go | 13 -- internal/transcriber/transcriber.go | 18 -- 13 files changed, 556 insertions(+), 109 deletions(-) create mode 100644 internal/config/config.go create mode 100644 internal/config/manager.go diff --git a/README.md b/README.md index 80af5bc..7260e66 100644 --- a/README.md +++ b/README.md @@ -190,22 +190,48 @@ hyprvoice status ## Configuration -Configuration will be read from `~/.config/hyprvoice/config.toml` (planned). Currently, the daemon uses default settings. +Configuration is automatically loaded from `~/.config/hyprvoice/config.toml`. The daemon creates this file with sensible defaults and helpful comments on first run. Changes to the config file are applied immediately without restarting the daemon. ### Transcription Providers -Hyprvoice will support multiple transcription backends: +Hyprvoice supports multiple transcription backends: -#### OpenAI Whisper API +#### Generated Configuration Example -Fast, accurate cloud-based transcription: +The daemon automatically creates `~/.config/hyprvoice/config.toml` with helpful comments: ```toml +# Hyprvoice Configuration +# This file is automatically generated with defaults. +# Edit values as needed - changes are applied immediately without daemon restart. + +# Audio Recording Configuration +[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) + +# Speech Transcription Configuration [transcription] -provider = "openai" -api_key = "your_openai_api_key" -model = "whisper-1" -language = "auto" # or "en", "es", etc. + provider = "openai" # Transcription service ("openai" only currently supported) + api_key = "" # OpenAI API key (or set OPENAI_API_KEY environment variable) + language = "" # Language code (empty for auto-detect, "en", "it", "es", "fr", etc.) + model = "whisper-1" # OpenAI model name ("whisper-1" recommended) + +# Text Injection Configuration +[injection] + mode = "fallback" # Injection method ("clipboard", "type", "fallback") + restore_clipboard = true # Restore original clipboard after injection + wtype_timeout = "5s" # Timeout for direct typing via wtype + clipboard_timeout = "3s" # Timeout for clipboard operations + +# Desktop Notification Configuration +[notifications] + enabled = true # Enable desktop notifications + type = "desktop" # Notification type ("desktop", "log", "none") -- always keep "desktop" unless debugging ``` #### whisper.cpp Local (Planned) @@ -219,14 +245,27 @@ model_path = "~/models/ggml-base.en.bin" threads = 4 ``` -#### Text Injection (Current) +#### Recording Configuration + +Audio capture settings: + +```toml +[recording] +sample_rate = 16000 # Audio sample rate in Hz +channels = 1 # Number of audio channels (1 for mono) +format = "s16" # Audio format (s16 recommended) +buffer_size = 8192 # Internal buffer size in bytes +device = "" # PipeWire device (empty for default) +channel_buffer_size = 30 # Audio frame buffer size +``` + +#### Text Injection Configurable text injection with multiple modes: ```toml [injection] mode = "fallback" # "clipboard", "type", or "fallback" -always_copy_clipboard = true restore_clipboard = true wtype_timeout = "5s" clipboard_timeout = "3s" @@ -240,10 +279,36 @@ clipboard_timeout = "3s" **Behavior:** -- `always_copy_clipboard = true`: Always copy text to clipboard regardless of mode - `restore_clipboard = true`: Save and restore original clipboard content - Smart fallback ensures text injection always succeeds when possible +#### Notifications + +Desktop notification settings: + +```toml +[notifications] +enabled = true # Enable/disable notifications +type = "desktop" # "desktop", "log", or "none" +``` + +**Notification Types:** + +- **`desktop`**: Use notify-send for desktop notifications +- **`log`**: Log messages to console only +- **`none`**: Disable all notifications + +Always keep `type = "desktop"` unless debugging. + +### Configuration Hot-Reloading + +The daemon automatically watches the config file for changes and applies them immediately: + +- **Notification settings**: Applied instantly +- **Injection settings**: Applied to current and future operations +- **Recording/Transcription settings**: Applied to new recording sessions +- **Invalid configs**: Rejected with error notification, daemon continues with previous config + ### Service Configuration #### Systemd Service @@ -281,17 +346,17 @@ systemctl --user enable --now hyprvoice.service ## Development Status -| Component | Status | Notes | -| --------------------- | ------ | -------------------------------- | -| Core daemon & IPC | ✅ | Unix socket control plane | -| Recording workflow | ✅ | Toggle recording via PipeWire | -| Audio capture | ✅ | Efficient PipeWire integration | -| Desktop notifications | ✅ | Status feedback via notify-send | -| OpenAI transcription | ✅ | HTTP API integration | -| Text injection | ✅ | Clipboard + wtype with fallback | -| Configuration system | ⏳ | TOML-based user settings | -| Comprehensive tests | ⏳ | Pipeline and integration testing | -| whisper.cpp support | ⏳ | Local model inference | +| Component | Status | Notes | +| --------------------- | ------ | ---------------------------------------- | +| Core daemon & IPC | ✅ | Unix socket control plane | +| Recording workflow | ✅ | Toggle recording via PipeWire | +| Audio capture | ✅ | Efficient PipeWire integration | +| Desktop notifications | ✅ | Status feedback via notify-send | +| OpenAI transcription | ✅ | HTTP API integration | +| Text injection | ✅ | Clipboard + wtype with fallback | +| Configuration system | ✅ | TOML-based user settings with hot-reload | +| Comprehensive tests | ⏳ | Pipeline and integration testing | +| whisper.cpp support | ⏳ | Local model inference | **Legend**: ✅ Complete · ⏳ Planned diff --git a/cmd/hyprvoice/main.go b/cmd/hyprvoice/main.go index 38ec386..d8418ce 100644 --- a/cmd/hyprvoice/main.go +++ b/cmd/hyprvoice/main.go @@ -5,7 +5,6 @@ import ( "github.com/leonardotrapani/hyprvoice/internal/bus" "github.com/leonardotrapani/hyprvoice/internal/daemon" - "github.com/leonardotrapani/hyprvoice/internal/notify" "github.com/spf13/cobra" ) @@ -33,7 +32,11 @@ func serveCmd() *cobra.Command { Use: "serve", Short: "Run the daemon", RunE: func(cmd *cobra.Command, args []string) error { - return (daemon.New(notify.Desktop{})).Run() + d, err := daemon.New() + if err != nil { + return fmt.Errorf("failed to create daemon: %w", err) + } + return d.Run() }, } } diff --git a/go.mod b/go.mod index a411033..117d9e6 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,11 @@ module github.com/leonardotrapani/hyprvoice go 1.24.5 require ( + github.com/BurntSushi/toml v1.5.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/sashabaranov/go-openai v1.41.1 // indirect github.com/spf13/cobra v1.9.1 // indirect github.com/spf13/pflag v1.0.6 // indirect + golang.org/x/sys v0.13.0 // indirect ) diff --git a/go.sum b/go.sum index 3a7a8f1..a83ea15 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,8 @@ +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -8,5 +12,7 @@ github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..8e36a6d --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,265 @@ +package config + +import ( + "fmt" + "log" + "os" + "path/filepath" + "time" + + "github.com/BurntSushi/toml" + "github.com/leonardotrapani/hyprvoice/internal/injection" + "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"` +} + +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"` +} + +type TranscriptionConfig struct { + Provider string `toml:"provider"` + APIKey string `toml:"api_key"` + Language string `toml:"language"` + Model string `toml:"model"` +} + +type InjectionConfig struct { + Mode string `toml:"mode"` + RestoreClipboard bool `toml:"restore_clipboard"` + 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" +} + +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, + } +} + +func (c *Config) ToTranscriberConfig() transcriber.Config { + config := transcriber.Config{ + Provider: c.Transcription.Provider, + APIKey: c.Transcription.APIKey, + Language: c.Transcription.Language, + Model: c.Transcription.Model, + } + + if config.APIKey == "" { + config.APIKey = os.Getenv("OPENAI_API_KEY") + } + + return config +} + +func (c *Config) ToInjectionConfig() injection.Config { + return injection.Config{ + Mode: c.Injection.Mode, + RestoreClipboard: c.Injection.RestoreClipboard, + 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") + } + + // Transcription + if c.Transcription.Provider == "" { + return fmt.Errorf("invalid transcription.provider: empty") + } + if c.Transcription.Provider == "openai" { + apiKey := c.Transcription.APIKey + if apiKey == "" { + apiKey = os.Getenv("OPENAI_API_KEY") + } + if apiKey == "" { + return fmt.Errorf("OpenAI API key required (set transcription.api_key in config or OPENAI_API_KEY env var)") + } + + // 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) + } + } + if c.Transcription.Model == "" { + return fmt.Errorf("invalid transcription.model: empty") + } + + // Injection + validModes := map[string]bool{"clipboard": true, "type": true, "fallback": true} + if !validModes[c.Injection.Mode] { + return fmt.Errorf("invalid injection.mode: %s (must be clipboard, type, or fallback)", c.Injection.Mode) + } + 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 +} + +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) + } + + log.Printf("Config: configuration loaded successfully") + return &config, nil +} + +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. + +# Audio Recording Configuration +[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) + +# Speech Transcription Configuration +[transcription] + provider = "openai" # Transcription service ("openai" only currently supported) + api_key = "" # OpenAI API key (or set OPENAI_API_KEY environment variable) + language = "" # Language code (empty for auto-detect, "en", "it", "es", "fr", etc.) + model = "whisper-1" # OpenAI model name ("whisper-1" recommended) + +# Text Injection Configuration +[injection] + mode = "fallback" # Injection method ("clipboard", "type", "fallback") + restore_clipboard = true # Restore original clipboard after injection + wtype_timeout = "5s" # Timeout for direct typing via wtype + clipboard_timeout = "3s" # Timeout for clipboard operations + +# Desktop Notification Configuration +[notifications] + enabled = true # Enable desktop notifications + type = "desktop" # Notification type ("desktop", "log", "none") + +# Mode explanations: +# - "clipboard": Copy text to clipboard only +# - "type": Direct typing via wtype only +# - "fallback": Try typing first, fallback to clipboard if it fails +# +# Language codes: Use empty string ("") for automatic detection, or specific codes like: +# "en" (English), "it" (Italian), "es" (Spanish), "fr" (French), "de" (German), etc. +` + + if _, err := file.WriteString(configContent); err != nil { + return fmt.Errorf("failed to write config content: %w", err) + } + + return nil +} diff --git a/internal/config/manager.go b/internal/config/manager.go new file mode 100644 index 0000000..dffe04c --- /dev/null +++ b/internal/config/manager.go @@ -0,0 +1,139 @@ +package config + +import ( + "context" + "log" + "path/filepath" + "sync" + + "github.com/fsnotify/fsnotify" +) + +type Manager struct { + mu sync.RWMutex + config *Config + watcher *fsnotify.Watcher + wg sync.WaitGroup +} + +func NewManager() (*Manager, error) { + log.Printf("Config manager: initializing configuration system...") + + config, err := Load() + if err != nil { + log.Printf("Config manager: failed to load initial configuration: %v", err) + return nil, err + } + + log.Printf("Config manager: validating initial configuration...") + if err := config.Validate(); err != nil { + log.Printf("Config manager: validation warning: %v", err) + } + + m := &Manager{ + config: config, + } + + log.Printf("Config manager: initialization completed successfully") + return m, nil +} + +func (m *Manager) GetConfig() *Config { + m.mu.RLock() + defer m.mu.RUnlock() + + // Return a copy to prevent external modification + configCopy := *m.config + return &configCopy +} + +func (m *Manager) StartWatching(ctx context.Context) error { + configPath, err := GetConfigPath() + if err != nil { + return err + } + + watcher, err := fsnotify.NewWatcher() + if err != nil { + return err + } + + m.watcher = watcher + + configDir := filepath.Dir(configPath) + err = watcher.Add(configDir) + if err != nil { + watcher.Close() + return err + } + + m.wg.Add(1) + go m.watchLoop(ctx, configPath) + + log.Printf("Config manager: watching %s for changes", configPath) + return nil +} + +func (m *Manager) Stop() { + if m.watcher != nil { + m.watcher.Close() + } + m.wg.Wait() +} + +func (m *Manager) watchLoop(ctx context.Context, configPath string) { + defer m.wg.Done() + configFileName := filepath.Base(configPath) + + for { + select { + case event, ok := <-m.watcher.Events: + if !ok { + return + } + + // Filter for our config file only + eventFileName := filepath.Base(event.Name) + if eventFileName != configFileName { + continue + } + + // Only react to Write and Create events (ignore Chmod, Remove, etc.) + if event.Op&fsnotify.Write == fsnotify.Write || event.Op&fsnotify.Create == fsnotify.Create { + log.Printf("Config manager: file change detected: %s. Reloading config...", event.Name) + m.reloadConfig() + } + + case err, ok := <-m.watcher.Errors: + if !ok { + return + } + log.Printf("Config watcher error: %v", err) + + case <-ctx.Done(): + return + } + } +} + +func (m *Manager) reloadConfig() { + log.Printf("Config manager: starting configuration reload...") + + newConfig, err := Load() + if err != nil { + log.Printf("Config manager: failed to reload config: %v", err) + return + } + + log.Printf("Config manager: validating new configuration...") + if err := newConfig.Validate(); err != nil { + log.Printf("Config manager: invalid config after reload: %v", err) + return + } + + m.mu.Lock() + m.config = newConfig + m.mu.Unlock() + + log.Printf("Config manager: configuration successfully reloaded") +} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index a5d5f09..e05ae74 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -12,13 +12,15 @@ import ( "syscall" "github.com/leonardotrapani/hyprvoice/internal/bus" + "github.com/leonardotrapani/hyprvoice/internal/config" "github.com/leonardotrapani/hyprvoice/internal/notify" "github.com/leonardotrapani/hyprvoice/internal/pipeline" ) type Daemon struct { - mu sync.RWMutex - notifier notify.Notifier + mu sync.RWMutex + notifier notify.Notifier + configMgr *config.Manager ctx context.Context cancel context.CancelFunc @@ -28,18 +30,35 @@ type Daemon struct { wg sync.WaitGroup } -func New(n notify.Notifier) *Daemon { - if n == nil { +func New() (*Daemon, error) { + configMgr, err := config.NewManager() + + conf := configMgr.GetConfig() + + var n notify.Notifier + + switch conf.Notifications.Type { + case "desktop": n = notify.Desktop{} - } - ctx, cancel := context.WithCancel(context.Background()) - d := &Daemon{ - notifier: n, - ctx: ctx, - cancel: cancel, + case "log": + n = notify.Log{} + case "none": + n = notify.Nop{} } - return d + if err != nil { + return nil, fmt.Errorf("failed to create config manager: %w", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + d := &Daemon{ + notifier: n, + configMgr: configMgr, + ctx: ctx, + cancel: cancel, + } + + return d, nil } func (d *Daemon) status() pipeline.Status { @@ -78,6 +97,11 @@ func (d *Daemon) Run() error { } defer bus.RemovePidFile() + if err := d.configMgr.StartWatching(d.ctx); err != nil { + log.Printf("Warning: failed to start config file watching: %v", err) + } + defer d.configMgr.Stop() + sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT) defer signal.Stop(sigCh) @@ -150,7 +174,8 @@ func (d *Daemon) handle(c net.Conn) { func (d *Daemon) toggle() { switch d.status() { case pipeline.Idle: - p := pipeline.New() + config := d.configMgr.GetConfig() + p := pipeline.New(config) p.Run(d.ctx) d.mu.Lock() diff --git a/internal/injection/clipboard.go b/internal/injection/clipboard.go index 7c7d8db..4899d38 100644 --- a/internal/injection/clipboard.go +++ b/internal/injection/clipboard.go @@ -8,7 +8,6 @@ import ( "time" ) -// getClipboard retrieves the current clipboard content using wl-paste func getClipboard(ctx context.Context, timeout time.Duration) (string, error) { ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() @@ -16,15 +15,12 @@ func getClipboard(ctx context.Context, timeout time.Duration) (string, error) { cmd := exec.CommandContext(ctx, "wl-paste", "--no-newline") output, err := cmd.Output() if err != nil { - // wl-paste returns non-zero exit code if clipboard is empty or unavailable - // This is normal behavior, so we return empty string instead of error return "", nil } return string(output), nil } -// setClipboard sets the clipboard content using wl-copy func setClipboard(ctx context.Context, text string, timeout time.Duration) error { ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() @@ -39,7 +35,6 @@ func setClipboard(ctx context.Context, text string, timeout time.Duration) error return nil } -// checkClipboardAvailable checks if wl-clipboard tools are available func checkClipboardAvailable() error { if _, err := exec.LookPath("wl-copy"); err != nil { return fmt.Errorf("wl-copy not found: %w (install wl-clipboard)", err) diff --git a/internal/injection/injection.go b/internal/injection/injection.go index fbdad95..7fdefca 100644 --- a/internal/injection/injection.go +++ b/internal/injection/injection.go @@ -6,59 +6,37 @@ import ( "time" ) -// Injector interface for text injection type Injector interface { Inject(ctx context.Context, text string) error } -// Config for text injection type Config struct { - Mode string // "clipboard", "type", "fallback" - AlwaysCopyClipboard bool // Always copy to clipboard regardless of mode - RestoreClipboard bool // Restore original clipboard after injection - WtypeTimeout time.Duration // Timeout for wtype commands - ClipboardTimeout time.Duration // Timeout for clipboard operations + Mode string // "clipboard", "type", "fallback" + RestoreClipboard bool // Restore original clipboard after injection + WtypeTimeout time.Duration // Timeout for wtype commands + ClipboardTimeout time.Duration // Timeout for clipboard operations } -// DefaultConfig returns sensible defaults for injection -func DefaultConfig() Config { - return Config{ - Mode: "fallback", - AlwaysCopyClipboard: true, - RestoreClipboard: true, - WtypeTimeout: 5 * time.Second, - ClipboardTimeout: 3 * time.Second, - } -} - -// injector implements the Injector interface type injector struct { config Config } -// NewInjector creates a new injector with the given config func NewInjector(config Config) Injector { return &injector{ config: config, } } -// NewDefaultInjector creates an injector with default configuration -func NewDefaultInjector() Injector { - return NewInjector(DefaultConfig()) -} - -// Inject performs text injection based on the configured mode func (i *injector) Inject(ctx context.Context, text string) error { if text == "" { return fmt.Errorf("cannot inject empty text") } - // Always copy to clipboard if configured + // Copy to clipboard for clipboard mode and fallback mode var originalClipboard string var err error - if i.config.AlwaysCopyClipboard || i.config.Mode == "clipboard" || i.config.Mode == "fallback" { + if i.config.Mode == "clipboard" || i.config.Mode == "fallback" { if err := checkClipboardAvailable(); err != nil { return fmt.Errorf("clipboard tools not available: %w", err) } @@ -99,10 +77,9 @@ func (i *injector) Inject(ctx context.Context, text string) error { // Restore original clipboard if configured and we have it if i.config.RestoreClipboard && originalClipboard != "" { - // Restore after a short delay to ensure the text has been processed go func() { time.Sleep(100 * time.Millisecond) - restoreCtx, cancel := context.WithTimeout(context.Background(), i.config.ClipboardTimeout) + restoreCtx, cancel := context.WithTimeout(ctx, i.config.ClipboardTimeout) defer cancel() setClipboard(restoreCtx, originalClipboard, i.config.ClipboardTimeout) }() diff --git a/internal/injection/typing.go b/internal/injection/typing.go index 268c662..46229e0 100644 --- a/internal/injection/typing.go +++ b/internal/injection/typing.go @@ -7,12 +7,10 @@ import ( "time" ) -// typeText types the given text using wtype func typeText(ctx context.Context, text string, timeout time.Duration) error { ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - // Check if wtype is available if err := checkWtypeAvailable(); err != nil { return err } @@ -26,7 +24,6 @@ func typeText(ctx context.Context, text string, timeout time.Duration) error { return nil } -// checkWtypeAvailable checks if wtype is available on the system func checkWtypeAvailable() error { if _, err := exec.LookPath("wtype"); err != nil { return fmt.Errorf("wtype not found: %w (install wtype package)", err) diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go index d9df4b5..1a74ce2 100644 --- a/internal/pipeline/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -7,6 +7,7 @@ import ( "sync/atomic" "time" + "github.com/leonardotrapani/hyprvoice/internal/config" "github.com/leonardotrapani/hyprvoice/internal/injection" "github.com/leonardotrapani/hyprvoice/internal/recording" "github.com/leonardotrapani/hyprvoice/internal/transcriber" @@ -44,6 +45,7 @@ type pipeline struct { status Status actionCh chan Action errorCh chan PipelineError + config *config.Config mu sync.RWMutex wg sync.WaitGroup @@ -53,10 +55,11 @@ type pipeline struct { running atomic.Bool } -func New() Pipeline { +func New(cfg *config.Config) Pipeline { return &pipeline{ actionCh: make(chan Action, 1), errorCh: make(chan PipelineError, 10), + config: cfg, } } func (p *pipeline) Run(ctx context.Context) { @@ -82,7 +85,7 @@ func (p *pipeline) run(ctx context.Context) { log.Printf("Pipeline: Starting recording") p.setStatus(Recording) - recorder := recording.NewDefaultRecorder() + recorder := recording.NewRecorder(p.config.ToRecordingConfig()) frameCh, rErrCh, err := recorder.Start(ctx) if err != nil { @@ -93,7 +96,7 @@ func (p *pipeline) run(ctx context.Context) { defer recorder.Stop() - t, err := transcriber.NewDefaultTranscriber() + t, err := transcriber.NewTranscriber(p.config.ToTranscriberConfig()) if err != nil { log.Printf("Pipeline: Failed to create transcriber: %v", err) p.sendError("Transcription Error", "Failed to create transcriber", err) @@ -222,7 +225,7 @@ func (p *pipeline) handleInjectAction(ctx context.Context, recorder *recording.R } log.Printf("Pipeline: Final transcription text: %s", transcriptionText) - injector := injection.NewDefaultInjector() + injector := injection.NewInjector(p.config.ToInjectionConfig()) if err := injector.Inject(ctx, transcriptionText); err != nil { p.sendError("Injection Error", "Failed to inject text", err) diff --git a/internal/recording/recording.go b/internal/recording/recording.go index e5d9a9c..cf30cea 100644 --- a/internal/recording/recording.go +++ b/internal/recording/recording.go @@ -28,17 +28,6 @@ type Config struct { ChannelBufferSize int } -func DefaultConfig() Config { - return Config{ - SampleRate: 16000, - Channels: 1, - Format: "s16", - BufferSize: 8192, - Device: "", - ChannelBufferSize: 30, - } -} - type Recorder struct { config Config recording atomic.Bool @@ -218,8 +207,6 @@ func (r *Recorder) buildPwRecordArgs() []string { return args } -func NewDefaultRecorder() *Recorder { return NewRecorder(DefaultConfig()) } - func CheckPipeWireAvailable(ctx context.Context) error { if _, err := exec.LookPath("pw-record"); err != nil { return fmt.Errorf("pw-record not found: %w (install pipewire-tools)", err) diff --git a/internal/transcriber/transcriber.go b/internal/transcriber/transcriber.go index 2e018c2..9fdd35a 100644 --- a/internal/transcriber/transcriber.go +++ b/internal/transcriber/transcriber.go @@ -3,7 +3,6 @@ package transcriber import ( "context" "fmt" - "os" "github.com/leonardotrapani/hyprvoice/internal/recording" ) @@ -28,14 +27,6 @@ type Config struct { Model string } -func DefaultConfig() Config { - return Config{ - Provider: "openai", - Language: "it", - Model: "whisper-1", - } -} - // NewTranscriber creates a new simple transcriber func NewTranscriber(config Config) (Transcriber, error) { // Create the appropriate adapter @@ -57,12 +48,3 @@ func NewTranscriber(config Config) (Transcriber, error) { return transcriber, nil } - -func NewDefaultTranscriber() (Transcriber, error) { - config := DefaultConfig() - if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" { - config.APIKey = apiKey - } - - return NewTranscriber(config) -}