feat: new readme

This commit is contained in:
leonardotrapani
2026-02-01 22:04:42 +01:00
parent 0b0e084155
commit 305f64a73d
16 changed files with 517 additions and 685 deletions
+4
View File
@@ -22,6 +22,10 @@ This repo is a Go CLI + daemon for voice-powered typing on Wayland/Hyprland.
- IPC: unix socket at ~/.cache/hyprvoice/control.sock, single-character commands - IPC: unix socket at ~/.cache/hyprvoice/control.sock, single-character commands
- Config: ~/.config/hyprvoice/config.toml (hot reloaded by daemon) - Config: ~/.config/hyprvoice/config.toml (hot reloaded by daemon)
## Configuration
- First-time setup: hyprvoice onboarding (guided flow, no advanced settings)
- Full editor: hyprvoice configure (menu-based, includes advanced settings)
## Docs ## Docs
- docs/structure.md: code map and entry points - docs/structure.md: code map and entry points
- docs/architecture.md: deeper architecture + adapters/interfaces - docs/architecture.md: deeper architecture + adapters/interfaces
+159 -472
View File
@@ -1,441 +1,145 @@
# Hyprvoice - Voice-Powered Typing for Hyprland / Wayland # Hyprvoice - Voice-Powered Typing for Wayland/Hyprland
Press a toggle key, speak, and get instant text input. Built natively for Wayland/Hyprland - no X11 hacks or workarounds, just clean integration with modern Linux desktops. 26 voice models, cloud and local, built for Wayland dictation.
## Features Press a toggle key, speak, and get instant text input. Built natively for Wayland/Hyprland with clean PipeWire capture and robust text injection.
- **Toggle workflow**: Press once to start recording, press again to stop and inject text ## Highlights
- **Interactive configuration**: User-friendly TUI wizard - no manual config file editing required
- **LLM post-processing**: Automatically cleans up transcriptions - removes stutters, fixes grammar, adds punctuation (enabled by default)
- **Wayland native**: Purpose-built for Wayland compositors - no legacy X11 dependencies or hacky workarounds
- **Real-time feedback**: Desktop notifications for recording states and transcription status
- **Multiple transcription backends**: OpenAI Whisper, Groq, Mistral Voxtral, ElevenLabs Scribe, and Deepgram Nova
- **Local transcription**: Offline transcription via whisper.cpp - no API keys, no cloud, complete privacy
- **Streaming transcription**: Real-time results with ElevenLabs, Deepgram, and OpenAI Realtime
- **57 language support**: Full multilingual support with language-model compatibility validation
- **Smart text injection**: Clipboard save/restore with direct typing fallback
- **Daemon architecture**: Lightweight control plane with efficient pipeline management
**Status:** Beta - core functionality complete and tested, ready for early adopters - 26 speech-to-text models across cloud and local providers, including whisper.cpp.
- Streaming and batch transcription with 57-language support and model-language validation.
- Optional LLM post-processing plus keywords to preserve names and technical terms.
- Toggle workflow with optional status notifications and cancel support.
- Text injection via ydotool, wtype, and clipboard fallback with clipboard restore.
- Guided onboarding and a full configure menu with hot-reload.
## Installation ## Voice Providers and Models
### From AUR (Arch Linux) - Recommended All supported speech-to-text providers and models:
### OpenAI (cloud)
- `whisper-1` (batch)
- `gpt-4o-transcribe` (batch)
- `gpt-4o-mini-transcribe` (batch)
- `gpt-4o-realtime-preview` (streaming)
### Groq (cloud)
- `whisper-large-v3`
- `whisper-large-v3-turbo`
- `distil-whisper-large-v3-en` (English only)
### Mistral (cloud)
- `voxtral-mini-latest`
- `voxtral-mini-2507`
### ElevenLabs (cloud)
- `scribe_v1` (batch)
- `scribe_v2` (batch)
- `scribe_v1-streaming`
- `scribe_v2-streaming`
### whisper-cpp (local)
- English-only: `tiny.en`, `base.en`, `small.en`, `medium.en`
- Multilingual: `tiny`, `base`, `small`, `medium`, `large-v3`
### Deepgram (cloud)
- `nova-3`
- `nova-3-general`
- `nova-2`
- `nova-2-general`
Language coverage: 57 languages overall; Deepgram models cover a subset; English-only models are labeled above.
## Installation (AUR)
```bash ```bash
# Install hyprvoice and all dependencies automatically
yay -S hyprvoice-bin yay -S hyprvoice-bin
# or # or
paru -S hyprvoice-bin paru -S hyprvoice-bin
``` ```
The AUR package automatically installs all dependencies (`pipewire`, `wl-clipboard`, `wtype`, etc.) and sets up the systemd service. Follow the post-install instructions to complete setup. The package installs system dependencies and the systemd user service.
You'll still need an API key for a cloud provider, or whisper.cpp for local transcription. Onboarding will guide you through the choice.
### Alternative: Download Binary
For non-Arch users or testing:
```bash
# Download and install binary
wget https://github.com/leonardotrapani/hyprvoice/releases/latest/download/hyprvoice-linux-x86_64
mkdir -p ~/.local/bin
mv hyprvoice-linux-x86_64 ~/.local/bin/hyprvoice
chmod +x ~/.local/bin/hyprvoice
# Add to PATH (add to ~/.bashrc or ~/.zshrc)
export PATH="$HOME/.local/bin:$PATH"
# You'll need to manually install dependencies and create systemd service
# See Requirements section above
```
### Build from Source
```bash
git clone https://github.com/leonardotrapani/hyprvoice.git
cd hyprvoice
go mod download
go build -o hyprvoice ./cmd/hyprvoice
# Install locally
mkdir -p ~/.local/bin
cp hyprvoice ~/.local/bin/
export PATH="$HOME/.local/bin:$PATH"
```
## Requirements
- **Wayland desktop** (Hyprland, Niri, GNOME, KDE, etc.)
- **PipeWire audio system** with tools
- **API key for transcription**: OpenAI, Groq, Mistral, ElevenLabs, or Deepgram API key (check each provider's pricing), OR whisper.cpp for local transcription (no API key required)
**System packages** (automatically installed with AUR package):
- `pipewire`, `pipewire-pulse`, `pipewire-audio` - Audio capture
- `wl-clipboard` - Clipboard integration
- `wtype` - Text typing (Wayland)
- `ydotool` - Text typing (universal, recommended for Chromium apps)
- `libnotify` - Desktop notifications
- `systemd` - User service management
For manual installation on other distros:
```bash
# Ubuntu/Debian
sudo apt install pipewire-pulse pipewire-bin wl-clipboard wtype ydotool libnotify-bin
# Fedora
sudo dnf install pipewire-utils wl-clipboard wtype ydotool libnotify
# For ydotool, you also need to start the daemon:
systemctl --user enable --now ydotool
# Or add user to input group for uinput access:
sudo usermod -aG input $USER
```
## Quick Start ## Quick Start
After installing via AUR: 1. Run onboarding:
1. **Configure hyprvoice interactively:**
```bash ```bash
hyprvoice configure hyprvoice onboarding
``` ```
This wizard will guide you through setting up your transcription provider, API key, audio preferences, and other settings. 2. Enable and start the service:
2. **Enable and start the service:**
```bash ```bash
systemctl --user enable --now hyprvoice.service systemctl --user enable --now hyprvoice.service
``` ```
3. **Add keybinding to your window manager:** 3. Add a keybinding (Hyprland example):
```bash ```bash
# For Hyprland, add to ~/.config/hypr/hyprland.conf
bind = SUPER, R, exec, hyprvoice toggle bind = SUPER, R, exec, hyprvoice toggle
``` ```
4. **Test voice input:** 4. Test voice input:
```bash ```bash
# Check daemon status
hyprvoice status
# Toggle recording (or use your keybind)
hyprvoice toggle hyprvoice toggle
# Speak something... ```
hyprvoice toggle # Stop and transcribe
Run `hyprvoice configure` anytime for advanced settings.
## Commands
### Core CLI
```bash
hyprvoice onboarding
hyprvoice configure
hyprvoice serve
hyprvoice toggle
hyprvoice cancel
hyprvoice status
hyprvoice version
hyprvoice stop
```
### Model management (whisper-cpp)
```bash
hyprvoice model list
hyprvoice model list --provider whisper-cpp
hyprvoice model download base.en
hyprvoice model remove base.en
```
### Service management
```bash
systemctl --user status hyprvoice.service
systemctl --user restart hyprvoice.service
journalctl --user -u hyprvoice.service -f
``` ```
## Configuration ## Configuration
The recommended way to configure hyprvoice is through the interactive wizard: Configuration lives in `~/.config/hyprvoice/config.toml` and hot-reloads automatically.
```bash - First-time setup: `hyprvoice onboarding`
hyprvoice configure - Full TUI editor: `hyprvoice configure`
```
The wizard guides you through all settings with a user-friendly interface: ## Docs
- **Providers** - API keys for OpenAI, Groq, Mistral, ElevenLabs, Deepgram - `docs/config.md` - configuration reference and examples
- **Transcription** - Speech-to-text provider, model, and language selection (cloud or local) - `docs/providers.md` - provider and model details
- **LLM** - Post-processing to clean up transcriptions (enabled by default) - `docs/architecture.md` - architecture and adapter overview
- **Keywords** - Domain-specific terms for better accuracy - `docs/structure.md` - code map and entry points
- **Injection** - How text is typed (ydotool, wtype, clipboard)
- **Notifications** - Desktop notification preferences
- **Advanced Settings** - Recording parameters, timeouts
Configuration is stored in `~/.config/hyprvoice/config.toml`. Changes are applied immediately without restarting the daemon.
For manual configuration and detailed options, see [docs/config.md](docs/config.md).
## Quick Reference
### Common Commands
```bash
# Interactive configuration wizard
hyprvoice configure
# Start the daemon
hyprvoice serve
# Toggle recording on/off
hyprvoice toggle
# Cancel current operation
hyprvoice cancel
# Check current status
hyprvoice status
# Get protocol version
hyprvoice version
# Stop the daemon (if not using systemd service)
hyprvoice stop
```
### Model Management (Local Transcription)
```bash
# List all available models
hyprvoice model list
# List only transcription models
hyprvoice model list --type transcription
# List models for a specific provider
hyprvoice model list --provider whisper-cpp
# Download a local model
hyprvoice model download base.en
# Remove a downloaded model
hyprvoice model remove base.en
```
### Keybinding Pattern
Most setups use this toggle pattern in window manager config:
```bash
bind = SUPER, R, exec, hyprvoice toggle
bind = SUPER SHIFT, R, exec, hyprvoice cancel # Optional: cancel current operation
```
## Keyboard Shortcuts Setup
### Hyprland
Add to your `~/.config/hypr/hyprland.conf`:
```bash
# Hyprvoice - Voice to Text (toggle recording)
bind = SUPER, R, exec, hyprvoice toggle
# Optional: Cancel current operation
bind = SUPER SHIFT, C, exec, hyprvoice cancel
# Optional: Status check
bind = SUPER SHIFT, R, exec, hyprvoice status && notify-send "Hyprvoice" "$(hyprvoice status)"
```
## Usage Examples
### Basic Toggle Workflow
1. **Press keybind** → Recording starts (notification appears)
2. **Speak your text** → Audio captured in real-time
3. **Press keybind again** → Recording stops, transcription begins
4. **Text appears** → Injected at cursor position or clipboard
**Cancel anytime:** Press your cancel keybind (e.g., `SUPER+SHIFT+C`) to abort the current operation and return to idle.
### CLI Usage
```bash
# Start daemon manually (if not using systemd service)
hyprvoice serve
# In another terminal: toggle recording
hyprvoice toggle
# ... speak ...
hyprvoice toggle
# Check what's happening
hyprvoice status
```
## Local Transcription
For complete offline privacy, use whisper.cpp for local transcription - no API keys, no cloud, no data leaves your machine.
### Prerequisites
1. **Install whisper.cpp**: Build from source or install via package manager
```bash
# Arch Linux
yay -S whisper.cpp
# Build from source (recommended for CUDA/Metal support)
git clone https://github.com/ggerganov/whisper.cpp
cd whisper.cpp && make
sudo cp main /usr/local/bin/whisper-cli
```
2. **Download a model**:
```bash
# List available models
hyprvoice model list --provider whisper-cpp
# Download recommended model (142MB, English-only, fast)
hyprvoice model download base.en
# Or download multilingual model (142MB, 57 languages)
hyprvoice model download base
```
### Available Models
| Model | Size | Languages | Speed | Accuracy |
| ---------- | ----- | ----------- | -------- | -------- |
| tiny.en | 75MB | English | Fastest | Good |
| base.en | 142MB | English | Fast | Better |
| small.en | 466MB | English | Medium | Great |
| medium.en | 1.5GB | English | Slow | Excellent|
| tiny | 75MB | 57 langs | Fastest | Good |
| base | 142MB | 57 langs | Fast | Better |
| small | 466MB | 57 langs | Medium | Great |
| medium | 1.5GB | 57 langs | Slow | Excellent|
| large-v3 | 3GB | 57 langs | Slowest | Best |
**Recommendation**: Start with `base.en` for English or `base` for multilingual. Models ending in `.en` are English-only but slightly faster.
Run `hyprvoice configure` to set up local transcription, or see [docs/config.md](docs/config.md) for manual configuration.
## Streaming Transcription
For real-time transcription results as you speak, use streaming providers. Text appears progressively instead of waiting for the entire recording to finish.
### Streaming Providers
| Provider | Models | Latency | Languages |
| ---------- | -------------------------- | ---------- | --------- |
| ElevenLabs | scribe_v1-streaming, scribe_v2-streaming | ~150ms | 57 langs |
| Deepgram | nova-3, nova-2 | ~100ms | 40+ langs |
| OpenAI | gpt-4o-realtime-preview | ~200ms | 57 langs |
Streaming models show partial results while recording. Final text is accumulated and injected when you toggle off.
Run `hyprvoice configure` to set up streaming, or see [docs/config.md](docs/config.md) for manual configuration.
### Service Management
The systemd user service is automatically installed with the AUR package:
```bash
# Check service status
systemctl --user status hyprvoice.service
# Start/stop service
systemctl --user start hyprvoice.service
systemctl --user stop hyprvoice.service
# Enable/disable autostart
systemctl --user enable hyprvoice.service
systemctl --user disable hyprvoice.service
# View logs
journalctl --user -u hyprvoice.service -f
```
### File Locations
- **Socket**: `~/.cache/hyprvoice/control.sock` - IPC communication
- **PID file**: `~/.cache/hyprvoice/hyprvoice.pid` - Process tracking
- **Config**: `~/.config/hyprvoice/config.toml` - User settings
- **Models**: `~/.local/share/hyprvoice/models/whisper/` - Downloaded whisper models
## 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 + Realtime streaming |
| Groq transcription | ✅ | Fast Whisper API with transcription and translation |
| Mistral transcription | ✅ | Voxtral API for European languages |
| ElevenLabs transcription | ✅ | Scribe batch + streaming (90+ languages) |
| Deepgram transcription | ✅ | Nova-3 streaming (40+ languages) |
| Local transcription | ✅ | whisper.cpp with model download management |
| Streaming support | ✅ | Real-time results with ElevenLabs, Deepgram, OpenAI |
| Model management | ✅ | `hyprvoice model list/download/remove` CLI |
| Language validation | ✅ | Model-language compatibility checking |
| LLM post-processing | ✅ | OpenAI/Groq text cleanup (enabled by default) |
| Text injection | ✅ | Clipboard + wtype/ydotool with fallback |
| Configuration system | ✅ | TOML-based user settings with hot-reload |
| Interactive TUI setup | ✅ | `hyprvoice configure` wizard with section editing |
| Unit test coverage | ✅ | Comprehensive test suite (100% pass) |
| CI/CD Pipeline | ✅ | Automated builds and releases via GitHub Actions |
| Installation (AUR etc) | ✅ | AUR package with automated dependency installation |
**Legend**: ✅ Complete · ⏳ Planned
## Architecture Overview
Hyprvoice uses a **daemon + pipeline** architecture for efficient resource management:
- **Control Daemon**: Lightweight IPC server managing lifecycle
- **Pipeline**: Stateful audio processing (recording → transcribing → processing → injecting)
- **State Machine**: `idle → recording → transcribing → processing → injecting → idle`
### System Architecture
```mermaid
flowchart LR
subgraph Client
CLI["CLI/Tool"]
end
subgraph Daemon
D["Control Daemon (lifecycle + IPC)"]
end
subgraph Pipeline
A["Audio Capture"]
T["Transcribing"]
I["Injecting (wtype + clipboard)"]
end
N["notify-send/log"]
CLI -- unix socket --> D
D -- start/stop --> A
A -- frames --> T
T -- status --> D
D -- events --> N
D -- inject action --> T
T --> I
I -->|done| D
```
```mermaid
stateDiagram-v2
[*] --> idle
idle --> recording: toggle
recording --> transcribing: first_frame
transcribing --> processing: llm_enabled
transcribing --> injecting: llm_disabled
processing --> injecting: inject_action
injecting --> idle: done
recording --> idle: abort
injecting --> idle: abort
```
### How It Works
1. **Toggle recording** → Pipeline starts, audio capture begins
2. **Audio streaming** → PipeWire frames buffered for transcription
3. **Toggle stop** → Recording ends, transcription starts
4. **LLM processing** → Text cleaned up (if enabled, which is the default)
5. **Text injection** → Result typed or copied to clipboard
6. **Return to idle** → Pipeline cleaned up, ready for next session
### Data Flow
1. `toggle` (daemon) → create pipeline → recording
2. First frame arrives → transcribing (daemon may notify `Transcribing` later)
3. Audio frames → audio buffer (collect all audio during session)
4. Second `toggle` during transcribing → transcribe collected audio
5. If LLM enabled → processing → clean up text with LLM
6. injecting → type or paste text
7. Complete → idle; pipeline stops; daemon clears reference
8. Notifications at key transitions
## Troubleshooting ## Troubleshooting
@@ -551,90 +255,73 @@ hyprvoice toggle
hyprvoice status hyprvoice status
``` ```
## Development ## Architecture Overview
### Building from Source Hyprvoice uses a **daemon + pipeline** architecture for efficient resource management:
```bash - **Control Daemon**: Lightweight IPC server managing lifecycle
git clone https://github.com/leonardotrapani/hyprvoice.git - **Pipeline**: Stateful audio processing (recording → transcribing → processing → injecting)
cd hyprvoice - **State Machine**: `idle → recording → transcribing → processing → injecting → idle`
go mod download
go build -o hyprvoice ./cmd/hyprvoice
# Install locally ### System Architecture
mkdir -p ~/.local/bin
cp hyprvoice ~/.local/bin/ ```mermaid
export PATH="$HOME/.local/bin:$PATH" flowchart LR
subgraph Client
CLI["CLI/Tool"]
end
subgraph Daemon
D["Control Daemon (lifecycle + IPC)"]
end
subgraph Pipeline
A["Audio Capture"]
T["Transcribing"]
I["Injecting (wtype + clipboard)"]
end
N["notify-send/log"]
CLI -- unix socket --> D
D -- start/stop --> A
A -- frames --> T
T -- status --> D
D -- events --> N
D -- inject action --> T
T --> I
I -->|done| D
``` ```
## For Maintainers ```mermaid
stateDiagram-v2
### Publishing to AUR [*] --> idle
idle --> recording: toggle
See [`packaging/RELEASE.md`](packaging/RELEASE.md) for complete release process including AUR deployment. recording --> transcribing: first_frame
transcribing --> processing: llm_enabled
Quick start for AUR: transcribing --> injecting: llm_disabled
processing --> injecting: inject_action
```bash injecting --> idle: done
# After creating your first GitHub release recording --> idle: abort
cd packaging/ injecting --> idle: abort
./setup-aur.sh # One-time AUR repository setup
``` ```
### Project Structure ### How It Works
``` 1. **Toggle recording** → Pipeline starts, audio capture begins
hyprvoice/ 2. **Audio streaming** → PipeWire frames buffered for transcription
├── cmd/hyprvoice/ # CLI application entry point 3. **Toggle stop** → Recording ends, transcription starts
├── internal/ 4. **LLM processing** → Text cleaned up (if enabled)
│ ├── bus/ # IPC (Unix socket) + PID management 5. **Text injection** → Result typed or copied to clipboard
│ ├── config/ # Configuration loading and validation 6. **Return to idle** → Pipeline cleaned up, ready for next session
│ ├── daemon/ # Control daemon (lifecycle management)
│ ├── deps/ # Dependency checking (whisper-cli, ffmpeg)
│ ├── injection/ # Text injection (clipboard + wtype + ydotool)
│ ├── language/ # Language codes and provider-specific mappings
│ ├── llm/ # LLM post-processing adapters (OpenAI, Groq)
│ ├── models/whisper/ # Whisper model info and download management
│ ├── notify/ # Desktop notification integration
│ ├── pipeline/ # Audio processing pipeline + state machine
│ ├── provider/ # Provider registry with Model metadata
│ ├── recording/ # PipeWire audio capture
│ ├── transcriber/ # Batch and streaming adapters (OpenAI, Groq, Mistral, ElevenLabs, Deepgram, whisper-cpp)
│ └── tui/ # Interactive configuration wizard
├── go.mod # Go module definition
└── README.md
```
### Development Workflow ### Data Flow
```bash 1. `toggle` (daemon) → create pipeline → recording
# Terminal 1: Run daemon with logs 2. First frame arrives → transcribing (daemon may notify `Transcribing` later)
go run ./cmd/hyprvoice serve 3. Audio frames → audio buffer (collect all audio during session)
4. Second `toggle` during transcribing → transcribe collected audio
# Terminal 2: Test commands 5. If LLM enabled → processing → clean up text with LLM
go run ./cmd/hyprvoice toggle 6. injecting → type or paste text
go run ./cmd/hyprvoice status 7. Complete → idle; pipeline stops; daemon clears reference
go run ./cmd/hyprvoice stop 8. Notifications at key transitions
```
### IPC Protocol
Simple single-character commands over Unix socket:
- `t` - Toggle recording on/off
- `c` - Cancel current operation
- `s` - Get current status
- `v` - Get protocol version
- `q` - Quit daemon gracefully
## Contributing
Contributions welcome! Please:
- Follow existing code conventions and patterns
- Add tests for new functionality when available
- Update documentation for user-facing changes
- Test on Hyprland/Wayland before submitting PRs
## License ## License
+38 -8
View File
@@ -2,8 +2,10 @@ package main
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"os/exec" "os/exec"
"path/filepath"
"sort" "sort"
"strings" "strings"
@@ -33,6 +35,7 @@ func init() {
statusCmd(), statusCmd(),
versionCmd(), versionCmd(),
stopCmd(), stopCmd(),
onboardingCmd(),
configureCmd(), configureCmd(),
modelCmd(), modelCmd(),
) )
@@ -128,8 +131,6 @@ func cancelCmd() *cobra.Command {
} }
func configureCmd() *cobra.Command { func configureCmd() *cobra.Command {
var onboarding bool
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "configure", Use: "configure",
Short: "Interactive configuration setup", Short: "Interactive configuration setup",
@@ -138,23 +139,47 @@ This will guide you through setting up:
- Provider API keys (OpenAI, Groq, Mistral, ElevenLabs) - Provider API keys (OpenAI, Groq, Mistral, ElevenLabs)
- Transcription settings - Transcription settings
- LLM post-processing - LLM post-processing
- Text injection and notification preferences`, - Text injection and notification preferences
For first-time setup, run 'hyprvoice onboarding'.`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
return runConfigure(onboarding) return runConfigure(false)
}, },
} }
cmd.Flags().BoolVar(&onboarding, "onboarding", false, "Run the guided onboarding wizard")
return cmd return cmd
} }
func onboardingCmd() *cobra.Command {
return &cobra.Command{
Use: "onboarding",
Short: "Guided first-time setup",
Long: `Guided onboarding wizard for hyprvoice.
This will walk you through the full setup flow (excluding advanced options).`,
RunE: func(cmd *cobra.Command, args []string) error {
return runConfigure(true)
},
}
}
func runConfigure(onboarding bool) error { func runConfigure(onboarding bool) error {
// Load existing config or create default var cfg *config.Config
cfg, err := config.Load() var err error
if onboarding {
cfg, err = config.Load()
if err != nil {
if errors.Is(err, config.ErrConfigNotFound) {
cfg = config.DefaultConfig()
} else {
return fmt.Errorf("failed to load config: %w", err)
}
}
} else {
cfg, err = config.Load()
if err != nil { if err != nil {
return fmt.Errorf("failed to load config: %w", err) return fmt.Errorf("failed to load config: %w", err)
} }
}
// Run TUI wizard // Run TUI wizard
result, err := tui.Run(cfg, onboarding) result, err := tui.Run(cfg, onboarding)
@@ -224,6 +249,11 @@ func showNextSteps(cfg *config.Config, onboarding bool) {
fmt.Println() fmt.Println()
configPath, _ := config.GetConfigPath() configPath, _ := config.GetConfigPath()
if onboarding {
configDir := filepath.Dir(configPath)
fmt.Printf("run hyprvoice configure to configure more, or check %s\n", configDir)
return
}
fmt.Printf("Config file location: %s\n", configPath) fmt.Printf("Config file location: %s\n", configPath)
} }
+11
View File
@@ -2,12 +2,23 @@
This document covers manual configuration of hyprvoice via the `config.toml` file. For most users, the interactive wizard is recommended: This document covers manual configuration of hyprvoice via the `config.toml` file. For most users, the interactive wizard is recommended:
```bash
hyprvoice onboarding
```
To adjust settings later:
```bash ```bash
hyprvoice configure hyprvoice configure
``` ```
Configuration is stored in `~/.config/hyprvoice/config.toml` and changes are applied immediately without restarting the daemon. Configuration is stored in `~/.config/hyprvoice/config.toml` and changes are applied immediately without restarting the daemon.
## Onboarding vs Configure
- `hyprvoice onboarding`: guided first-time setup for provider keys, voice model, language/streaming, LLM post-processing, keywords, and notifications. Advanced settings stay at defaults.
- `hyprvoice configure`: full TUI menu for all sections, including advanced recording, injection backends, timeouts, and notification messages.
## Table of Contents ## Table of Contents
- [Unified Provider System](#unified-provider-system) - [Unified Provider System](#unified-provider-system)
+13 -19
View File
@@ -1,6 +1,7 @@
package config package config
import ( import (
"errors"
"os" "os"
"path/filepath" "path/filepath"
"runtime" "runtime"
@@ -261,41 +262,34 @@ func TestConfig_Validate(t *testing.T) {
} }
func TestConfig_Load(t *testing.T) { func TestConfig_Load(t *testing.T) {
// Test that Load creates default config when none exists // Test that Load errors when no config exists
t.Run("creates default config when none exists", func(t *testing.T) { t.Run("errors when config missing", func(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
originalConfigDir := os.Getenv("XDG_CONFIG_HOME") originalConfigDir := os.Getenv("XDG_CONFIG_HOME")
originalAPIKey := os.Getenv("OPENAI_API_KEY")
os.Setenv("XDG_CONFIG_HOME", tempDir) os.Setenv("XDG_CONFIG_HOME", tempDir)
os.Setenv("OPENAI_API_KEY", "test-api-key") // Set test API key for validation
defer func() { defer func() {
if originalConfigDir == "" { if originalConfigDir == "" {
os.Unsetenv("XDG_CONFIG_HOME") os.Unsetenv("XDG_CONFIG_HOME")
} else { } else {
os.Setenv("XDG_CONFIG_HOME", originalConfigDir) os.Setenv("XDG_CONFIG_HOME", originalConfigDir)
} }
if originalAPIKey == "" {
os.Unsetenv("OPENAI_API_KEY")
} else {
os.Setenv("OPENAI_API_KEY", originalAPIKey)
}
}() }()
config, err := Load() _, err := Load()
if err != nil { if err == nil {
t.Errorf("Load() error = %v", err) t.Errorf("Load() expected error when config is missing")
return return
} }
if !errors.Is(err, ErrConfigNotFound) {
// Verify the loaded config is valid t.Errorf("Load() error = %v, expected ErrConfigNotFound", err)
if err := config.Validate(); err != nil { }
t.Errorf("Loaded config is invalid: %v", err) if !strings.Contains(err.Error(), "hyprvoice onboarding") {
t.Errorf("Load() error should mention onboarding: %v", err)
} }
// Verify config file was created
configPath := filepath.Join(tempDir, "hyprvoice", "config.toml") configPath := filepath.Join(tempDir, "hyprvoice", "config.toml")
if _, err := os.Stat(configPath); os.IsNotExist(err) { if _, statErr := os.Stat(configPath); !os.IsNotExist(statErr) {
t.Errorf("Load() did not create config file") t.Errorf("Load() should not create config file when missing")
} }
}) })
+38
View File
@@ -0,0 +1,38 @@
package config
import "time"
// DefaultConfig returns the initial configuration used for onboarding.
func DefaultConfig() *Config {
return &Config{
Recording: RecordingConfig{
SampleRate: 16000,
Channels: 1,
Format: "s16",
BufferSize: 8192,
Device: "",
ChannelBufferSize: 30,
Timeout: 5 * time.Minute,
},
Transcription: TranscriptionConfig{
Language: "",
Streaming: false,
Threads: 0,
},
Injection: InjectionConfig{
Backends: []string{"ydotool", "wtype", "clipboard"},
YdotoolTimeout: 5 * time.Second,
WtypeTimeout: 5 * time.Second,
ClipboardTimeout: 3 * time.Second,
},
Notifications: NotificationsConfig{
Enabled: false,
Type: "",
},
Providers: make(map[string]ProviderConfig),
Keywords: nil,
LLM: LLMConfig{
Enabled: false,
},
}
}
+11 -6
View File
@@ -1,6 +1,7 @@
package config package config
import ( import (
"errors"
"fmt" "fmt"
"log" "log"
"os" "os"
@@ -11,6 +12,8 @@ import (
"github.com/BurntSushi/toml" "github.com/BurntSushi/toml"
) )
var ErrConfigNotFound = errors.New("config not found")
func GetConfigPath() (string, error) { func GetConfigPath() (string, error) {
configDir, err := os.UserConfigDir() configDir, err := os.UserConfigDir()
if err != nil { if err != nil {
@@ -47,12 +50,9 @@ func Load() (*Config, error) {
} }
if _, err := os.Stat(configPath); os.IsNotExist(err) { if _, err := os.Stat(configPath); os.IsNotExist(err) {
log.Printf("Config: no config file found at %s, creating with defaults", configPath) return nil, fmt.Errorf("%w: run hyprvoice onboarding", ErrConfigNotFound)
if err := SaveDefaultConfig(); err != nil { } else if err != nil {
return nil, fmt.Errorf("failed to create default config: %w", err) return nil, fmt.Errorf("failed to stat config file %s: %w", configPath, err)
}
log.Printf("Config: default configuration created successfully")
return Load()
} }
log.Printf("Config: loading configuration from %s", configPath) log.Printf("Config: loading configuration from %s", configPath)
@@ -76,6 +76,11 @@ func Load() (*Config, error) {
config.Providers = make(map[string]ProviderConfig) config.Providers = make(map[string]ProviderConfig)
} }
if config.Transcription.Provider == "groq-translation" {
log.Printf("Config: deprecated transcription.provider 'groq-translation' detected - using 'groq-transcription' instead")
config.Transcription.Provider = "groq-transcription"
}
config.applyLLMDefaults() config.applyLLMDefaults()
config.applyThreadsDefault() config.applyThreadsDefault()
+1 -1
View File
@@ -23,7 +23,7 @@ func Save(cfg *Config) error {
// Header // Header
sb.WriteString(`# Hyprvoice Configuration sb.WriteString(`# Hyprvoice Configuration
# Generated by hyprvoice configure # Generated by hyprvoice onboarding or configure
# Changes are applied immediately without daemon restart. # Changes are applied immediately without daemon restart.
`) `)
+14 -14
View File
@@ -24,14 +24,14 @@ func TestGetTranscriptionModelOptions_ShowsCapabilities(t *testing.T) {
} }
if model.SupportsStreaming && !model.SupportsBatch { if model.SupportsStreaming && !model.SupportsBatch {
// streaming-only should have [streaming] tag // streaming-only should mention streaming
if !strings.Contains(opt.Label, "[streaming]") { if !strings.Contains(opt.Desc, "streaming") {
t.Errorf("streaming-only model %s should have [streaming] tag in label: %s", opt.ID, opt.Label) t.Errorf("streaming-only model %s should mention streaming in desc: %s", opt.ID, opt.Desc)
} }
} else if model.SupportsBothModes() { } else if model.SupportsBothModes() {
// both modes should have [batch+streaming] tag // both modes should mention batch+streaming
if !strings.Contains(opt.Label, "[batch+streaming]") { if !strings.Contains(opt.Desc, "batch+streaming") {
t.Errorf("both-modes model %s should have [batch+streaming] tag in label: %s", opt.ID, opt.Label) t.Errorf("both-modes model %s should mention batch+streaming in desc: %s", opt.ID, opt.Desc)
} }
} }
// batch-only models don't need a tag // batch-only models don't need a tag
@@ -44,7 +44,7 @@ func TestGetTranscriptionModelOptions_NoHeadersAnymore(t *testing.T) {
for _, opt := range options { for _, opt := range options {
if opt.ID == "" { if opt.ID == "" {
t.Errorf("should not have headers anymore, got: %s", opt.Label) t.Errorf("should not have headers anymore, got empty id")
} }
} }
} }
@@ -57,11 +57,11 @@ func TestGetTranscriptionModelOptions_OpenAI_ShowsCapabilities(t *testing.T) {
t.Errorf("expected 3 options for openai, got %d", len(options)) t.Errorf("expected 3 options for openai, got %d", len(options))
} }
// gpt-4o-transcribe and gpt-4o-mini-transcribe should have [batch+streaming] // gpt-4o-transcribe and gpt-4o-mini-transcribe should mention batch+streaming
for _, opt := range options { for _, opt := range options {
if strings.Contains(opt.ID, "gpt-4o") { if strings.Contains(opt.ID, "gpt-4o") {
if !strings.Contains(opt.Label, "[batch+streaming]") { if !strings.Contains(opt.Desc, "batch+streaming") {
t.Errorf("gpt-4o model %s should have [batch+streaming] tag: %s", opt.ID, opt.Label) t.Errorf("gpt-4o model %s should mention batch+streaming: %s", opt.ID, opt.Desc)
} }
} }
} }
@@ -76,8 +76,8 @@ func TestGetTranscriptionModelOptions_Deepgram_ShowsBothModes(t *testing.T) {
} }
for _, opt := range options { for _, opt := range options {
if !strings.Contains(opt.Label, "[batch+streaming]") { if !strings.Contains(opt.Desc, "batch+streaming") {
t.Errorf("deepgram model %s should have [batch+streaming] tag: %s", opt.ID, opt.Label) t.Errorf("deepgram model %s should mention batch+streaming: %s", opt.ID, opt.Desc)
} }
} }
} }
@@ -93,8 +93,8 @@ func TestGetTranscriptionModelOptions_Groq_BatchOnly(t *testing.T) {
// batch-only models should not have any mode tags // batch-only models should not have any mode tags
for _, opt := range options { for _, opt := range options {
if strings.Contains(opt.Label, "[streaming]") || strings.Contains(opt.Label, "[batch]") { if strings.Contains(opt.Desc, "streaming") {
t.Errorf("batch-only model should not have mode tags: %s", opt.Label) t.Errorf("batch-only model should not mention streaming: %s", opt.Desc)
} }
} }
} }
+159 -116
View File
@@ -21,7 +21,6 @@ const (
menuVoiceModel = "voice_model" menuVoiceModel = "voice_model"
menuLLM = "llm" menuLLM = "llm"
menuKeywords = "keywords" menuKeywords = "keywords"
menuInjection = "injection"
menuNotifications = "notifications" menuNotifications = "notifications"
menuAdvanced = "advanced" menuAdvanced = "advanced"
menuSave = "save" menuSave = "save"
@@ -29,12 +28,10 @@ const (
) )
func newWelcomeScreen(state *wizardState) screen { func newWelcomeScreen(state *wizardState) screen {
desc := []string{ desc := append([]string{}, LogoLines()...)
"Voice-powered typing for Wayland/Hyprland.", desc = append(desc, "", "Voice-powered typing for Wayland/Hyprland.", "Let's set up your configuration.")
"Let's set up your configuration.", s := newInfoScreen(state, "Hyprvoice Onboarding", desc, func() screen {
} return onboardingVoiceProviderScreen(state)
s := newInfoScreen(state, "Hyprvoice Configure", desc, func() screen {
return onboardingProvidersScreen(state)
}, func() screen { }, func() screen {
state.cancelled = true state.cancelled = true
state.result = &ConfigureResult{Cancelled: true} state.result = &ConfigureResult{Cancelled: true}
@@ -44,17 +41,9 @@ func newWelcomeScreen(state *wizardState) screen {
return s return s
} }
func onboardingProvidersScreen(state *wizardState) screen {
return newProvidersScreen(state,
func() screen { return newWelcomeScreen(state) },
func() screen { return onboardingVoiceProviderScreen(state) },
true,
)
}
func onboardingVoiceProviderScreen(state *wizardState) screen { func onboardingVoiceProviderScreen(state *wizardState) screen {
return newVoiceProviderScreen(state, return newVoiceProviderScreen(state,
func() screen { return onboardingProvidersScreen(state) }, func() screen { return newWelcomeScreen(state) },
func() screen { return onboardingLLMScreen(state) }, func() screen { return onboardingLLMScreen(state) },
) )
} }
@@ -66,20 +55,26 @@ func onboardingLLMScreen(state *wizardState) screen {
) )
} }
func onboardingSummaryScreen(state *wizardState, onBack func() screen) screen {
return newSummaryScreen(state, func() screen {
return newNotificationsScreen(state, onBack)
})
}
func newMenuScreen(state *wizardState) screen { func newMenuScreen(state *wizardState) screen {
items := []optionItem{ items := []optionItem{
{title: formatProvidersLabel(state.cfg), value: menuProviders}, {title: formatProvidersLabel(state.cfg), desc: "Manage API keys for cloud providers.", value: menuProviders},
{title: formatVoiceModelLabel(state.cfg), value: menuVoiceModel}, {title: formatVoiceModelLabel(state.cfg), desc: "Pick the transcription provider, model, and language.", value: menuVoiceModel},
{title: formatLLMLabel(state.cfg), value: menuLLM}, {title: formatLLMLabel(state.cfg), desc: "Configure post-processing and custom prompts.", value: menuLLM},
{title: formatKeywordsLabel(state.cfg), value: menuKeywords}, {title: formatKeywordsLabel(state.cfg), desc: "Words to preserve spelling and phrasing.", value: menuKeywords},
{title: formatInjectionLabel(state.cfg), value: menuInjection}, {title: formatNotificationsLabel(state.cfg), desc: "Notification type and message text.", value: menuNotifications},
{title: formatNotificationsLabel(state.cfg), value: menuNotifications}, {title: "Advanced Settings", desc: "Recording, injection, and timeout settings.", value: menuAdvanced},
{title: "Advanced Settings", value: menuAdvanced}, {title: "Save & Exit", desc: "Write config changes to disk.", value: menuSave},
{title: "Save & Exit", value: menuSave}, {title: "Discard & Exit", desc: "Exit without saving changes.", value: menuDiscard},
{title: "Discard & Exit", value: menuDiscard},
} }
desc := []string{"Select a section to update."} desc := append([]string{}, LogoLines()...)
desc = append(desc, "", "Select a section to update.")
screen := newListScreen(state, "Configuration Menu", desc, items, func(item optionItem) screen { screen := newListScreen(state, "Configuration Menu", desc, items, func(item optionItem) screen {
switch item.value { switch item.value {
case menuProviders: case menuProviders:
@@ -90,8 +85,6 @@ func newMenuScreen(state *wizardState) screen {
return newLLMEnableScreen(state, func() screen { return newMenuScreen(state) }, func() screen { return newMenuScreen(state) }) return newLLMEnableScreen(state, func() screen { return newMenuScreen(state) }, func() screen { return newMenuScreen(state) })
case menuKeywords: case menuKeywords:
return newKeywordsScreen(state, func() screen { return newMenuScreen(state) }) return newKeywordsScreen(state, func() screen { return newMenuScreen(state) })
case menuInjection:
return newInjectionScreen(state, func() screen { return newMenuScreen(state) })
case menuNotifications: case menuNotifications:
return newNotificationsScreen(state, func() screen { return newMenuScreen(state) }) return newNotificationsScreen(state, func() screen { return newMenuScreen(state) })
case menuAdvanced: case menuAdvanced:
@@ -117,18 +110,24 @@ func newMenuScreen(state *wizardState) screen {
func newProvidersScreen(state *wizardState, onBack func() screen, onNext func() screen, onboarding bool) screen { func newProvidersScreen(state *wizardState, onBack func() screen, onNext func() screen, onboarding bool) screen {
items := make([]optionItem, 0, len(AllProviders)+1) items := make([]optionItem, 0, len(AllProviders)+1)
for _, name := range AllProviders { for _, name := range AllProviders {
items = append(items, optionItem{title: formatProviderOption(state.cfg, name), value: name}) items = append(items, optionItem{
title: formatProviderOption(state.cfg, name),
desc: formatProviderOptionDesc(state.cfg, name),
value: name,
})
} }
exitLabel := "Done" exitLabel := "Done"
exitDesc := "Return to menu."
if onboarding { if onboarding {
exitLabel = "Next" exitLabel = "Next"
exitDesc = "Continue to voice model setup."
} }
items = append(items, optionItem{title: exitLabel, value: "back"}) items = append(items, optionItem{title: exitLabel, desc: exitDesc, value: "back"})
desc := []string{ desc := []string{
"Add or update API keys for cloud providers.", "Add or update API keys for cloud providers.",
"Recommended: local models maximize privacy; for cloud quality, ElevenLabs is the top pick.", "Recommended: for cloud quality, ElevenLabs is the top pick.",
"Tip: press / to filter.", "Tip: press / to filter.",
} }
@@ -160,9 +159,9 @@ func newProviderKeyFlow(state *wizardState, providerName string, onContinue func
if isProviderConfigured(state.cfg, providerName) { if isProviderConfigured(state.cfg, providerName) {
masked := maskAPIKey(state.cfg.Providers[providerName].APIKey) masked := maskAPIKey(state.cfg.Providers[providerName].APIKey)
desc := []string{fmt.Sprintf("Current key: %s", masked)} desc := []string{fmt.Sprintf("Current key: %s", masked)}
return newConfirmScreen(state, fmt.Sprintf("%s API Key", displayName), desc, "Update key", "Keep current", func() screen { return newConfirmScreen(state, fmt.Sprintf("%s API Key", displayName), desc, "Update key", "Replace the stored API key.", "Keep current", "Keep the existing API key.", func() screen {
return newAPIKeyInputScreen(state, providerName, onContinue, onCancel) return newAPIKeyInputScreen(state, providerName, onContinue, onCancel)
}, func() screen { return onContinue() }) }, func() screen { return onContinue() }, onCancel)
} }
return newAPIKeyInputScreen(state, providerName, onContinue, onCancel) return newAPIKeyInputScreen(state, providerName, onContinue, onCancel)
} }
@@ -229,7 +228,7 @@ func newVoiceProviderScreen(state *wizardState, onBack func() screen, onNext fun
selectedProvider := item.value selectedProvider := item.value
providerName := selectedProvider providerName := selectedProvider
switch selectedProvider { switch selectedProvider {
case "groq-transcription", "groq-translation": case "groq-transcription":
providerName = "groq" providerName = "groq"
case "mistral-transcription": case "mistral-transcription":
providerName = "mistral" providerName = "mistral"
@@ -262,7 +261,7 @@ func newVoiceModelScreen(state *wizardState, providerName string, onBack func()
items := make([]optionItem, 0, len(options)) items := make([]optionItem, 0, len(options))
for _, opt := range options { for _, opt := range options {
items = append(items, optionItem{title: opt.Label, value: opt.ID}) items = append(items, optionItem{title: opt.Title, desc: opt.Desc, value: opt.ID})
} }
desc := []string{ desc := []string{
@@ -280,11 +279,11 @@ func newVoiceModelScreen(state *wizardState, providerName string, onBack func()
return nil return nil
} }
confirmDesc := []string{fmt.Sprintf("Download %s (%s)?", modelInfo.Name, modelInfo.Size)} confirmDesc := []string{fmt.Sprintf("Download %s (%s)?", modelInfo.Name, modelInfo.Size)}
return newConfirmScreen(state, "Download Model", confirmDesc, "Download", "Cancel", func() screen { return newConfirmScreen(state, "Download Model", confirmDesc, "Download", "Download and install the model.", "Cancel", "Return to model list.", func() screen {
return newDownloadScreen(state, "Downloading Model", []string{modelInfo.Name}, item.value, func() screen { return newDownloadScreen(state, "Downloading Model", []string{modelInfo.Name}, item.value, func() screen {
return applyVoiceModelSelection(state, providerName, item.value, onBack, onNext) return applyVoiceModelSelection(state, providerName, item.value, onBack, onNext)
}, func() screen { return newVoiceModelScreen(state, providerName, onBack, onNext) }) }, func() screen { return newVoiceModelScreen(state, providerName, onBack, onNext) })
}, func() screen { return newVoiceModelScreen(state, providerName, onBack, onNext) }) }, func() screen { return newVoiceModelScreen(state, providerName, onBack, onNext) }, nil)
} }
return applyVoiceModelSelection(state, providerName, item.value, onBack, onNext) return applyVoiceModelSelection(state, providerName, item.value, onBack, onNext)
}, func() screen { return onBack() }) }, func() screen { return onBack() })
@@ -299,6 +298,7 @@ func newVoiceModelScreen(state *wizardState, providerName string, onBack func()
func applyVoiceModelSelection(state *wizardState, providerName, modelID string, onBack func() screen, onNext func() screen) screen { func applyVoiceModelSelection(state *wizardState, providerName, modelID string, onBack func() screen, onNext func() screen) screen {
state.cfg.Transcription.Provider = providerName state.cfg.Transcription.Provider = providerName
state.cfg.Transcription.Model = modelID state.cfg.Transcription.Model = modelID
backToModels := func() screen { return newVoiceModelScreen(state, providerName, onBack, onNext) }
registryName := mapConfigProviderToRegistry(providerName) registryName := mapConfigProviderToRegistry(providerName)
model, err := provider.GetModel(registryName, modelID) model, err := provider.GetModel(registryName, modelID)
@@ -317,20 +317,24 @@ func applyVoiceModelSelection(state *wizardState, providerName, modelID string,
} else { } else {
state.cfg.Transcription.Language = "" state.cfg.Transcription.Language = ""
} }
return applyStreamingSelection(state, model, onNext) return applyStreamingSelection(state, model, backToModels, onNext)
} }
return newLanguageScreen(state, model, func() screen { return newLanguageScreen(state, model, func() screen {
return newVoiceModelScreen(state, providerName, onBack, onNext) return newVoiceModelScreen(state, providerName, onBack, onNext)
}, func() screen { }, func() screen {
return applyStreamingSelection(state, model, onNext) return applyStreamingSelection(state, model, backToModels, onNext)
}) })
} }
func newLanguageScreen(state *wizardState, model *provider.Model, onBack func() screen, onNext func() screen) screen { func newLanguageScreen(state *wizardState, model *provider.Model, onBack func() screen, onNext func() screen) screen {
items := []optionItem{{title: "Auto-detect (recommended)", value: ""}} items := []optionItem{{title: "Auto-detect", desc: "Recommended. Let the model detect language.", value: ""}}
for _, code := range model.SupportedLanguages { for _, code := range model.SupportedLanguages {
items = append(items, optionItem{title: code, value: code}) label := provider.LanguageLabel(code)
if label == "" {
label = code
}
items = append(items, optionItem{title: label, desc: fmt.Sprintf("Language code: %s", code), value: code})
} }
desc := []string{"Select the language for the voice model.", "Tip: press / to filter."} desc := []string{"Select the language for the voice model.", "Tip: press / to filter."}
screen := newListScreen(state, "Language", desc, items, func(item optionItem) screen { screen := newListScreen(state, "Language", desc, items, func(item optionItem) screen {
@@ -345,16 +349,16 @@ func newLanguageScreen(state *wizardState, model *provider.Model, onBack func()
return screen return screen
} }
func applyStreamingSelection(state *wizardState, model *provider.Model, next func() screen) screen { func applyStreamingSelection(state *wizardState, model *provider.Model, onBack func() screen, next func() screen) screen {
if model.SupportsBothModes() { if model.SupportsBothModes() {
desc := []string{"This model supports both batch and streaming modes."} desc := []string{"This model supports both batch and streaming modes."}
return newConfirmScreen(state, "Enable Streaming Mode?", desc, "Yes, streaming", "No, batch", func() screen { return newConfirmScreen(state, "Enable Streaming Mode?", desc, "Yes, streaming", "Lower latency, higher resource use.", "No, batch", "Wait for full transcription.", func() screen {
state.cfg.Transcription.Streaming = true state.cfg.Transcription.Streaming = true
return next() return next()
}, func() screen { }, func() screen {
state.cfg.Transcription.Streaming = false state.cfg.Transcription.Streaming = false
return next() return next()
}) }, onBack)
} }
if model.SupportsStreaming { if model.SupportsStreaming {
state.cfg.Transcription.Streaming = true state.cfg.Transcription.Streaming = true
@@ -371,12 +375,12 @@ func newLLMEnableScreen(state *wizardState, onBack func() screen, onNext func()
} else { } else {
desc = []string{"Currently disabled.", desc[0]} desc = []string{"Currently disabled.", desc[0]}
} }
return newConfirmScreen(state, "Enable LLM Post-Processing?", desc, "Yes (recommended)", "No", func() screen { return newConfirmScreen(state, "Enable LLM Post-Processing?", desc, "Yes (recommended)", "Clean up grammar and punctuation.", "No", "Keep raw transcription text.", func() screen {
return newLLMProviderScreen(state, onBack, onNext) return newLLMProviderScreen(state, onBack, onNext)
}, func() screen { }, func() screen {
state.cfg.LLM.Enabled = false state.cfg.LLM.Enabled = false
return onNext() return onNext()
}) }, onBack)
} }
func newLLMProviderScreen(state *wizardState, onBack func() screen, onNext func() screen) screen { func newLLMProviderScreen(state *wizardState, onBack func() screen, onNext func() screen) screen {
@@ -414,7 +418,13 @@ func newLLMModelScreen(state *wizardState, providerName string, onBack func() sc
models := provider.ModelsOfType(p, provider.LLM) models := provider.ModelsOfType(p, provider.LLM)
items := make([]optionItem, 0, len(models)) items := make([]optionItem, 0, len(models))
for _, m := range models { for _, m := range models {
items = append(items, optionItem{title: fmt.Sprintf("%s (%s)", m.Name, m.Description), value: m.ID}) desc := m.Description
if desc == "" {
desc = fmt.Sprintf("Model id: %s", m.ID)
} else {
desc = fmt.Sprintf("%s (id: %s)", desc, m.ID)
}
items = append(items, optionItem{title: m.Name, desc: desc, value: m.ID})
} }
desc := []string{"Choose the LLM model.", "Tip: press / to filter."} desc := []string{"Choose the LLM model.", "Tip: press / to filter."}
screen := newListScreen(state, "LLM Model", desc, items, func(item optionItem) screen { screen := newListScreen(state, "LLM Model", desc, items, func(item optionItem) screen {
@@ -441,10 +451,10 @@ func newPostProcessingScreen(state *wizardState, onBack func() screen, onNext fu
} }
items := []toggleItem{ items := []toggleItem{
{title: "Remove stutters (repeated words)", value: "stutters", selected: current.RemoveStutters}, {title: "Remove stutters", desc: "Remove repeated words in speech.", value: "stutters", selected: current.RemoveStutters},
{title: "Add punctuation", value: "punctuation", selected: current.AddPunctuation}, {title: "Add punctuation", desc: "Insert commas and sentence breaks.", value: "punctuation", selected: current.AddPunctuation},
{title: "Fix grammar", value: "grammar", selected: current.FixGrammar}, {title: "Fix grammar", desc: "Correct basic grammatical errors.", value: "grammar", selected: current.FixGrammar},
{title: "Remove filler words (um, uh, like)", value: "fillers", selected: current.RemoveFillerWords}, {title: "Remove filler words", desc: "Remove fillers like 'um' and 'like'.", value: "fillers", selected: current.RemoveFillerWords},
} }
desc := []string{"Select which improvements to apply.", "Tip: press / to filter."} desc := []string{"Select which improvements to apply.", "Tip: press / to filter."}
@@ -483,7 +493,8 @@ func newCustomPromptConfirmScreen(state *wizardState, onBack func() screen, onNe
} else { } else {
desc = append([]string{"Current prompt: none."}, desc...) desc = append([]string{"Current prompt: none."}, desc...)
} }
return newConfirmScreen(state, "Add Custom Prompt?", desc, "Yes", "No", func() screen { prev := func() screen { return newPostProcessingScreen(state, onBack, onNext) }
return newConfirmScreen(state, "Add Custom Prompt?", desc, "Yes", "Provide additional instructions.", "No", "Use default behavior only.", func() screen {
return newInputScreen(state, "Custom Prompt", []string{"Additional instructions for the LLM."}, state.cfg.LLM.CustomPrompt.Prompt, "Format as bullet points", false, func(s string) error { return newInputScreen(state, "Custom Prompt", []string{"Additional instructions for the LLM."}, state.cfg.LLM.CustomPrompt.Prompt, "Format as bullet points", false, func(s string) error {
if len(s) > 500 { if len(s) > 500 {
return fmt.Errorf("prompt must be 500 characters or less") return fmt.Errorf("prompt must be 500 characters or less")
@@ -499,7 +510,7 @@ func newCustomPromptConfirmScreen(state *wizardState, onBack func() screen, onNe
state.cfg.LLM.CustomPrompt.Enabled = false state.cfg.LLM.CustomPrompt.Enabled = false
state.cfg.LLM.Enabled = true state.cfg.LLM.Enabled = true
return onNext() return onNext()
}) }, prev)
} }
func newKeywordsScreen(state *wizardState, onBack func() screen) screen { func newKeywordsScreen(state *wizardState, onBack func() screen) screen {
@@ -526,7 +537,7 @@ func newKeywordsScreen(state *wizardState, onBack func() screen) screen {
state.cfg.Keywords = keywords state.cfg.Keywords = keywords
} }
if state.onboarding { if state.onboarding {
return newInjectionScreen(state, func() screen { return newKeywordsScreen(state, onBack) }) return newNotificationsScreen(state, func() screen { return newKeywordsScreen(state, onBack) })
} }
return onBack() return onBack()
}, onBack) }, onBack)
@@ -543,9 +554,9 @@ func newInjectionScreen(state *wizardState, onBack func() screen) screen {
} }
items := []toggleItem{ items := []toggleItem{
{title: "ydotool - best for Chromium/Electron (needs ydotoold)", value: "ydotool", selected: selectedSet["ydotool"]}, {title: "ydotool", desc: "Best for Chromium/Electron. Requires ydotoold.", value: "ydotool", selected: selectedSet["ydotool"]},
{title: "wtype - native Wayland typing", value: "wtype", selected: selectedSet["wtype"]}, {title: "wtype", desc: "Native Wayland typing.", value: "wtype", selected: selectedSet["wtype"]},
{title: "clipboard - copy to clipboard only", value: "clipboard", selected: selectedSet["clipboard"]}, {title: "clipboard", desc: "Copy to clipboard only.", value: "clipboard", selected: selectedSet["clipboard"]},
} }
desc := []string{"Backends are tried in order until one succeeds.", "Tip: press / to filter."} desc := []string{"Backends are tried in order until one succeeds.", "Tip: press / to filter."}
screen := newMultiSelectScreen(state, "Text Injection Backends", desc, items, true, func(items []toggleItem) screen { screen := newMultiSelectScreen(state, "Text Injection Backends", desc, items, true, func(items []toggleItem) screen {
@@ -556,9 +567,6 @@ func newInjectionScreen(state *wizardState, onBack func() screen) screen {
} }
} }
state.cfg.Injection.Backends = backends state.cfg.Injection.Backends = backends
if state.onboarding {
return newNotificationsScreen(state, func() screen { return newInjectionScreen(state, onBack) })
}
return onBack() return onBack()
}, onBack) }, onBack)
screen.footer = "space toggle • enter save • esc back • / filter" screen.footer = "space toggle • enter save • esc back • / filter"
@@ -572,16 +580,20 @@ func newNotificationsScreen(state *wizardState, onBack func() screen) screen {
} else { } else {
desc = append([]string{"Currently disabled."}, desc...) desc = append([]string{"Currently disabled."}, desc...)
} }
return newConfirmScreen(state, "Enable Desktop Notifications?", desc, "Yes", "No", func() screen { return newConfirmScreen(state, "Enable Desktop Notifications?", desc, "Yes", "Show status notifications.", "No", "Disable notifications.", func() screen {
state.cfg.Notifications.Enabled = true state.cfg.Notifications.Enabled = true
if state.cfg.Notifications.Type == "none" {
state.cfg.Notifications.Type = ""
}
return newNotificationTypeScreen(state, onBack) return newNotificationTypeScreen(state, onBack)
}, func() screen { }, func() screen {
state.cfg.Notifications.Enabled = false state.cfg.Notifications.Enabled = false
if state.onboarding { if state.onboarding {
return newAdvancedPromptScreen(state, onBack) state.cfg.Notifications.Type = "none"
return onboardingSummaryScreen(state, onBack)
} }
return onBack() return onBack()
}) }, onBack)
} }
func newNotificationTypeScreen(state *wizardState, onBack func() screen) screen { func newNotificationTypeScreen(state *wizardState, onBack func() screen) screen {
@@ -589,9 +601,9 @@ func newNotificationTypeScreen(state *wizardState, onBack func() screen) screen
state.cfg.Notifications.Type = "desktop" state.cfg.Notifications.Type = "desktop"
} }
items := []optionItem{ items := []optionItem{
{title: "Desktop notifications (notify-send)", value: "desktop"}, {title: "Reccomended: Desktop notifications", desc: "Uses notify-send to show popups.", value: "desktop"},
{title: "Log to console only", value: "log"}, {title: "Log to console", desc: "Only use for development, or if you want to plug it to something else. Write status changes to logs only.", value: "log"},
{title: "None (silent)", value: "none"}, {title: "None", desc: "Disable notifications entirely.", value: "none"},
} }
desc := []string{"Choose how notifications should be displayed."} desc := []string{"Choose how notifications should be displayed."}
screen := newListScreen(state, "Notification Type", desc, items, func(item optionItem) screen { screen := newListScreen(state, "Notification Type", desc, items, func(item optionItem) screen {
@@ -605,14 +617,15 @@ func newNotificationTypeScreen(state *wizardState, onBack func() screen) screen
func newCustomMessagesConfirmScreen(state *wizardState, onBack func() screen) screen { func newCustomMessagesConfirmScreen(state *wizardState, onBack func() screen) screen {
desc := []string{"Customize the text shown in notifications."} desc := []string{"Customize the text shown in notifications."}
return newConfirmScreen(state, "Customize Notification Messages?", desc, "Yes", "No", func() screen { prev := func() screen { return newNotificationTypeScreen(state, onBack) }
return newConfirmScreen(state, "Customize Notification Messages?", desc, "Yes", "Edit titles and bodies.", "No", "Use default messages.", func() screen {
return newNotificationMessagesScreen(state, onBack) return newNotificationMessagesScreen(state, onBack)
}, func() screen { }, func() screen {
if state.onboarding { if state.onboarding {
return newAdvancedPromptScreen(state, onBack) return onboardingSummaryScreen(state, onBack)
} }
return onBack() return onBack()
}) }, prev)
} }
func newNotificationMessagesScreen(state *wizardState, onBack func() screen) screen { func newNotificationMessagesScreen(state *wizardState, onBack func() screen) screen {
@@ -626,20 +639,22 @@ func newNotificationMessagesScreen(state *wizardState, onBack func() screen) scr
if len(display) > 40 { if len(display) > 40 {
display = display[:40] + "..." display = display[:40] + "..."
} }
label := fmt.Sprintf("%s: \"%s\"", def.ConfigKey, display) label := formatNotificationMessageTitle(def)
items = append(items, optionItem{title: label, value: def.ConfigKey}) desc := fmt.Sprintf("Current: \"%s\"", display)
items = append(items, optionItem{title: label, desc: desc, value: def.ConfigKey})
} }
items = append(items, optionItem{title: "Back", value: "back"}) items = append(items, optionItem{title: "Back", desc: "Return without editing.", value: "back"})
desc := []string{"Select a message to edit."} desc := []string{"Select a message to edit."}
backFn := onBack
if state.onboarding {
backFn = func() screen { return onboardingSummaryScreen(state, onBack) }
}
screen := newListScreen(state, "Notification Messages", desc, items, func(item optionItem) screen { screen := newListScreen(state, "Notification Messages", desc, items, func(item optionItem) screen {
if item.value == "back" { if item.value == "back" {
if state.onboarding { return backFn()
return newAdvancedPromptScreen(state, onBack)
}
return onBack()
} }
return newNotificationMessageEditScreen(state, item.value, func() screen { return newNotificationMessagesScreen(state, onBack) }) return newNotificationMessageEditScreen(state, item.value, func() screen { return newNotificationMessagesScreen(state, onBack) })
}, func() screen { return onBack() }) }, func() screen { return backFn() })
screen.footer = "enter select • esc back • / filter" screen.footer = "enter select • esc back • / filter"
return screen return screen
} }
@@ -675,34 +690,38 @@ func newNotificationMessageEditScreen(state *wizardState, configKey string, onBa
} }
func newAdvancedPromptScreen(state *wizardState, onBack func() screen) screen { func newAdvancedPromptScreen(state *wizardState, onBack func() screen) screen {
desc := []string{"Configure advanced settings like recording parameters and timeouts."} desc := []string{"Configure advanced settings like recording, injection, and timeouts."}
return newConfirmScreen(state, "Configure Advanced Settings?", desc, "Yes", "No", func() screen { return newConfirmScreen(state, "Configure Advanced Settings?", desc, "Yes", "Edit recording, injection, and timeout values.", "No", "Skip advanced options for now.", func() screen {
return newAdvancedMenuScreen(state, onBack, true) return newAdvancedMenuScreen(state, onBack, true)
}, func() screen { }, func() screen {
if state.onboarding { if state.onboarding {
return newMenuScreen(state) return newMenuScreen(state)
} }
return onBack() return onBack()
}) }, onBack)
} }
func newAdvancedMenuScreen(state *wizardState, onBack func() screen, onboarding bool) screen { func newAdvancedMenuScreen(state *wizardState, onBack func() screen, onboarding bool) screen {
items := []optionItem{ items := []optionItem{
{title: formatAdvancedRecordingLabel(state.cfg), value: "recording"}, {title: formatAdvancedRecordingLabel(state.cfg), desc: "Sample rate, channels, device, and timeout.", value: "recording"},
{title: formatAdvancedInjectionTimeoutLabel(state.cfg), value: "timeouts"},
{title: "Back", value: "back"},
} }
if !onboarding {
items = append(items, optionItem{title: formatInjectionLabel(state.cfg), desc: "Backends for typing and clipboard fallback.", value: "injection"})
}
items = append(items, optionItem{title: formatAdvancedInjectionTimeoutLabel(state.cfg), desc: "Timeouts for ydotool, wtype, clipboard.", value: "timeouts"})
if onboarding { if onboarding {
items[len(items)-1].title = "Next" items = append(items, optionItem{title: "Next", desc: "Continue without changing advanced settings.", value: "next"})
} }
desc := []string{"Configure low-level options."} desc := []string{"Configure low-level options."}
screen := newListScreen(state, "Advanced Settings", desc, items, func(item optionItem) screen { screen := newListScreen(state, "Advanced Settings", desc, items, func(item optionItem) screen {
switch item.value { switch item.value {
case "recording": case "recording":
return newRecordingSettingsScreen(state, func() screen { return newAdvancedMenuScreen(state, onBack, onboarding) }) return newRecordingSettingsScreen(state, func() screen { return newAdvancedMenuScreen(state, onBack, onboarding) })
case "injection":
return newInjectionScreen(state, func() screen { return newAdvancedMenuScreen(state, onBack, onboarding) })
case "timeouts": case "timeouts":
return newInjectionTimeoutsScreen(state, func() screen { return newAdvancedMenuScreen(state, onBack, onboarding) }) return newInjectionTimeoutsScreen(state, func() screen { return newAdvancedMenuScreen(state, onBack, onboarding) })
case "back": case "next":
if onboarding { if onboarding {
return newMenuScreen(state) return newMenuScreen(state)
} }
@@ -809,8 +828,8 @@ func newInjectionTimeoutsScreen(state *wizardState, onBack func() screen) screen
func newSummaryScreen(state *wizardState, onBack func() screen) screen { func newSummaryScreen(state *wizardState, onBack func() screen) screen {
summary := buildSummaryLines(state.cfg) summary := buildSummaryLines(state.cfg)
items := []optionItem{ items := []optionItem{
{title: "Save", value: "save"}, {title: "Save", desc: "Write configuration to disk.", value: "save"},
{title: "Cancel", value: "cancel"}, {title: "Cancel", desc: "Go back without saving.", value: "cancel"},
} }
desc := []string{} desc := []string{}
desc = append(desc, summary...) desc = append(desc, summary...)
@@ -831,9 +850,9 @@ func buildVoiceProviderOptions(cfg *config.Config) []optionItem {
whisperStatus := deps.CheckWhisperCli() whisperStatus := deps.CheckWhisperCli()
if whisperStatus.Installed { if whisperStatus.Installed {
options = append(options, optionItem{title: "Whisper.cpp (local, no API key)", value: "whisper-cpp"}) options = append(options, optionItem{title: "Whisper.cpp (local)", desc: "Local transcription with no API key.", value: "whisper-cpp"})
} else { } else {
options = append(options, optionItem{title: "Whisper.cpp (local, install required)", value: "whisper-cpp-disabled"}) options = append(options, optionItem{title: "Whisper.cpp (local)", desc: "Install whisper-cli to enable local models.", value: "whisper-cpp-disabled"})
} }
configured := getConfiguredProviders(cfg) configured := getConfiguredProviders(cfg)
@@ -842,18 +861,17 @@ func buildVoiceProviderOptions(cfg *config.Config) []optionItem {
if p != nil && len(provider.ModelsOfType(p, provider.Transcription)) > 0 { if p != nil && len(provider.ModelsOfType(p, provider.Transcription)) > 0 {
switch name { switch name {
case "openai": case "openai":
options = append(options, optionItem{title: "OpenAI Whisper", value: "openai"}) options = append(options, optionItem{title: "OpenAI Whisper", desc: "Configured. Balanced quality and cost.", value: "openai"})
case "groq": case "groq":
options = append(options, options = append(options,
optionItem{title: "Groq Whisper (transcription)", value: "groq-transcription"}, optionItem{title: "Groq Whisper", desc: "Configured. Fast transcription.", value: "groq-transcription"},
optionItem{title: "Groq Whisper (translate to English)", value: "groq-translation"},
) )
case "mistral": case "mistral":
options = append(options, optionItem{title: "Mistral Voxtral", value: "mistral-transcription"}) options = append(options, optionItem{title: "Mistral Voxtral", desc: "Configured. Strong European language support.", value: "mistral-transcription"})
case "elevenlabs": case "elevenlabs":
options = append(options, optionItem{title: "ElevenLabs Scribe", value: "elevenlabs"}) options = append(options, optionItem{title: "ElevenLabs Scribe", desc: "Configured. Best cloud quality.", value: "elevenlabs"})
case "deepgram": case "deepgram":
options = append(options, optionItem{title: "Deepgram Nova", value: "deepgram"}) options = append(options, optionItem{title: "Deepgram Nova", desc: "Configured. Great streaming performance.", value: "deepgram"})
} }
} }
} }
@@ -864,22 +882,21 @@ func buildVoiceProviderOptions(cfg *config.Config) []optionItem {
} }
if !configuredSet["openai"] { if !configuredSet["openai"] {
options = append(options, optionItem{title: "OpenAI Whisper (add API key)", value: "openai"}) options = append(options, optionItem{title: "OpenAI Whisper", desc: "Requires API key. You'll be prompted.", value: "openai"})
} }
if !configuredSet["groq"] { if !configuredSet["groq"] {
options = append(options, options = append(options,
optionItem{title: "Groq Whisper transcription (add API key)", value: "groq-transcription"}, optionItem{title: "Groq Whisper", desc: "Requires API key. You'll be prompted.", value: "groq-transcription"},
optionItem{title: "Groq Whisper translation (add API key)", value: "groq-translation"},
) )
} }
if !configuredSet["mistral"] { if !configuredSet["mistral"] {
options = append(options, optionItem{title: "Mistral Voxtral (add API key)", value: "mistral-transcription"}) options = append(options, optionItem{title: "Mistral Voxtral", desc: "Requires API key. You'll be prompted.", value: "mistral-transcription"})
} }
if !configuredSet["elevenlabs"] { if !configuredSet["elevenlabs"] {
options = append(options, optionItem{title: "ElevenLabs Scribe (add API key)", value: "elevenlabs"}) options = append(options, optionItem{title: "ElevenLabs Scribe", desc: "Requires API key. You'll be prompted.", value: "elevenlabs"})
} }
if !configuredSet["deepgram"] { if !configuredSet["deepgram"] {
options = append(options, optionItem{title: "Deepgram Nova (add API key)", value: "deepgram"}) options = append(options, optionItem{title: "Deepgram Nova", desc: "Requires API key. You'll be prompted.", value: "deepgram"})
} }
return options return options
@@ -893,9 +910,9 @@ func buildLLMProviderOptions(cfg *config.Config) []optionItem {
if p != nil && len(provider.ModelsOfType(p, provider.LLM)) > 0 { if p != nil && len(provider.ModelsOfType(p, provider.LLM)) > 0 {
switch name { switch name {
case "openai": case "openai":
options = append(options, optionItem{title: "OpenAI GPT", value: "openai"}) options = append(options, optionItem{title: "OpenAI GPT", desc: "Configured. Balanced quality and cost.", value: "openai"})
case "groq": case "groq":
options = append(options, optionItem{title: "Groq Llama (fast)", value: "groq"}) options = append(options, optionItem{title: "Groq Llama", desc: "Configured. Very fast inference.", value: "groq"})
} }
} }
} }
@@ -905,37 +922,58 @@ func buildLLMProviderOptions(cfg *config.Config) []optionItem {
configuredSet[name] = true configuredSet[name] = true
} }
if !configuredSet["openai"] { if !configuredSet["openai"] {
options = append(options, optionItem{title: "OpenAI GPT (add API key)", value: "openai"}) options = append(options, optionItem{title: "OpenAI GPT", desc: "Requires API key. You'll be prompted.", value: "openai"})
} }
if !configuredSet["groq"] { if !configuredSet["groq"] {
options = append(options, optionItem{title: "Groq Llama (add API key)", value: "groq"}) options = append(options, optionItem{title: "Groq Llama", desc: "Requires API key. You'll be prompted.", value: "groq"})
} }
return options return options
} }
func formatProviderOption(cfg *config.Config, name string) string { func formatProviderOption(cfg *config.Config, name string) string {
status := "(not configured)"
if pc, exists := cfg.Providers[name]; exists && pc.APIKey != "" {
status = "(configured)"
}
switch name { switch name {
case "openai": case "openai":
return fmt.Sprintf("OpenAI - Whisper + GPT %s", status) return "OpenAI - Whisper + GPT"
case "groq": case "groq":
return fmt.Sprintf("Groq - Whisper + Llama %s", status) return "Groq - Whisper + Llama"
case "mistral": case "mistral":
return fmt.Sprintf("Mistral - Voxtral %s", status) return "Mistral - Voxtral"
case "elevenlabs": case "elevenlabs":
return fmt.Sprintf("ElevenLabs - Scribe %s", status) return "ElevenLabs - Scribe"
case "deepgram": case "deepgram":
return fmt.Sprintf("Deepgram - Nova %s", status) return "Deepgram - Nova"
default: default:
return fmt.Sprintf("%s %s", name, status) return name
} }
} }
func formatProviderOptionDesc(cfg *config.Config, name string) string {
status := "Not configured"
if pc, exists := cfg.Providers[name]; exists && pc.APIKey != "" {
status = "Configured"
}
recommendation := ""
switch name {
case "openai":
recommendation = "Recommended for balanced quality and cost."
case "groq":
recommendation = "Recommended for fastest turnaround."
case "mistral":
recommendation = "Recommended for European languages."
case "elevenlabs":
recommendation = "Recommended for best cloud quality."
case "deepgram":
recommendation = "Recommended for realtime streaming."
}
if recommendation == "" {
return status + "."
}
return status + ". " + recommendation
}
func formatProvidersLabel(cfg *config.Config) string { func formatProvidersLabel(cfg *config.Config) string {
count := len(getConfiguredProviders(cfg)) count := len(getConfiguredProviders(cfg))
if count == 0 { if count == 0 {
@@ -1042,6 +1080,11 @@ func findMessageDef(key string) *notify.MessageDef {
return nil return nil
} }
func formatNotificationMessageTitle(def notify.MessageDef) string {
label := strings.ReplaceAll(def.ConfigKey, "_", " ")
return strings.Title(label)
}
func buildSummaryLines(cfg *config.Config) []string { func buildSummaryLines(cfg *config.Config) []string {
var lines []string var lines []string
+27 -26
View File
@@ -3,6 +3,7 @@ package tui
import ( import (
"fmt" "fmt"
"sort" "sort"
"strings"
"github.com/leonardotrapani/hyprvoice/internal/config" "github.com/leonardotrapani/hyprvoice/internal/config"
"github.com/leonardotrapani/hyprvoice/internal/models/whisper" "github.com/leonardotrapani/hyprvoice/internal/models/whisper"
@@ -39,16 +40,6 @@ func maskAPIKey(key string) string {
return key[:7] + "..." + key[len(key)-4:] return key[:7] + "..." + key[len(key)-4:]
} }
func hasUserChanges(cfg *config.Config) bool {
if len(cfg.Providers) > 0 {
return true
}
if cfg.Transcription.APIKey != "" {
return true
}
return false
}
func getConfiguredProviders(cfg *config.Config) []string { func getConfiguredProviders(cfg *config.Config) []string {
providers := make([]string, 0, len(cfg.Providers)) providers := make([]string, 0, len(cfg.Providers))
for name, pc := range cfg.Providers { for name, pc := range cfg.Providers {
@@ -69,7 +60,7 @@ func isProviderConfigured(cfg *config.Config, providerName string) bool {
func mapConfigProviderToRegistry(configProvider string) string { func mapConfigProviderToRegistry(configProvider string) string {
switch configProvider { switch configProvider {
case "groq-transcription", "groq-translation": case "groq-transcription":
return "groq" return "groq"
case "mistral-transcription": case "mistral-transcription":
return "mistral" return "mistral"
@@ -78,27 +69,37 @@ func mapConfigProviderToRegistry(configProvider string) string {
} }
} }
func buildModelLabel(m provider.Model) string { func buildModelDesc(m provider.Model) string {
label := fmt.Sprintf("%s (%s)", m.Name, m.Description) parts := []string{}
if m.Description != "" {
parts = append(parts, m.Description)
} else if m.Name != "" {
parts = append(parts, m.Name)
}
if m.Local && m.LocalInfo != nil { if m.Local {
label += fmt.Sprintf(" [%s]", m.LocalInfo.Size) parts = append(parts, "local model")
} }
if m.SupportsBothModes() { if m.SupportsBothModes() {
label += " [batch+streaming]" parts = append(parts, "batch+streaming")
} else if m.SupportsStreaming { } else if m.SupportsStreaming {
label += " [streaming]" parts = append(parts, "streaming")
} else {
parts = append(parts, "batch-only")
} }
return label if m.Local && m.LocalInfo != nil && m.LocalInfo.Size != "" {
parts = append(parts, fmt.Sprintf("size %s", m.LocalInfo.Size))
}
if len(parts) == 0 {
return "Transcription model"
}
return strings.Join(parts, " - ")
} }
func getTranscriptionModelOptions(configProvider string) []modelOption { func getTranscriptionModelOptions(configProvider string) []modelOption {
if configProvider == "groq-translation" {
return []modelOption{{ID: "whisper-large-v3", Label: "whisper-large-v3 (only option)"}}
}
registryName := mapConfigProviderToRegistry(configProvider) registryName := mapConfigProviderToRegistry(configProvider)
p := provider.GetProvider(registryName) p := provider.GetProvider(registryName)
if p == nil { if p == nil {
@@ -108,15 +109,15 @@ func getTranscriptionModelOptions(configProvider string) []modelOption {
models := provider.ModelsOfType(p, provider.Transcription) models := provider.ModelsOfType(p, provider.Transcription)
options := make([]modelOption, 0, len(models)) options := make([]modelOption, 0, len(models))
for _, m := range models { for _, m := range models {
label := buildModelLabel(m) desc := buildModelDesc(m)
if m.Local && registryName == "whisper-cpp" { if m.Local && registryName == "whisper-cpp" {
if whisper.IsInstalled(m.ID) { if whisper.IsInstalled(m.ID) {
label = "[x] " + label desc = desc + " - installed"
} else { } else {
label = "[ ] " + label desc = desc + " - not installed"
} }
} }
options = append(options, modelOption{ID: m.ID, Label: label}) options = append(options, modelOption{ID: m.ID, Title: m.ID, Desc: desc})
} }
return options return options
+15 -5
View File
@@ -91,13 +91,18 @@ type confirmScreen struct {
footer string footer string
onYes func() screen onYes func() screen
onNo func() screen onNo func() screen
onBack func() screen
errText string errText string
} }
func newConfirmScreen(state *wizardState, title string, desc []string, yesLabel, noLabel string, onYes func() screen, onNo func() screen) *confirmScreen { func newConfirmScreen(state *wizardState, title string, desc []string, yesLabel, yesDesc, noLabel, noDesc string, onYes func() screen, onNo func() screen, onBack func() screen) *confirmScreen {
items := []optionItem{ items := []optionItem{
{title: yesLabel, value: "yes"}, {title: yesLabel, desc: yesDesc, value: "yes"},
{title: noLabel, value: "no"}, {title: noLabel, desc: noDesc, value: "no"},
}
footer := "enter select • esc cancel"
if onBack != nil {
footer = "enter select • esc back"
} }
delegate := list.NewDefaultDelegate() delegate := list.NewDefaultDelegate()
l := list.New(itemsToList(items), delegate, 0, 0) l := list.New(itemsToList(items), delegate, 0, 0)
@@ -106,7 +111,7 @@ func newConfirmScreen(state *wizardState, title string, desc []string, yesLabel,
l.SetFilteringEnabled(false) l.SetFilteringEnabled(false)
l.SetShowStatusBar(false) l.SetShowStatusBar(false)
l.Title = title l.Title = title
return &confirmScreen{state: state, title: title, desc: desc, list: l, footer: "enter select • esc back", onYes: onYes, onNo: onNo} return &confirmScreen{state: state, title: title, desc: desc, list: l, footer: footer, onYes: onYes, onNo: onNo, onBack: onBack}
} }
func (s *confirmScreen) Init() tea.Cmd { return nil } func (s *confirmScreen) Init() tea.Cmd { return nil }
@@ -127,6 +132,9 @@ func (s *confirmScreen) Update(msg tea.Msg) (screen, tea.Cmd) {
} }
} }
case "esc", "q": case "esc", "q":
if s.onBack != nil {
return s.onBack(), nil
}
if s.onNo != nil { if s.onNo != nil {
return s.onNo(), nil return s.onNo(), nil
} }
@@ -314,7 +322,9 @@ func newInfoScreen(state *wizardState, title string, desc []string, next func()
return &infoScreen{state: state, title: title, desc: desc, footer: "enter continue • esc back", next: next, back: back} return &infoScreen{state: state, title: title, desc: desc, footer: "enter continue • esc back", next: next, back: back}
} }
func (s *infoScreen) Init() tea.Cmd { return nil } func (s *infoScreen) Init() tea.Cmd {
return nil
}
func (s *infoScreen) Update(msg tea.Msg) (screen, tea.Cmd) { func (s *infoScreen) Update(msg tea.Msg) (screen, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
+14 -5
View File
@@ -1,6 +1,10 @@
package tui package tui
import "github.com/charmbracelet/lipgloss" import (
"strings"
"github.com/charmbracelet/lipgloss"
)
// Base styles for hyprvoice TUI components // Base styles for hyprvoice TUI components
var ( var (
@@ -60,14 +64,19 @@ var (
Padding(1, 2) Padding(1, 2)
) )
// Logo returns the hyprvoice ASCII art const logoASCII = `
func Logo() string {
logo := `
_ _ _ _
| |__ _ _ _ __ _ ____ _(_) ___ ___ | |__ _ _ _ __ _ ____ _(_) ___ ___
| '_ \| | | | '_ \| '__\ \ / / |/ __/ _ \ | '_ \| | | | '_ \| '__\ \ / / |/ __/ _ \
| | | | |_| | |_) | | \ V /| | (_| __/ | | | | |_| | |_) | | \ V /| | (_| __/
|_| |_|\__, | .__/|_| \_/ |_|\___\___| |_| |_|\__, | .__/|_| \_/ |_|\___\___|
|___/|_| ` |___/|_| `
return StyleHeader.Render(logo)
// Logo returns the hyprvoice ASCII art
func Logo() string {
return StyleHeader.Render(strings.Trim(logoASCII, "\n"))
}
func LogoLines() []string {
return strings.Split(strings.Trim(logoASCII, "\n"), "\n")
} }
+2 -1
View File
@@ -62,5 +62,6 @@ func (i toggleItem) FilterValue() string {
type modelOption struct { type modelOption struct {
ID string ID string
Label string Title string
Desc string
} }
+3 -6
View File
@@ -78,19 +78,16 @@ func (m wizardModel) View() string {
} }
// Run starts the TUI configuration wizard. // Run starts the TUI configuration wizard.
// If onboarding is true, forces the guided wizard flow even if config exists. // If onboarding is true, starts the guided onboarding flow.
func Run(existingConfig *config.Config, onboarding bool) (*ConfigureResult, error) { func Run(existingConfig *config.Config, onboarding bool) (*ConfigureResult, error) {
if existingConfig == nil { if existingConfig == nil {
return nil, fmt.Errorf("config is required") return nil, fmt.Errorf("config is required")
} }
state := &wizardState{cfg: existingConfig} state := &wizardState{cfg: existingConfig, onboarding: onboarding}
if onboarding || !hasUserChanges(existingConfig) {
state.onboarding = true
}
var start screen var start screen
if state.onboarding { if onboarding {
start = newWelcomeScreen(state) start = newWelcomeScreen(state)
} else { } else {
start = newMenuScreen(state) start = newMenuScreen(state)
+6 -4
View File
@@ -2,14 +2,16 @@ post_install() {
echo "==> Hyprvoice installed successfully!" echo "==> Hyprvoice installed successfully!"
echo "" echo ""
echo " 📋 To use hyprvoice:" echo " 📋 To use hyprvoice:"
echo " 1. Configure: hyprvoice configure" echo " 1. Run onboarding: hyprvoice onboarding"
echo " 2. Enable service: systemctl --user enable hyprvoice.service" echo " 2. Enable service: systemctl --user enable --now hyprvoice.service"
echo " 3. Start service: systemctl --user start hyprvoice.service" echo " 3. Add keybinding to your window manager"
echo " 4. Add keybinding to your window manager" echo " 4. Test voice input: hyprvoice toggle"
echo "" echo ""
echo " 🔑 For Hyprland, add to ~/.config/hypr/hyprland.conf:" echo " 🔑 For Hyprland, add to ~/.config/hypr/hyprland.conf:"
echo " bind = SUPER, R, exec, hyprvoice toggle" echo " bind = SUPER, R, exec, hyprvoice toggle"
echo "" echo ""
echo " Later: hyprvoice configure for advanced settings"
echo ""
} }
post_upgrade() { post_upgrade() {