feat: new readme
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
+40
-10
@@ -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,22 +139,46 @@ 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 err != nil {
|
if onboarding {
|
||||||
return fmt.Errorf("failed to load config: %w", err)
|
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 {
|
||||||
|
return fmt.Errorf("failed to load config: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run TUI wizard
|
// Run TUI wizard
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
`)
|
`)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user