configuration file
This commit is contained in:
@@ -190,22 +190,48 @@ hyprvoice status
|
|||||||
|
|
||||||
## Configuration
|
## 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
|
### 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
|
```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]
|
[transcription]
|
||||||
provider = "openai"
|
provider = "openai" # Transcription service ("openai" only currently supported)
|
||||||
api_key = "your_openai_api_key"
|
api_key = "" # OpenAI API key (or set OPENAI_API_KEY environment variable)
|
||||||
model = "whisper-1"
|
language = "" # Language code (empty for auto-detect, "en", "it", "es", "fr", etc.)
|
||||||
language = "auto" # or "en", "es", 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)
|
#### whisper.cpp Local (Planned)
|
||||||
@@ -219,14 +245,27 @@ model_path = "~/models/ggml-base.en.bin"
|
|||||||
threads = 4
|
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:
|
Configurable text injection with multiple modes:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[injection]
|
[injection]
|
||||||
mode = "fallback" # "clipboard", "type", or "fallback"
|
mode = "fallback" # "clipboard", "type", or "fallback"
|
||||||
always_copy_clipboard = true
|
|
||||||
restore_clipboard = true
|
restore_clipboard = true
|
||||||
wtype_timeout = "5s"
|
wtype_timeout = "5s"
|
||||||
clipboard_timeout = "3s"
|
clipboard_timeout = "3s"
|
||||||
@@ -240,10 +279,36 @@ clipboard_timeout = "3s"
|
|||||||
|
|
||||||
**Behavior:**
|
**Behavior:**
|
||||||
|
|
||||||
- `always_copy_clipboard = true`: Always copy text to clipboard regardless of mode
|
|
||||||
- `restore_clipboard = true`: Save and restore original clipboard content
|
- `restore_clipboard = true`: Save and restore original clipboard content
|
||||||
- Smart fallback ensures text injection always succeeds when possible
|
- 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
|
### Service Configuration
|
||||||
|
|
||||||
#### Systemd Service
|
#### Systemd Service
|
||||||
@@ -281,17 +346,17 @@ systemctl --user enable --now hyprvoice.service
|
|||||||
|
|
||||||
## Development Status
|
## Development Status
|
||||||
|
|
||||||
| Component | Status | Notes |
|
| Component | Status | Notes |
|
||||||
| --------------------- | ------ | -------------------------------- |
|
| --------------------- | ------ | ---------------------------------------- |
|
||||||
| Core daemon & IPC | ✅ | Unix socket control plane |
|
| Core daemon & IPC | ✅ | Unix socket control plane |
|
||||||
| Recording workflow | ✅ | Toggle recording via PipeWire |
|
| Recording workflow | ✅ | Toggle recording via PipeWire |
|
||||||
| Audio capture | ✅ | Efficient PipeWire integration |
|
| Audio capture | ✅ | Efficient PipeWire integration |
|
||||||
| Desktop notifications | ✅ | Status feedback via notify-send |
|
| Desktop notifications | ✅ | Status feedback via notify-send |
|
||||||
| OpenAI transcription | ✅ | HTTP API integration |
|
| OpenAI transcription | ✅ | HTTP API integration |
|
||||||
| Text injection | ✅ | Clipboard + wtype with fallback |
|
| Text injection | ✅ | Clipboard + wtype with fallback |
|
||||||
| Configuration system | ⏳ | TOML-based user settings |
|
| Configuration system | ✅ | TOML-based user settings with hot-reload |
|
||||||
| Comprehensive tests | ⏳ | Pipeline and integration testing |
|
| Comprehensive tests | ⏳ | Pipeline and integration testing |
|
||||||
| whisper.cpp support | ⏳ | Local model inference |
|
| whisper.cpp support | ⏳ | Local model inference |
|
||||||
|
|
||||||
**Legend**: ✅ Complete · ⏳ Planned
|
**Legend**: ✅ Complete · ⏳ Planned
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
|
|
||||||
"github.com/leonardotrapani/hyprvoice/internal/bus"
|
"github.com/leonardotrapani/hyprvoice/internal/bus"
|
||||||
"github.com/leonardotrapani/hyprvoice/internal/daemon"
|
"github.com/leonardotrapani/hyprvoice/internal/daemon"
|
||||||
"github.com/leonardotrapani/hyprvoice/internal/notify"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,7 +32,11 @@ func serveCmd() *cobra.Command {
|
|||||||
Use: "serve",
|
Use: "serve",
|
||||||
Short: "Run the daemon",
|
Short: "Run the daemon",
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
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()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ module github.com/leonardotrapani/hyprvoice
|
|||||||
go 1.24.5
|
go 1.24.5
|
||||||
|
|
||||||
require (
|
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/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/sashabaranov/go-openai v1.41.1 // indirect
|
github.com/sashabaranov/go-openai v1.41.1 // indirect
|
||||||
github.com/spf13/cobra v1.9.1 // indirect
|
github.com/spf13/cobra v1.9.1 // indirect
|
||||||
github.com/spf13/pflag v1.0.6 // indirect
|
github.com/spf13/pflag v1.0.6 // indirect
|
||||||
|
golang.org/x/sys v0.13.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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/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 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
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/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 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
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/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=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
+37
-12
@@ -12,13 +12,15 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"github.com/leonardotrapani/hyprvoice/internal/bus"
|
"github.com/leonardotrapani/hyprvoice/internal/bus"
|
||||||
|
"github.com/leonardotrapani/hyprvoice/internal/config"
|
||||||
"github.com/leonardotrapani/hyprvoice/internal/notify"
|
"github.com/leonardotrapani/hyprvoice/internal/notify"
|
||||||
"github.com/leonardotrapani/hyprvoice/internal/pipeline"
|
"github.com/leonardotrapani/hyprvoice/internal/pipeline"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Daemon struct {
|
type Daemon struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
notifier notify.Notifier
|
notifier notify.Notifier
|
||||||
|
configMgr *config.Manager
|
||||||
|
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
@@ -28,18 +30,35 @@ type Daemon struct {
|
|||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(n notify.Notifier) *Daemon {
|
func New() (*Daemon, error) {
|
||||||
if n == nil {
|
configMgr, err := config.NewManager()
|
||||||
|
|
||||||
|
conf := configMgr.GetConfig()
|
||||||
|
|
||||||
|
var n notify.Notifier
|
||||||
|
|
||||||
|
switch conf.Notifications.Type {
|
||||||
|
case "desktop":
|
||||||
n = notify.Desktop{}
|
n = notify.Desktop{}
|
||||||
}
|
case "log":
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
n = notify.Log{}
|
||||||
d := &Daemon{
|
case "none":
|
||||||
notifier: n,
|
n = notify.Nop{}
|
||||||
ctx: ctx,
|
|
||||||
cancel: cancel,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
func (d *Daemon) status() pipeline.Status {
|
||||||
@@ -78,6 +97,11 @@ func (d *Daemon) Run() error {
|
|||||||
}
|
}
|
||||||
defer bus.RemovePidFile()
|
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)
|
sigCh := make(chan os.Signal, 1)
|
||||||
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
|
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
|
||||||
defer signal.Stop(sigCh)
|
defer signal.Stop(sigCh)
|
||||||
@@ -150,7 +174,8 @@ func (d *Daemon) handle(c net.Conn) {
|
|||||||
func (d *Daemon) toggle() {
|
func (d *Daemon) toggle() {
|
||||||
switch d.status() {
|
switch d.status() {
|
||||||
case pipeline.Idle:
|
case pipeline.Idle:
|
||||||
p := pipeline.New()
|
config := d.configMgr.GetConfig()
|
||||||
|
p := pipeline.New(config)
|
||||||
p.Run(d.ctx)
|
p.Run(d.ctx)
|
||||||
|
|
||||||
d.mu.Lock()
|
d.mu.Lock()
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// getClipboard retrieves the current clipboard content using wl-paste
|
|
||||||
func getClipboard(ctx context.Context, timeout time.Duration) (string, error) {
|
func getClipboard(ctx context.Context, timeout time.Duration) (string, error) {
|
||||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -16,15 +15,12 @@ func getClipboard(ctx context.Context, timeout time.Duration) (string, error) {
|
|||||||
cmd := exec.CommandContext(ctx, "wl-paste", "--no-newline")
|
cmd := exec.CommandContext(ctx, "wl-paste", "--no-newline")
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
if err != nil {
|
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 "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(output), nil
|
return string(output), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// setClipboard sets the clipboard content using wl-copy
|
|
||||||
func setClipboard(ctx context.Context, text string, timeout time.Duration) error {
|
func setClipboard(ctx context.Context, text string, timeout time.Duration) error {
|
||||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -39,7 +35,6 @@ func setClipboard(ctx context.Context, text string, timeout time.Duration) error
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkClipboardAvailable checks if wl-clipboard tools are available
|
|
||||||
func checkClipboardAvailable() error {
|
func checkClipboardAvailable() error {
|
||||||
if _, err := exec.LookPath("wl-copy"); err != nil {
|
if _, err := exec.LookPath("wl-copy"); err != nil {
|
||||||
return fmt.Errorf("wl-copy not found: %w (install wl-clipboard)", err)
|
return fmt.Errorf("wl-copy not found: %w (install wl-clipboard)", err)
|
||||||
|
|||||||
@@ -6,59 +6,37 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Injector interface for text injection
|
|
||||||
type Injector interface {
|
type Injector interface {
|
||||||
Inject(ctx context.Context, text string) error
|
Inject(ctx context.Context, text string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config for text injection
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Mode string // "clipboard", "type", "fallback"
|
Mode string // "clipboard", "type", "fallback"
|
||||||
AlwaysCopyClipboard bool // Always copy to clipboard regardless of mode
|
RestoreClipboard bool // Restore original clipboard after injection
|
||||||
RestoreClipboard bool // Restore original clipboard after injection
|
WtypeTimeout time.Duration // Timeout for wtype commands
|
||||||
WtypeTimeout time.Duration // Timeout for wtype commands
|
ClipboardTimeout time.Duration // Timeout for clipboard operations
|
||||||
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 {
|
type injector struct {
|
||||||
config Config
|
config Config
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewInjector creates a new injector with the given config
|
|
||||||
func NewInjector(config Config) Injector {
|
func NewInjector(config Config) Injector {
|
||||||
return &injector{
|
return &injector{
|
||||||
config: config,
|
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 {
|
func (i *injector) Inject(ctx context.Context, text string) error {
|
||||||
if text == "" {
|
if text == "" {
|
||||||
return fmt.Errorf("cannot inject empty 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 originalClipboard string
|
||||||
var err error
|
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 {
|
if err := checkClipboardAvailable(); err != nil {
|
||||||
return fmt.Errorf("clipboard tools not available: %w", err)
|
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
|
// Restore original clipboard if configured and we have it
|
||||||
if i.config.RestoreClipboard && originalClipboard != "" {
|
if i.config.RestoreClipboard && originalClipboard != "" {
|
||||||
// Restore after a short delay to ensure the text has been processed
|
|
||||||
go func() {
|
go func() {
|
||||||
time.Sleep(100 * time.Millisecond)
|
time.Sleep(100 * time.Millisecond)
|
||||||
restoreCtx, cancel := context.WithTimeout(context.Background(), i.config.ClipboardTimeout)
|
restoreCtx, cancel := context.WithTimeout(ctx, i.config.ClipboardTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
setClipboard(restoreCtx, originalClipboard, i.config.ClipboardTimeout)
|
setClipboard(restoreCtx, originalClipboard, i.config.ClipboardTimeout)
|
||||||
}()
|
}()
|
||||||
|
|||||||
@@ -7,12 +7,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// typeText types the given text using wtype
|
|
||||||
func typeText(ctx context.Context, text string, timeout time.Duration) error {
|
func typeText(ctx context.Context, text string, timeout time.Duration) error {
|
||||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// Check if wtype is available
|
|
||||||
if err := checkWtypeAvailable(); err != nil {
|
if err := checkWtypeAvailable(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -26,7 +24,6 @@ func typeText(ctx context.Context, text string, timeout time.Duration) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkWtypeAvailable checks if wtype is available on the system
|
|
||||||
func checkWtypeAvailable() error {
|
func checkWtypeAvailable() error {
|
||||||
if _, err := exec.LookPath("wtype"); err != nil {
|
if _, err := exec.LookPath("wtype"); err != nil {
|
||||||
return fmt.Errorf("wtype not found: %w (install wtype package)", err)
|
return fmt.Errorf("wtype not found: %w (install wtype package)", err)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/leonardotrapani/hyprvoice/internal/config"
|
||||||
"github.com/leonardotrapani/hyprvoice/internal/injection"
|
"github.com/leonardotrapani/hyprvoice/internal/injection"
|
||||||
"github.com/leonardotrapani/hyprvoice/internal/recording"
|
"github.com/leonardotrapani/hyprvoice/internal/recording"
|
||||||
"github.com/leonardotrapani/hyprvoice/internal/transcriber"
|
"github.com/leonardotrapani/hyprvoice/internal/transcriber"
|
||||||
@@ -44,6 +45,7 @@ type pipeline struct {
|
|||||||
status Status
|
status Status
|
||||||
actionCh chan Action
|
actionCh chan Action
|
||||||
errorCh chan PipelineError
|
errorCh chan PipelineError
|
||||||
|
config *config.Config
|
||||||
|
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
@@ -53,10 +55,11 @@ type pipeline struct {
|
|||||||
running atomic.Bool
|
running atomic.Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func New() Pipeline {
|
func New(cfg *config.Config) Pipeline {
|
||||||
return &pipeline{
|
return &pipeline{
|
||||||
actionCh: make(chan Action, 1),
|
actionCh: make(chan Action, 1),
|
||||||
errorCh: make(chan PipelineError, 10),
|
errorCh: make(chan PipelineError, 10),
|
||||||
|
config: cfg,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
func (p *pipeline) Run(ctx context.Context) {
|
func (p *pipeline) Run(ctx context.Context) {
|
||||||
@@ -82,7 +85,7 @@ func (p *pipeline) run(ctx context.Context) {
|
|||||||
log.Printf("Pipeline: Starting recording")
|
log.Printf("Pipeline: Starting recording")
|
||||||
p.setStatus(Recording)
|
p.setStatus(Recording)
|
||||||
|
|
||||||
recorder := recording.NewDefaultRecorder()
|
recorder := recording.NewRecorder(p.config.ToRecordingConfig())
|
||||||
frameCh, rErrCh, err := recorder.Start(ctx)
|
frameCh, rErrCh, err := recorder.Start(ctx)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -93,7 +96,7 @@ func (p *pipeline) run(ctx context.Context) {
|
|||||||
|
|
||||||
defer recorder.Stop()
|
defer recorder.Stop()
|
||||||
|
|
||||||
t, err := transcriber.NewDefaultTranscriber()
|
t, err := transcriber.NewTranscriber(p.config.ToTranscriberConfig())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Pipeline: Failed to create transcriber: %v", err)
|
log.Printf("Pipeline: Failed to create transcriber: %v", err)
|
||||||
p.sendError("Transcription Error", "Failed to create transcriber", 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)
|
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 {
|
if err := injector.Inject(ctx, transcriptionText); err != nil {
|
||||||
p.sendError("Injection Error", "Failed to inject text", err)
|
p.sendError("Injection Error", "Failed to inject text", err)
|
||||||
|
|||||||
@@ -28,17 +28,6 @@ type Config struct {
|
|||||||
ChannelBufferSize int
|
ChannelBufferSize int
|
||||||
}
|
}
|
||||||
|
|
||||||
func DefaultConfig() Config {
|
|
||||||
return Config{
|
|
||||||
SampleRate: 16000,
|
|
||||||
Channels: 1,
|
|
||||||
Format: "s16",
|
|
||||||
BufferSize: 8192,
|
|
||||||
Device: "",
|
|
||||||
ChannelBufferSize: 30,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type Recorder struct {
|
type Recorder struct {
|
||||||
config Config
|
config Config
|
||||||
recording atomic.Bool
|
recording atomic.Bool
|
||||||
@@ -218,8 +207,6 @@ func (r *Recorder) buildPwRecordArgs() []string {
|
|||||||
return args
|
return args
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDefaultRecorder() *Recorder { return NewRecorder(DefaultConfig()) }
|
|
||||||
|
|
||||||
func CheckPipeWireAvailable(ctx context.Context) error {
|
func CheckPipeWireAvailable(ctx context.Context) error {
|
||||||
if _, err := exec.LookPath("pw-record"); err != nil {
|
if _, err := exec.LookPath("pw-record"); err != nil {
|
||||||
return fmt.Errorf("pw-record not found: %w (install pipewire-tools)", err)
|
return fmt.Errorf("pw-record not found: %w (install pipewire-tools)", err)
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package transcriber
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/leonardotrapani/hyprvoice/internal/recording"
|
"github.com/leonardotrapani/hyprvoice/internal/recording"
|
||||||
)
|
)
|
||||||
@@ -28,14 +27,6 @@ type Config struct {
|
|||||||
Model string
|
Model string
|
||||||
}
|
}
|
||||||
|
|
||||||
func DefaultConfig() Config {
|
|
||||||
return Config{
|
|
||||||
Provider: "openai",
|
|
||||||
Language: "it",
|
|
||||||
Model: "whisper-1",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewTranscriber creates a new simple transcriber
|
// NewTranscriber creates a new simple transcriber
|
||||||
func NewTranscriber(config Config) (Transcriber, error) {
|
func NewTranscriber(config Config) (Transcriber, error) {
|
||||||
// Create the appropriate adapter
|
// Create the appropriate adapter
|
||||||
@@ -57,12 +48,3 @@ func NewTranscriber(config Config) (Transcriber, error) {
|
|||||||
|
|
||||||
return transcriber, nil
|
return transcriber, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDefaultTranscriber() (Transcriber, error) {
|
|
||||||
config := DefaultConfig()
|
|
||||||
if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" {
|
|
||||||
config.APIKey = apiKey
|
|
||||||
}
|
|
||||||
|
|
||||||
return NewTranscriber(config)
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user