feat: 1.0

1.0
This commit is contained in:
Leonardo Trapani
2026-02-02 16:54:50 +01:00
committed by GitHub
91 changed files with 15337 additions and 2448 deletions
+4 -3
View File
@@ -6,7 +6,8 @@ on:
branches: [main, develop] branches: [main, develop]
pull_request: pull_request:
branches: [main, develop] branches: [main, develop]
workflow_call: {} # <-- makes this workflow reusable workflow_dispatch: {} # manual trigger
workflow_call: {} # reusable workflow
jobs: jobs:
test: test:
@@ -17,9 +18,9 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v4 uses: actions/setup-go@v5
with: with:
go-version: "1.21" go-version-file: go.mod
- name: Install dependencies - name: Install dependencies
run: | run: |
+40
View File
@@ -0,0 +1,40 @@
# .github/workflows/e2e.yml
name: E2E Tests
on:
workflow_dispatch:
jobs:
integration:
name: Integration Tests
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
build-essential \
pkg-config \
libasound2-dev \
libpulse-dev \
libpipewire-0.3-dev
- name: Download dependencies
run: go mod download
- name: Run integration tests
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
DEEPGRAM_API_KEY: ${{ secrets.DEEPGRAM_API_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
ELEVENLABS_API_KEY: ${{ secrets.ELEVENLABS_API_KEY }}
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
run: go test -tags=integration -v ./cmd/hyprvoice -timeout 15m
+2 -2
View File
@@ -25,9 +25,9 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v4 uses: actions/setup-go@v5
with: with:
go-version: "1.21" go-version-file: go.mod
- name: Install dependencies - name: Install dependencies
run: | run: |
+3 -2
View File
@@ -29,7 +29,8 @@ go.work.sum
# .vscode/ # .vscode/
# Output binaries # Output binaries
hyprvoice /hyprvoice
hyprvoice-* /hyprvoice-*
packaging/hyprvoice-v*
tmp/* tmp/*
CLAUDE.md CLAUDE.md
+34
View File
@@ -0,0 +1,34 @@
# AGENTS.md
This repo is a Go CLI + daemon for voice-powered typing on Wayland/Hyprland.
## Build and run
- go mod download
- go build -o hyprvoice ./cmd/hyprvoice
- go run ./cmd/hyprvoice
## Main structure (short)
- cmd/hyprvoice: CLI entrypoint and commands
- internal/daemon: daemon lifecycle + IPC command handling
- internal/pipeline: recording -> transcription -> processing -> injection state machine
- internal/recording: PipeWire capture
- internal/transcriber: batch/streaming adapters
- internal/llm: post-processing adapters and prompts
- internal/injection: wtype/ydotool/clipboard backends
- internal/provider: provider registry + model metadata
- internal/config: config load/validate + hot reload
## Runtime quick facts
- IPC: unix socket at ~/.cache/hyprvoice/control.sock, single-character commands
- 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/structure.md: code map and entry points
- docs/architecture.md: deeper architecture + adapters/interfaces
- docs/config.md: config reference and paths
- docs/providers.md: provider and model details
- packaging/RELEASE.md: release and AUR workflow
+142 -573
View File
@@ -1,571 +1,149 @@
# 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 hyprland 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
- **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, and Eleven Labs Scribe (99 languages, excellent accuracy)
- **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.
- Optional LLM post-processing for grammar, punctuation, and more.
- 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.
- Personalization through custom prompt and keywords sent both to LLM and to voice model.
- Whisprflow quality but for linux and open source.
- Support for streaming models for blazing fast transcription.
## 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`
### Mistral (cloud)
- `voxtral-mini-latest`
### ElevenLabs (cloud)
- `scribe_v1` (batch)
- `scribe_v2` (batch)
- `scribe_v2_realtime` (streaming)
### whisper-cpp (local)
- English-only: `tiny.en`, `base.en`, `small.en`, `medium.en`
- Multilingual: `tiny`, `base`, `small`, `medium`, `large-v1`, `large-v2`, `large-v3`, `large-v3-turbo`
### Deepgram (cloud)
- `flux-general-en`
- `nova-3`
- `nova-2`
## 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, or Eleven Labs API key (check each provider's pricing)
**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
``` ```
## Quick Reference Run `hyprvoice configure` anytime for advanced settings.
### Common Commands ## Commands
### Core CLI
```bash ```bash
# Interactive configuration wizard hyprvoice onboarding
hyprvoice configure hyprvoice configure
# Start the daemon
hyprvoice serve hyprvoice serve
# Toggle recording on/off
hyprvoice toggle hyprvoice toggle
# Cancel current operation
hyprvoice cancel hyprvoice cancel
# Check current status
hyprvoice status hyprvoice status
# Get protocol version
hyprvoice version hyprvoice version
# Stop the daemon (if not using systemd service)
hyprvoice stop hyprvoice stop
``` ```
### Keybinding Pattern ### Model management (whisper-cpp)
Most setups use this toggle pattern in window manager config:
```bash ```bash
bind = SUPER, R, exec, hyprvoice toggle hyprvoice model list
bind = SUPER SHIFT, R, exec, hyprvoice cancel # Optional: cancel current operation hyprvoice model list --provider whisper-cpp
hyprvoice model download base.en
hyprvoice model remove base.en
``` ```
## Keyboard Shortcuts Setup ### Model testing (E2E)
### Hyprland
Add to your `~/.config/hypr/hyprland.conf`:
```bash ```bash
# Hyprvoice - Voice to Text (toggle recording) hyprvoice test-models
bind = SUPER, R, exec, hyprvoice toggle hyprvoice test-models --audio /path/to/sample.wav --output test-models.json
# 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 ### Service management
### 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 ```bash
# Start daemon manually (if not using systemd service) systemctl --user status hyprvoice.service
hyprvoice serve systemctl --user restart hyprvoice.service
journalctl --user -u hyprvoice.service -f
# In another terminal: toggle recording
hyprvoice toggle
# ... speak ...
hyprvoice toggle
# Check what's happening
hyprvoice status
``` ```
## Configuration ## Configuration
Use the interactive configuration 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`
```
This will guide you through setting up: ## Docs
- OpenAI API key for transcription - `docs/config.md` - configuration reference and examples
- Language preferences (auto-detect or specific language) - `docs/providers.md` - provider and model details
- Text injection method (clipboard/typing/fallback) - `docs/architecture.md` - architecture and adapter overview
- Notification settings - `docs/structure.md` - code map and entry points
- Recording timeout - `docs/testing.md` - integration testing with test-models
Configuration is stored in `~/.config/hyprvoice/config.toml` and can also be edited manually. Changes are applied immediately without restarting the daemon.
### Transcription Providers
Hyprvoice supports multiple transcription backends:
#### OpenAI Whisper API
Cloud-based transcription using OpenAI's Whisper API:
```toml
[transcription]
provider = "openai"
api_key = "sk-..." # Or set OPENAI_API_KEY environment variable
language = "" # Empty for auto-detect, or "en", "es", "fr", etc.
model = "whisper-1"
```
**Features:**
- High-quality transcription
- Supports 50+ languages
- Auto-detection or specify language for better accuracy
#### Groq Whisper API (Transcription)
Fast cloud-based transcription using Groq's Whisper API:
```toml
[transcription]
provider = "groq-transcription"
api_key = "gsk_..." # Or set GROQ_API_KEY environment variable
language = "" # Empty for auto-detect, or "en", "es", "fr", etc.
model = "whisper-large-v3" # Or "whisper-large-v3-turbo" for faster processing
```
**Features:**
- Ultra-fast transcription (significantly faster than OpenAI)
- Same Whisper model quality
- Supports 50+ languages
- Free tier available with generous limits
#### Groq Translation API
Fast translation of audio to English using Groq's Whisper API:
```toml
[transcription]
provider = "groq-translation"
api_key = "gsk_..." # Or set GROQ_API_KEY environment variable
language = "es" # Optional: hint source language for better accuracy
model = "whisper-large-v3-turbo"
```
**Features:**
- Translates any language audio → English text
- Ultra-fast processing
- Language field hints at source language (improves accuracy)
- Always outputs English regardless of input language
#### Generated Configuration Example
The daemon automatically creates `~/.config/hyprvoice/config.toml` with helpful comments:
```toml
# Hyprvoice Configuration
# This file is automatically generated with defaults.
# Edit values as needed - changes are applied immediately without daemon restart.
# Audio Recording Configuration
[recording]
sample_rate = 16000 # Audio sample rate in Hz (16000 recommended for speech)
channels = 1 # Number of audio channels (1 = mono, 2 = stereo)
format = "s16" # Audio format (s16 = 16-bit signed integers)
buffer_size = 8192 # Internal buffer size in bytes (larger = less CPU, more latency)
device = "" # PipeWire audio device (empty = use default microphone)
channel_buffer_size = 30 # Audio frame buffer size (frames to buffer)
timeout = "5m" # Maximum recording duration (e.g., "30s", "2m", "5m")
# Speech Transcription Configuration
[transcription]
provider = "openai" # Transcription service: "openai", "groq-transcription", or "groq-translation"
api_key = "" # API key (or set OPENAI_API_KEY/GROQ_API_KEY environment variable)
language = "" # Language code (empty for auto-detect, "en", "it", "es", "fr", etc.)
model = "whisper-1" # Model: OpenAI="whisper-1", Groq="whisper-large-v3" or "whisper-large-v3-turbo"
# Text Injection Configuration
[injection]
backends = ["ydotool", "wtype", "clipboard"] # Ordered fallback chain
ydotool_timeout = "5s" # Timeout for ydotool commands
wtype_timeout = "5s" # Timeout for wtype commands
clipboard_timeout = "3s" # Timeout for clipboard operations
# Desktop Notification Configuration
[notifications]
enabled = true # Enable desktop notifications
type = "desktop" # Notification type ("desktop", "log", "none") -- always keep "desktop" unless debugging
```
#### whisper.cpp Local (Planned) -> Not yet implemented
Private, offline transcription using local models:
```toml
[transcription]
provider = "whisper_cpp"
model_path = "~/models/ggml-base.en.bin"
threads = 4
```
#### Recording Configuration
Audio capture settings:
```toml
[recording]
sample_rate = 16000 # Audio sample rate in Hz
channels = 1 # Number of audio channels (1 for mono)
format = "s16" # Audio format (s16 recommended)
buffer_size = 8192 # Internal buffer size in bytes
device = "" # PipeWire device (empty for default)
channel_buffer_size = 30 # Audio frame buffer size
timeout = "5m" # Maximum recording duration (prevents runaway recordings)
```
**Recording Timeout:**
- Prevents accidental long recordings that could consume resources
- Default: 5 minutes (`"5m"`)
- Format: Go duration strings like `"30s"`, `"2m"`, `"10m"`
- Recording automatically stops when timeout is reached
#### Text Injection
Configurable text injection with multiple backends:
```toml
[injection]
backends = ["ydotool", "wtype", "clipboard"] # Ordered fallback chain
ydotool_timeout = "5s"
wtype_timeout = "5s"
clipboard_timeout = "3s"
```
**Injection Backends:**
- **`ydotool`**: Uses ydotool (requires `ydotoold` daemon for ydotool v1.0.0+). Most compatible with Chromium/Electron apps.
- **`wtype`**: Uses wtype for Wayland. May have issues with some Chromium-based apps (known upstream bug).
- **`clipboard`**: Copies text to clipboard only. Most reliable, but requires manual paste.
**Fallback Chain:**
Backends are tried in order. The first successful one wins. Example configurations:
```toml
# Clipboard only (safest, always works)
backends = ["clipboard"]
# wtype with clipboard fallback
backends = ["wtype", "clipboard"]
# Full fallback chain (default) - best compatibility
backends = ["ydotool", "wtype", "clipboard"]
# ydotool only (if you have it set up)
backends = ["ydotool"]
```
**ydotool Setup:**
ydotool requires the `ydotoold` daemon running (for ydotool v1.0.0+) and access to `/dev/uinput`:
```bash
# Start ydotool daemon (systemd)
systemctl --user enable --now ydotool
# Or add user to input group
sudo usermod -aG input $USER
# Then logout/login
# For Hyprland, add to config to set correct keyboard layout:
# device:ydotoold-virtual-device {
# kb_layout = us
# }
```
**Behavior:**
- Backends are tried in order until one succeeds
- Include `clipboard` in the chain if you want text copied to clipboard as fallback
#### Notifications
Desktop notification settings:
```toml
[notifications]
enabled = true # Enable/disable notifications
type = "desktop" # "desktop", "log", or "none"
```
**Notification Types:**
- **`desktop`**: Use notify-send for desktop notifications
- **`log`**: Log messages to console only
- **`none`**: Disable all notifications
Always keep `type = "desktop"` unless debugging.
##### Custom Notification Messages
You can customize notification text via the `[notifications.messages]` section.
```toml
[notifications.messages]
[notifications.messages.recording_started]
title = "Hyprvoice"
body = "Recording Started"
[notifications.messages.transcribing]
title = "Hyprvoice"
body = "Recording Ended... Transcribing"
[notifications.messages.config_reloaded]
title = "Hyprvoice"
body = "Config Reloaded"
[notifications.messages.operation_cancelled]
title = "Hyprvoice"
body = "Operation Cancelled"
[notifications.messages.recording_aborted]
body = "Recording Aborted"
[notifications.messages.injection_aborted]
body = "Injection Aborted"
```
### Configuration Hot-Reloading
The daemon automatically watches the config file for changes and applies them immediately:
- **Notification settings**: Applied instantly
- **Injection settings**: Applied to current and future operations
- **Recording/Transcription settings**: Applied to new recording sessions
- **Invalid configs**: Rejected with error notification, daemon continues with previous config
### Service 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 (planned)
## Development Status
| Component | Status | Notes |
| ---------------------- | ------ | ----------------------------------------------------- |
| Core daemon & IPC | ✅ | Unix socket control plane |
| Recording workflow | ✅ | Toggle recording via PipeWire |
| Audio capture | ✅ | Efficient PipeWire integration |
| Desktop notifications | ✅ | Status feedback via notify-send |
| OpenAI transcription | ✅ | HTTP API integration |
| Groq transcription | ✅ | Fast Whisper API with transcription and translation |
| Text injection | ✅ | Clipboard + wtype with fallback |
| Configuration system | ✅ | TOML-based user settings with hot-reload |
| Interactive setup | ✅ | `hyprvoice configure` wizard for easy setup |
| 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 |
| Light dictation models | ⏳ | Alternatives to whispers for light and fast dictation |
| whisper.cpp support | ⏳ | Local model inference |
**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 → injecting)
- **State Machine**: `idle → recording → transcribing → 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 --> 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. **Text injection** → Result typed or copied to clipboard
5. **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 → send `inject` action → transcribe collected audio → injecting (simulated)
5. Complete → idle; pipeline stops; daemon clears reference
6. Notifications at key transitions
## Troubleshooting ## Troubleshooting
@@ -653,7 +231,7 @@ sudo apt install libnotify-bin # Ubuntu/Debian
``` ```
- Verify Wayland compositor supports text input protocols - Verify Wayland compositor supports text input protocols
- Check injection mode in configuration (fallback mode is most robust) - Check injection backends in configuration (fallback chain is most robust)
**Clipboard issues:** **Clipboard issues:**
@@ -681,82 +259,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
```bash processing --> injecting: inject_action
# After creating your first GitHub release injecting --> idle: done
cd packaging/ recording --> idle: abort
./setup-aur.sh # One-time AUR repository setup injecting --> idle: abort
``` ```
### 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
│ ├── daemon/ # Control daemon (lifecycle management) 6. **Return to idle** → Pipeline cleaned up, ready for next session
│ ├── injection/ # Text injection (clipboard + wtype)
│ ├── notify/ # Desktop notification integration
│ ├── pipeline/ # Audio processing pipeline + state machine
│ ├── recording/ # PipeWire audio capture
│ └── transcriber/ # Transcription adapters (OpenAI, whisper.cpp)
├── 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
+549
View File
@@ -0,0 +1,549 @@
//go:build integration
package main
import (
"context"
"encoding/binary"
"errors"
"fmt"
"math"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strings"
"testing"
"time"
"github.com/leonardotrapani/hyprvoice/internal/config"
"github.com/leonardotrapani/hyprvoice/internal/llm"
"github.com/leonardotrapani/hyprvoice/internal/models/whisper"
"github.com/leonardotrapani/hyprvoice/internal/provider"
"github.com/leonardotrapani/hyprvoice/internal/recording"
"github.com/leonardotrapani/hyprvoice/internal/transcriber"
)
const (
testSampleRate = 16000
testChannels = 1
testBitsPerSample = 16
testTimeout = 45 * time.Second
)
var testKeywords = []string{"Hyprvoice", "transcription", "dictation"}
func TestTranscriptionModels(t *testing.T) {
audio, err := loadTestAudio(t)
if err != nil {
t.Fatalf("failed to load test audio: %v", err)
}
cfg := loadTestConfig(t)
providerNames := provider.ListProvidersWithTranscription()
sort.Strings(providerNames)
smallestLocalModel := selectSmallestLocalModel()
for _, providerName := range providerNames {
p := provider.GetProvider(providerName)
if p == nil {
continue
}
models := provider.ModelsOfType(p, provider.Transcription)
sort.Slice(models, func(i, j int) bool {
return models[i].ID < models[j].ID
})
for _, model := range models {
if model.Local && providerName == provider.ProviderWhisperCpp && model.ID != smallestLocalModel {
continue
}
modes := getModesForModel(model)
languages := getLanguagesForModel(model)
keywordOptions := []bool{true, false}
for _, mode := range modes {
for _, lang := range languages {
for _, useKeywords := range keywordOptions {
testName := fmt.Sprintf("%s/%s/%s/lang=%s/keywords=%v",
providerName, model.ID, mode, langDisplay(lang), useKeywords)
model := model
mode := mode
lang := lang
useKeywords := useKeywords
providerName := providerName
t.Run(testName, func(t *testing.T) {
t.Parallel()
runTranscriptionTest(t, cfg, providerName, model, mode, lang, useKeywords, audio)
})
}
}
}
}
}
}
func TestLLMModels(t *testing.T) {
cfg := loadTestConfig(t)
providerNames := provider.ListProvidersWithLLM()
sort.Strings(providerNames)
for _, providerName := range providerNames {
p := provider.GetProvider(providerName)
if p == nil {
continue
}
models := provider.ModelsOfType(p, provider.LLM)
sort.Slice(models, func(i, j int) bool {
return models[i].ID < models[j].ID
})
for _, model := range models {
for _, useKeywords := range []bool{true, false} {
testName := fmt.Sprintf("%s/%s/keywords=%v", providerName, model.ID, useKeywords)
model := model
useKeywords := useKeywords
providerName := providerName
t.Run(testName, func(t *testing.T) {
t.Parallel()
runLLMTest(t, cfg, providerName, model, useKeywords)
})
}
}
}
}
func runTranscriptionTest(t *testing.T, cfg *config.Config, providerName string, model provider.Model, mode, lang string, useKeywords bool, audio []byte) {
if model.Local {
if _, err := exec.LookPath("whisper-cli"); err != nil {
t.Skip("whisper-cli not found")
}
if !whisper.IsInstalled(model.ID) {
t.Skipf("local model %s not installed", model.ID)
}
}
apiKey := resolveTestAPIKey(cfg, providerName)
if testProviderRequiresKey(providerName) && apiKey == "" {
t.Skipf("missing api key for %s", providerName)
}
var keywords []string
if useKeywords {
keywords = testKeywords
}
streaming := mode == "streaming"
transcribeCfg := transcriber.Config{
Provider: providerName,
APIKey: apiKey,
Language: lang,
Model: model.ID,
Keywords: keywords,
Threads: 0,
Streaming: streaming,
}
ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
defer cancel()
text, err := runTestTranscriber(ctx, transcribeCfg, audio)
if err != nil {
t.Errorf("transcription failed: %v", err)
return
}
text = strings.TrimSpace(text)
if text == "" {
t.Error("transcription returned empty text")
return
}
t.Logf("output (%d chars): %q", len(text), truncateTestString(text, 100))
}
func runLLMTest(t *testing.T, cfg *config.Config, providerName string, model provider.Model, useKeywords bool) {
apiKey := resolveTestAPIKey(cfg, providerName)
if testProviderRequiresKey(providerName) && apiKey == "" {
t.Skipf("missing api key for %s", providerName)
}
var keywords []string
if useKeywords {
keywords = testKeywords
}
llmCfg := llm.Config{
Provider: providerName,
APIKey: apiKey,
Model: model.ID,
RemoveStutters: true,
AddPunctuation: true,
FixGrammar: true,
RemoveFillerWords: true,
CustomPrompt: "",
Keywords: keywords,
}
adapter, err := llm.NewAdapter(llmCfg)
if err != nil {
t.Errorf("failed to create adapter: %v", err)
return
}
input := "uh i i i want to test hyprvoice you know this is just a cleanup check"
ctx, cancel := context.WithTimeout(context.Background(), testTimeout)
defer cancel()
output, err := adapter.Process(ctx, input)
if err != nil {
t.Errorf("llm processing failed: %v", err)
return
}
output = strings.TrimSpace(output)
if output == "" {
t.Error("llm returned empty output")
return
}
t.Logf("output (%d chars): %q", len(output), truncateTestString(output, 100))
}
func runTestTranscriber(ctx context.Context, cfg transcriber.Config, audio []byte) (string, error) {
tr, err := transcriber.NewTranscriber(cfg)
if err != nil {
return "", err
}
frameCh := make(chan recording.AudioFrame, 8)
errCh, err := tr.Start(ctx, frameCh)
if err != nil {
return "", err
}
sendErr := sendTestAudioFrames(ctx, frameCh, audio)
close(frameCh)
stopErr := tr.Stop(ctx)
errChErr := readTestErrorChannel(errCh)
if sendErr != nil {
return "", sendErr
}
if stopErr != nil {
return "", stopErr
}
if errChErr != nil {
return "", errChErr
}
return tr.GetFinalTranscription()
}
func sendTestAudioFrames(ctx context.Context, frameCh chan<- recording.AudioFrame, audio []byte) error {
const chunkBytes = 3200
bytesPerSecond := testSampleRate * (testBitsPerSample / 8) * testChannels
chunkDuration := time.Duration(float64(chunkBytes) / float64(bytesPerSecond) * float64(time.Second))
for offset := 0; offset < len(audio); offset += chunkBytes {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
end := offset + chunkBytes
if end > len(audio) {
end = len(audio)
}
frame := recording.AudioFrame{Data: audio[offset:end], Timestamp: time.Now()}
select {
case frameCh <- frame:
case <-ctx.Done():
return ctx.Err()
}
time.Sleep(chunkDuration)
}
return nil
}
func readTestErrorChannel(errCh <-chan error) error {
if errCh == nil {
return nil
}
var firstErr error
idleTimer := time.NewTimer(150 * time.Millisecond)
defer idleTimer.Stop()
for {
select {
case err, ok := <-errCh:
if !ok {
return firstErr
}
if err != nil && firstErr == nil {
firstErr = err
}
if !idleTimer.Stop() {
<-idleTimer.C
}
idleTimer.Reset(150 * time.Millisecond)
case <-idleTimer.C:
return firstErr
}
}
}
func loadTestAudio(t *testing.T) ([]byte, error) {
_, currentFile, _, ok := runtime.Caller(0)
if !ok {
return nil, fmt.Errorf("could not determine current file path")
}
projectRoot := filepath.Dir(filepath.Dir(filepath.Dir(currentFile)))
samplePath := filepath.Join(projectRoot, "testdata", "sample.wav")
data, err := os.ReadFile(samplePath)
if err != nil {
return nil, fmt.Errorf("could not read sample audio: %w", err)
}
return parseTestWAV(data)
}
func parseTestWAV(data []byte) ([]byte, error) {
if len(data) < 12 {
return nil, fmt.Errorf("invalid wav: too short")
}
if string(data[0:4]) != "RIFF" || string(data[8:12]) != "WAVE" {
return nil, fmt.Errorf("invalid wav: missing riff/wave header")
}
offset := 12
var fmtFound, dataFound bool
var sampleRate, channels, bitsPerSample int
var audioData []byte
for offset+8 <= len(data) {
chunkID := string(data[offset : offset+4])
chunkSize := int(binary.LittleEndian.Uint32(data[offset+4 : offset+8]))
offset += 8
if offset+chunkSize > len(data) {
return nil, fmt.Errorf("invalid wav: chunk overflows file")
}
switch chunkID {
case "fmt ":
if chunkSize < 16 {
return nil, fmt.Errorf("invalid wav: fmt chunk too short")
}
audioFormat := binary.LittleEndian.Uint16(data[offset : offset+2])
if audioFormat != 1 {
return nil, fmt.Errorf("unsupported wav format: %d", audioFormat)
}
channels = int(binary.LittleEndian.Uint16(data[offset+2 : offset+4]))
sampleRate = int(binary.LittleEndian.Uint32(data[offset+4 : offset+8]))
bitsPerSample = int(binary.LittleEndian.Uint16(data[offset+14 : offset+16]))
fmtFound = true
case "data":
audioData = data[offset : offset+chunkSize]
dataFound = true
}
offset += chunkSize
if chunkSize%2 == 1 {
offset++
}
}
if !fmtFound || !dataFound {
return nil, fmt.Errorf("invalid wav: missing fmt or data chunk")
}
if bitsPerSample != testBitsPerSample {
return nil, fmt.Errorf("unsupported wav bits per sample: %d", bitsPerSample)
}
monoData, err := downmixTestToMono(audioData, channels)
if err != nil {
return nil, err
}
resampled := resampleTestPCM16(monoData, sampleRate, testSampleRate)
if len(resampled) == 0 {
return nil, fmt.Errorf("invalid wav: empty audio data")
}
return resampled, nil
}
func downmixTestToMono(data []byte, channels int) ([]byte, error) {
if channels == 1 {
return data, nil
}
if channels <= 0 {
return nil, fmt.Errorf("invalid channel count: %d", channels)
}
frameSize := 2 * channels
if len(data)%frameSize != 0 {
return nil, fmt.Errorf("invalid pcm data length")
}
frames := len(data) / frameSize
out := make([]byte, frames*2)
for i := 0; i < frames; i++ {
var sum int32
for c := 0; c < channels; c++ {
idx := (i*channels + c) * 2
sample := int16(binary.LittleEndian.Uint16(data[idx : idx+2]))
sum += int32(sample)
}
mono := int16(sum / int32(channels))
out[i*2] = byte(mono)
out[i*2+1] = byte(mono >> 8)
}
return out, nil
}
func resampleTestPCM16(data []byte, inRate, outRate int) []byte {
if inRate <= 0 || outRate <= 0 || inRate == outRate {
return data
}
if len(data) < 2 {
return data
}
numInSamples := len(data) / 2
numOutSamples := int(math.Round(float64(numInSamples) * float64(outRate) / float64(inRate)))
if numOutSamples <= 0 {
return nil
}
out := make([]byte, numOutSamples*2)
for i := 0; i < numOutSamples; i++ {
srcPos := float64(i) * float64(inRate) / float64(outRate)
srcIdx := int(srcPos)
frac := srcPos - float64(srcIdx)
sample1 := sampleTestAtPCM16(data, srcIdx)
sample2 := sampleTestAtPCM16(data, srcIdx+1)
outSample := int16(float64(sample1)*(1-frac) + float64(sample2)*frac)
out[i*2] = byte(outSample)
out[i*2+1] = byte(outSample >> 8)
}
return out
}
func sampleTestAtPCM16(data []byte, idx int) int16 {
if idx <= 0 {
return int16(binary.LittleEndian.Uint16(data[0:2]))
}
pos := idx * 2
if pos+1 >= len(data) {
last := len(data) - 2
if last < 0 {
return 0
}
return int16(binary.LittleEndian.Uint16(data[last : last+2]))
}
return int16(binary.LittleEndian.Uint16(data[pos : pos+2]))
}
func loadTestConfig(t *testing.T) *config.Config {
cfg, err := config.Load()
if err != nil {
if errors.Is(err, config.ErrConfigNotFound) {
return config.DefaultConfig()
}
t.Logf("warning: could not load config: %v", err)
return config.DefaultConfig()
}
if cfg.Providers == nil {
cfg.Providers = make(map[string]config.ProviderConfig)
}
return cfg
}
func resolveTestAPIKey(cfg *config.Config, providerName string) string {
base := provider.BaseProviderName(providerName)
if cfg != nil && cfg.Providers != nil {
if pc, ok := cfg.Providers[base]; ok && pc.APIKey != "" {
return pc.APIKey
}
}
if envVar := provider.EnvVarForProvider(providerName); envVar != "" {
return os.Getenv(envVar)
}
return ""
}
func testProviderRequiresKey(providerName string) bool {
p := provider.GetProvider(provider.BaseProviderName(providerName))
if p == nil {
return false
}
return p.RequiresAPIKey()
}
func selectSmallestLocalModel() string {
models := whisper.ListModels()
if len(models) == 0 {
return ""
}
sort.Slice(models, func(i, j int) bool {
if models[i].SizeBytes == models[j].SizeBytes {
return !models[i].Multilingual && models[j].Multilingual
}
return models[i].SizeBytes < models[j].SizeBytes
})
return models[0].ID
}
func getModesForModel(model provider.Model) []string {
if model.SupportsBothModes() {
return []string{"batch", "streaming"}
}
if model.SupportsStreaming && !model.SupportsBatch {
return []string{"streaming"}
}
return []string{"batch"}
}
func getLanguagesForModel(model provider.Model) []string {
// always test auto-detect, plus the first supported language if available
if len(model.SupportedLanguages) > 0 {
return []string{model.SupportedLanguages[0], ""}
}
return []string{""}
}
func langDisplay(lang string) string {
if lang == "" {
return "auto"
}
return lang
}
func truncateTestString(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max] + "..."
}
+300 -562
View File
@@ -1,18 +1,22 @@
package main package main
import ( import (
"bufio" "context"
"errors"
"fmt" "fmt"
"os" "io"
"log"
"os/exec" "os/exec"
"strconv" "path/filepath"
"sort"
"strings" "strings"
"time"
"github.com/leonardotrapani/hyprvoice/internal/bus" "github.com/leonardotrapani/hyprvoice/internal/bus"
"github.com/leonardotrapani/hyprvoice/internal/config" "github.com/leonardotrapani/hyprvoice/internal/config"
"github.com/leonardotrapani/hyprvoice/internal/daemon" "github.com/leonardotrapani/hyprvoice/internal/daemon"
"github.com/leonardotrapani/hyprvoice/internal/notify" "github.com/leonardotrapani/hyprvoice/internal/models/whisper"
"github.com/leonardotrapani/hyprvoice/internal/provider"
"github.com/leonardotrapani/hyprvoice/internal/tui"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -33,7 +37,9 @@ func init() {
statusCmd(), statusCmd(),
versionCmd(), versionCmd(),
stopCmd(), stopCmd(),
onboardingCmd(),
configureCmd(), configureCmd(),
modelCmd(),
) )
} }
@@ -127,448 +133,96 @@ func cancelCmd() *cobra.Command {
} }
func configureCmd() *cobra.Command { func configureCmd() *cobra.Command {
return &cobra.Command{ cmd := &cobra.Command{
Use: "configure", Use: "configure",
Short: "Interactive configuration setup", Short: "Interactive configuration setup",
Long: `Interactive configuration wizard for hyprvoice. Long: `Interactive configuration wizard for hyprvoice.
This will guide you through setting up: This will guide you through setting up:
- Transcription provider (OpenAI, Groq, or Mistral) - Provider API keys (OpenAI, Groq, Mistral, ElevenLabs)
- API keys and model selection - Transcription settings
- Audio and text injection preferences - LLM post-processing
- Notification settings`, - 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 runInteractiveConfig() return runConfigure(false)
},
}
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 runInteractiveConfig() error { func runConfigure(onboarding bool) error {
fmt.Println("🎤 Hyprvoice Configuration Wizard") var cfg *config.Config
fmt.Println("==================================") var err error
fmt.Println() if onboarding {
cfg, err = loadConfigQuiet()
// Load existing config or create default if err != nil {
cfg, err := config.Load() if errors.Is(err, config.ErrConfigNotFound) {
if err != nil { cfg = config.DefaultConfig()
return fmt.Errorf("failed to load config: %w", err) } else {
} return fmt.Errorf("failed to load config: %w", err)
scanner := bufio.NewScanner(os.Stdin)
// Configure transcription
fmt.Println("📝 Transcription Configuration")
fmt.Println("------------------------------")
// Provider selection
for {
fmt.Println("Select transcription provider:")
fmt.Println(" 1. openai - OpenAI Whisper API (cloud-based)")
fmt.Println(" 2. groq-transcription - Groq Whisper API (fast transcription)")
fmt.Println(" 3. groq-translation - Groq Whisper API (translate to English)")
fmt.Println(" 4. mistral-transcription - Mistral Voxtral API (excellent for European languages)")
fmt.Println(" 5. elevenlabs - ElevenLabs Scribe API (99 languages, excellent accuracy)")
fmt.Printf("Provider [1-5] (current: %s): ", cfg.Transcription.Provider)
if !scanner.Scan() {
break
}
input := strings.TrimSpace(scanner.Text())
if input == "" {
break // keep current
}
switch input {
case "1":
cfg.Transcription.Provider = "openai"
case "2":
cfg.Transcription.Provider = "groq-transcription"
case "3":
cfg.Transcription.Provider = "groq-translation"
case "4":
cfg.Transcription.Provider = "mistral-transcription"
case "5":
cfg.Transcription.Provider = "elevenlabs"
case "openai", "groq-transcription", "groq-translation", "mistral-transcription", "elevenlabs":
cfg.Transcription.Provider = input
default:
fmt.Println("❌ Error: invalid provider. Please enter 1-5 or provider name.")
fmt.Println()
continue
}
break
}
// Model selection based on provider
switch cfg.Transcription.Provider {
case "openai":
fmt.Println("\nOpenAI Model:")
fmt.Printf("Model (current: %s): ", cfg.Transcription.Model)
if scanner.Scan() {
input := strings.TrimSpace(scanner.Text())
if input != "" {
cfg.Transcription.Model = input
} else if cfg.Transcription.Model == "" {
cfg.Transcription.Model = "whisper-1"
} }
} }
case "groq-transcription":
for {
fmt.Println("\nGroq Transcription Model:")
fmt.Println(" 1. whisper-large-v3 - Standard model")
fmt.Println(" 2. whisper-large-v3-turbo - Faster model")
fmt.Printf("Model [1-2] (current: %s): ", cfg.Transcription.Model)
if !scanner.Scan() {
break
}
input := strings.TrimSpace(scanner.Text())
switch input {
case "1":
cfg.Transcription.Model = "whisper-large-v3"
case "2":
cfg.Transcription.Model = "whisper-large-v3-turbo"
case "whisper-large-v3", "whisper-large-v3-turbo":
cfg.Transcription.Model = input
case "":
if cfg.Transcription.Model == "" {
cfg.Transcription.Model = "whisper-large-v3-turbo"
}
default:
fmt.Println("❌ Error: invalid model. Please enter 1, 2 or model name.")
continue
}
break
}
case "groq-translation":
for {
fmt.Println("\nGroq Translation Model:")
fmt.Println(" Note: Translation only supports whisper-large-v3 (turbo not available)")
fmt.Printf("Model (current: %s, press Enter for whisper-large-v3): ", cfg.Transcription.Model)
if !scanner.Scan() {
break
}
input := strings.TrimSpace(scanner.Text())
if input == "" || input == "whisper-large-v3" || input == "1" {
cfg.Transcription.Model = "whisper-large-v3"
break
}
fmt.Println("❌ Error: only whisper-large-v3 is supported for translation.")
}
case "mistral-transcription":
for {
fmt.Println("\nMistral Voxtral Model:")
fmt.Println(" 1. voxtral-mini-latest - Recommended (latest version)")
fmt.Println(" 2. voxtral-mini-2507 - Pinned version")
fmt.Printf("Model [1-2] (current: %s): ", cfg.Transcription.Model)
if !scanner.Scan() {
break
}
input := strings.TrimSpace(scanner.Text())
switch input {
case "1":
cfg.Transcription.Model = "voxtral-mini-latest"
case "2":
cfg.Transcription.Model = "voxtral-mini-2507"
case "voxtral-mini-latest", "voxtral-mini-2507":
cfg.Transcription.Model = input
case "":
if cfg.Transcription.Model == "" || !strings.HasPrefix(cfg.Transcription.Model, "voxtral") {
cfg.Transcription.Model = "voxtral-mini-latest"
}
default:
fmt.Println("❌ Error: invalid model. Please enter 1, 2 or model name.")
continue
}
break
}
case "elevenlabs":
for {
fmt.Println("\nElevenLabs Scribe Model:")
fmt.Println(" Language Support:")
fmt.Println(" scribe_v1: 99 languages (96.7% accuracy for English, ≤5% WER for Portuguese)")
fmt.Println(" scribe_v2: 90 languages (real-time optimized, lower latency)")
fmt.Println()
fmt.Println(" Available Models:")
fmt.Println(" 1. scribe_v1 - Best accuracy, full timestamps (recommended)")
fmt.Println(" 2. scribe_v2 - Real-time streaming, lower latency")
fmt.Printf("Model [1-2] (current: %s): ", cfg.Transcription.Model)
if !scanner.Scan() {
break
}
input := strings.TrimSpace(scanner.Text())
switch input {
case "1":
cfg.Transcription.Model = "scribe_v1"
case "2":
cfg.Transcription.Model = "scribe_v2"
case "scribe_v1", "scribe_v2":
cfg.Transcription.Model = input
case "":
if cfg.Transcription.Model == "" {
cfg.Transcription.Model = "scribe_v1"
}
default:
fmt.Println("❌ Error: invalid model. Please enter 1, 2 or model name.")
continue
}
break
}
}
// API Key (provider-aware)
var envVarName string
switch cfg.Transcription.Provider {
case "openai":
envVarName = "OPENAI_API_KEY"
case "mistral-transcription":
envVarName = "MISTRAL_API_KEY"
case "elevenlabs":
envVarName = "ELEVENLABS_API_KEY"
default:
envVarName = "GROQ_API_KEY"
}
fmt.Printf("\nAPI Key (current: %s, leave empty to use %s env var): ", maskAPIKey(cfg.Transcription.APIKey), envVarName)
if scanner.Scan() {
input := strings.TrimSpace(scanner.Text())
if input != "" {
cfg.Transcription.APIKey = input
}
}
// Language
if cfg.Transcription.Provider == "groq-translation" {
fmt.Printf("\nSource language hint (empty for auto-detect, current: %s): ", cfg.Transcription.Language)
fmt.Println("\n Note: Translation always outputs English. Language hints at source audio language.")
} else if cfg.Transcription.Provider == "elevenlabs" {
fmt.Println("\nLanguage Performance:")
fmt.Println(" Excellent (≤5% WER): English, Portuguese, +25 languages")
fmt.Println(" High (5-10% WER): French, German, Spanish, Italian, etc.")
fmt.Println(" Good (10-20% WER): Most supported languages")
fmt.Println(" Leave empty for auto-detection (recommended)")
fmt.Printf("Language (current: %s): ", cfg.Transcription.Language)
} else { } else {
fmt.Printf("\nLanguage (empty for auto-detect, current: %s): ", cfg.Transcription.Language) cfg, err = loadConfigQuiet()
} if err != nil {
if scanner.Scan() { return fmt.Errorf("failed to load config: %w", err)
input := strings.TrimSpace(scanner.Text())
cfg.Transcription.Language = input
}
fmt.Println()
// Configure injection
for {
fmt.Println("⌨️ Text Injection Configuration")
fmt.Println("--------------------------------")
fmt.Println("Backends are tried in order until one succeeds (fallback chain):")
fmt.Println(" - ydotool: Best for Chromium/Electron apps (requires ydotoold daemon for ydotool v1.0.0+)")
fmt.Println(" - wtype: Native Wayland typing (may fail on some Chromium apps)")
fmt.Println(" - clipboard: Copies to clipboard only (most reliable, needs manual paste)")
fmt.Println()
fmt.Println("Recommended: ydotool,wtype,clipboard (full fallback chain)")
fmt.Println()
fmt.Printf("Backends (comma-separated) (current: %s): ", strings.Join(cfg.Injection.Backends, ","))
if !scanner.Scan() {
break
}
input := strings.TrimSpace(scanner.Text())
if input == "" {
break // keep current
}
backends := strings.Split(input, ",")
validBackends := make([]string, 0)
invalidBackends := make([]string, 0)
for _, b := range backends {
b = strings.TrimSpace(b)
if b == "ydotool" || b == "wtype" || b == "clipboard" {
validBackends = append(validBackends, b)
} else if b != "" {
invalidBackends = append(invalidBackends, b)
}
}
if len(invalidBackends) > 0 {
fmt.Printf("❌ Error: invalid backend(s): %s. Valid: ydotool, wtype, clipboard.\n", strings.Join(invalidBackends, ", "))
fmt.Println()
continue
}
if len(validBackends) == 0 {
fmt.Println("❌ Error: at least one backend required.")
fmt.Println()
continue
}
cfg.Injection.Backends = validBackends
break
}
// Check if ydotool is selected and warn about daemon requirement
for _, b := range cfg.Injection.Backends {
if b == "ydotool" {
fmt.Println()
fmt.Println("⚠️ ydotool requires the ydotoold daemon to be running! make sure it works")
fmt.Println()
break
} }
} }
fmt.Println() // Run TUI wizard
result, err := tui.Run(cfg, onboarding)
// Configure notifications if err != nil {
for { return fmt.Errorf("configuration wizard error: %w", err)
fmt.Println("🔔 Notification Configuration")
fmt.Println("-----------------------------")
fmt.Printf("Enable notifications [y/n] (current: %v): ", cfg.Notifications.Enabled)
if !scanner.Scan() {
break
}
input := strings.TrimSpace(strings.ToLower(scanner.Text()))
switch input {
case "y", "yes":
cfg.Notifications.Enabled = true
case "n", "no":
cfg.Notifications.Enabled = false
case "":
// keep current
default:
fmt.Println("❌ Error: please enter y or n.")
fmt.Println()
continue
}
break
} }
// Ask if user wants to customize notification messages if result.Cancelled {
fmt.Print("Customize notification messages? [y/n] (default: n): ") fmt.Println("Configuration cancelled.")
if scanner.Scan() { return nil
input := strings.TrimSpace(strings.ToLower(scanner.Text()))
if input == "y" || input == "yes" {
fmt.Println()
// Get resolved values (user config merged with defaults)
msgs := cfg.Notifications.Messages.Resolve()
// Recording Started
fmt.Println(" Recording Started notification:")
fmt.Printf(" Title (current: %s): ", msgs[notify.MsgRecordingStarted].Title)
if scanner.Scan() {
if t := strings.TrimSpace(scanner.Text()); t != "" {
cfg.Notifications.Messages.RecordingStarted.Title = t
}
}
fmt.Printf(" Body (current: %s): ", msgs[notify.MsgRecordingStarted].Body)
if scanner.Scan() {
if b := strings.TrimSpace(scanner.Text()); b != "" {
cfg.Notifications.Messages.RecordingStarted.Body = b
}
}
fmt.Println()
// Transcribing
fmt.Println(" Transcribing notification:")
fmt.Printf(" Title (current: %s): ", msgs[notify.MsgTranscribing].Title)
if scanner.Scan() {
if t := strings.TrimSpace(scanner.Text()); t != "" {
cfg.Notifications.Messages.Transcribing.Title = t
}
}
fmt.Printf(" Body (current: %s): ", msgs[notify.MsgTranscribing].Body)
if scanner.Scan() {
if b := strings.TrimSpace(scanner.Text()); b != "" {
cfg.Notifications.Messages.Transcribing.Body = b
}
}
fmt.Println()
// Config Reloaded
fmt.Println(" Config Reloaded notification:")
fmt.Printf(" Title (current: %s): ", msgs[notify.MsgConfigReloaded].Title)
if scanner.Scan() {
if t := strings.TrimSpace(scanner.Text()); t != "" {
cfg.Notifications.Messages.ConfigReloaded.Title = t
}
}
fmt.Printf(" Body (current: %s): ", msgs[notify.MsgConfigReloaded].Body)
if scanner.Scan() {
if b := strings.TrimSpace(scanner.Text()); b != "" {
cfg.Notifications.Messages.ConfigReloaded.Body = b
}
}
fmt.Println()
// Operation Cancelled
fmt.Println(" Operation Cancelled notification:")
fmt.Printf(" Title (current: %s): ", msgs[notify.MsgOperationCancelled].Title)
if scanner.Scan() {
if t := strings.TrimSpace(scanner.Text()); t != "" {
cfg.Notifications.Messages.OperationCancelled.Title = t
}
}
fmt.Printf(" Body (current: %s): ", msgs[notify.MsgOperationCancelled].Body)
if scanner.Scan() {
if b := strings.TrimSpace(scanner.Text()); b != "" {
cfg.Notifications.Messages.OperationCancelled.Body = b
}
}
fmt.Println()
// Recording Aborted (body only)
fmt.Println(" Recording Aborted notification:")
fmt.Printf(" Body (current: %s): ", msgs[notify.MsgRecordingAborted].Body)
if scanner.Scan() {
if b := strings.TrimSpace(scanner.Text()); b != "" {
cfg.Notifications.Messages.RecordingAborted.Body = b
}
}
fmt.Println()
// Injection Aborted (body only)
fmt.Println(" Injection Aborted notification:")
fmt.Printf(" Body (current: %s): ", msgs[notify.MsgInjectionAborted].Body)
if scanner.Scan() {
if b := strings.TrimSpace(scanner.Text()); b != "" {
cfg.Notifications.Messages.InjectionAborted.Body = b
}
}
}
} }
fmt.Println()
// Configure recording timeout
for {
fmt.Println("⏱️ Recording Configuration")
fmt.Println("---------------------------")
fmt.Printf("Recording timeout in minutes (current: %.0f): ", cfg.Recording.Timeout.Minutes())
if !scanner.Scan() {
break
}
input := strings.TrimSpace(scanner.Text())
if input == "" {
break // keep current
}
minutes, err := strconv.Atoi(input)
if err != nil || minutes <= 0 {
fmt.Println("❌ Error: please enter a positive number.")
fmt.Println()
continue
}
cfg.Recording.Timeout = time.Duration(minutes) * time.Minute
break
}
fmt.Println()
// Validate configuration // Validate configuration
if err := cfg.Validate(); err != nil { if err := result.Config.Validate(); err != nil {
fmt.Printf("Configuration validation failed: %v\n", err) fmt.Printf("Configuration validation failed: %v\n", err)
fmt.Println("Please check your inputs and try again.")
return err return err
} }
// Save configuration // Save configuration
fmt.Println("💾 Saving configuration...") if err := config.Save(result.Config); err != nil {
if err := saveConfig(cfg); err != nil {
return fmt.Errorf("failed to save config: %w", err) return fmt.Errorf("failed to save config: %w", err)
} }
fmt.Println("✅ Configuration saved successfully!") fmt.Println()
fmt.Println("Configuration saved successfully!")
fmt.Println() fmt.Println()
// Show next steps
showNextSteps(result.Config, onboarding)
return nil
}
func loadConfigQuiet() (*config.Config, error) {
prev := log.Writer()
log.SetOutput(io.Discard)
defer log.SetOutput(prev)
return config.Load()
}
func showNextSteps(cfg *config.Config, onboarding bool) {
// Check if service is running // Check if service is running
serviceRunning := false serviceRunning := false
if _, err := exec.Command("systemctl", "--user", "is-active", "--quiet", "hyprvoice.service").CombinedOutput(); err == nil { if _, err := exec.Command("systemctl", "--user", "is-active", "--quiet", "hyprvoice.service").CombinedOutput(); err == nil {
@@ -584,177 +238,261 @@ func runInteractiveConfig() error {
} }
} }
// Show next steps fmt.Println("Next Steps:")
fmt.Println("🚀 Next Steps:")
step := 1 step := 1
if hasYdotool { if hasYdotool {
fmt.Printf("%d. Ensure ydotoold is running\n", step) fmt.Printf("%d. Ensure ydotoold is running\n", step)
step++ step++
} }
if !serviceRunning { if serviceRunning {
fmt.Printf("%d. Start the service: systemctl --user start hyprvoice.service\n", step)
} else {
fmt.Printf("%d. Restart the service to apply changes: systemctl --user restart hyprvoice.service\n", step) fmt.Printf("%d. Restart the service to apply changes: systemctl --user restart hyprvoice.service\n", step)
step++
} else if onboarding {
fmt.Printf("%d. Enable the service: systemctl --user enable --now hyprvoice.service\n", step)
step++
} else {
fmt.Printf("%d. Start the service if it is not running\n", step)
step++
} }
step++ fmt.Printf("%d. Test voice input: hyprvoice toggle\n", step)
fmt.Printf("%d. Test voice input: hyprvoice toggle (or use keybind you configured in hyprland config)\n", step)
fmt.Println() fmt.Println()
configPath, _ := config.GetConfigPath() configPath, _ := config.GetConfigPath()
fmt.Printf("📁 Config file location: %s\n", configPath) 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)
}
func modelCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "model",
Short: "Manage transcription models",
}
cmd.AddCommand(modelListCmd())
cmd.AddCommand(modelDownloadCmd())
cmd.AddCommand(modelRemoveCmd())
return cmd
}
func modelListCmd() *cobra.Command {
var providerFilter string
var typeFilter string
cmd := &cobra.Command{
Use: "list",
Short: "List available transcription and LLM models",
RunE: func(cmd *cobra.Command, args []string) error {
return runModelList(providerFilter, typeFilter)
},
}
cmd.Flags().StringVar(&providerFilter, "provider", "", "filter by provider name")
cmd.Flags().StringVar(&typeFilter, "type", "", "filter by type: transcription, llm")
return cmd
}
func runModelList(providerFilter, typeFilter string) error {
// parse type filter
var filterType *provider.ModelType
if typeFilter != "" {
switch strings.ToLower(typeFilter) {
case "transcription":
t := provider.Transcription
filterType = &t
case "llm":
t := provider.LLM
filterType = &t
default:
return fmt.Errorf("invalid type: %s (use 'transcription' or 'llm')", typeFilter)
}
}
// get providers to iterate
providerNames := provider.ListProviders()
sort.Strings(providerNames)
// filter by provider if specified
if providerFilter != "" {
found := false
for _, name := range providerNames {
if name == providerFilter {
providerNames = []string{name}
found = true
break
}
}
if !found {
return fmt.Errorf("unknown provider: %s", providerFilter)
}
}
for _, providerName := range providerNames {
p := provider.GetProvider(providerName)
if p == nil {
continue
}
models := p.Models()
if filterType != nil {
models = provider.ModelsOfType(p, *filterType)
}
if len(models) == 0 {
continue
}
// print provider header
fmt.Printf("\n%s:\n", providerName)
for _, m := range models {
printModelLine(m)
}
}
fmt.Println()
return nil return nil
} }
func formatBackends(backends []string) string { func printModelLine(m provider.Model) {
quoted := make([]string, len(backends)) // build prefix: checkmark for installed local models
for i, b := range backends { prefix := " "
quoted[i] = fmt.Sprintf(`"%s"`, b) if m.Local {
if whisper.IsInstalled(m.ID) {
prefix = " [x]"
} else {
prefix = " [ ]"
}
} }
return strings.Join(quoted, ", ")
// build suffix parts
var parts []string
// type indicator
if m.Type == provider.LLM {
parts = append(parts, "llm")
}
// mode capabilities indicator
if m.SupportsBothModes() {
parts = append(parts, "batch+streaming")
} else if m.SupportsStreaming {
parts = append(parts, "streaming")
}
// size for local models
if m.LocalInfo != nil && m.LocalInfo.Size != "" {
parts = append(parts, m.LocalInfo.Size)
}
// build line
line := fmt.Sprintf("%s %s", prefix, m.ID)
if m.Description != "" {
line += fmt.Sprintf(" - %s", m.Description)
}
if len(parts) > 0 {
line += fmt.Sprintf(" [%s]", strings.Join(parts, ", "))
}
fmt.Println(line)
} }
func maskAPIKey(key string) string { func modelDownloadCmd() *cobra.Command {
if key == "" { return &cobra.Command{
return "<not set>" Use: "download <model-name>",
Short: "Download a local model (e.g. whisper models)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runModelDownload(cmd.Context(), args[0])
},
} }
if len(key) <= 8 {
return "****"
}
return key[:4] + "****" + key[len(key)-4:]
} }
func saveConfig(cfg *config.Config) error { func runModelDownload(ctx context.Context, modelName string) error {
configPath, err := config.GetConfigPath() // find the model across all providers
model, _, err := provider.FindModelByID(modelName)
if err != nil { if err != nil {
return err return fmt.Errorf("unknown model: %s", modelName)
} }
file, err := os.Create(configPath) // check if it needs download (local model)
if !model.NeedsDownload() {
fmt.Printf("model '%s' is a cloud model and does not require download\n", modelName)
return nil
}
// check if already installed
if whisper.IsInstalled(modelName) {
path := whisper.GetModelPath(modelName)
fmt.Printf("model '%s' is already installed at %s\n", modelName, path)
return nil
}
// download with progress
fmt.Printf("downloading %s", modelName)
if model.LocalInfo != nil && model.LocalInfo.Size != "" {
fmt.Printf(" (%s)", model.LocalInfo.Size)
}
fmt.Println("...")
var lastPercent int
err = whisper.Download(ctx, modelName, func(downloaded, total int64) {
if total > 0 {
percent := int(downloaded * 100 / total)
if percent >= lastPercent+10 {
fmt.Printf("%d%% ", percent)
lastPercent = percent
}
}
})
if err != nil { if err != nil {
return fmt.Errorf("failed to create config file: %w", err) return fmt.Errorf("download failed: %w", err)
}
defer file.Close()
configContent := fmt.Sprintf(`# Hyprvoice Configuration
# This file is automatically generated with defaults.
# Edit values as needed - changes are applied immediately without daemon restart.
# Audio Recording Configuration
[recording]
sample_rate = %d # Audio sample rate in Hz (16000 recommended for speech)
channels = %d # Number of audio channels (1 = mono, 2 = stereo)
format = "%s" # Audio format (s16 = 16-bit signed integers)
buffer_size = %d # Internal buffer size in bytes (larger = less CPU, more latency)
device = "%s" # PipeWire audio device (empty = use default microphone)
channel_buffer_size = %d # Audio frame buffer size (frames to buffer)
timeout = "%s" # Maximum recording duration (e.g., "30s", "2m", "5m")
# Speech Transcription Configuration
[transcription]
provider = "%s" # Transcription service: "openai", "groq-transcription", "groq-translation", "mistral-transcription", or "elevenlabs"
api_key = "%s" # API key (or set OPENAI_API_KEY/GROQ_API_KEY/MISTRAL_API_KEY/ELEVENLABS_API_KEY environment variable)
language = "%s" # Language code (empty for auto-detect, "en", "it", "es", "fr", etc.)
model = "%s" # Model: OpenAI="whisper-1", Groq="whisper-large-v3", Mistral="voxtral-mini-latest", ElevenLabs="scribe_v1" or "scribe_v2"
# Text Injection Configuration
[injection]
backends = [%s] # Ordered fallback chain (tries each until one succeeds)
ydotool_timeout = "%s" # Timeout for ydotool commands
wtype_timeout = "%s" # Timeout for wtype commands
clipboard_timeout = "%s" # Timeout for clipboard operations
# Backend explanations:
# - "ydotool": Uses ydotool (requires ydotoold daemon running for ydotool v1.0.0+). Most compatible with Chromium/Electron apps.
# - "wtype": Uses wtype for Wayland. May have issues with some Chromium-based apps.
# - "clipboard": Copies text to clipboard only (most reliable, but requires manual paste).
#
# The backends are tried in order. First successful one wins.
#
# Provider explanations:
# - "openai": OpenAI Whisper API (cloud-based, requires OPENAI_API_KEY)
# - "groq-transcription": Groq Whisper API for transcription (fast, requires GROQ_API_KEY)
# Models: whisper-large-v3 or whisper-large-v3-turbo
# - "groq-translation": Groq Whisper API for translation to English (always outputs English text)
# Models: whisper-large-v3 only (turbo not supported for translation)
# - "mistral-transcription": Mistral Voxtral API (excellent for European languages, requires MISTRAL_API_KEY)
# Models: voxtral-mini-latest or voxtral-mini-2507
# - "elevenlabs": ElevenLabs Scribe API (excellent accuracy, 99 languages, requires ELEVENLABS_API_KEY)
# Models: scribe_v1 (99 languages, best accuracy) or scribe_v2 (90 languages, real-time)
#
# Language codes: Use empty string ("") for automatic detection, or specific codes like:
# "en" (English), "it" (Italian), "es" (Spanish), "fr" (French), "de" (German), etc.
# For groq-translation, the language field hints at the source audio language for better accuracy.
# Desktop Notification Configuration
[notifications]
enabled = %v # Enable desktop notifications
type = "%s" # Notification type ("desktop", "log", "none")
`,
cfg.Recording.SampleRate,
cfg.Recording.Channels,
cfg.Recording.Format,
cfg.Recording.BufferSize,
cfg.Recording.Device,
cfg.Recording.ChannelBufferSize,
cfg.Recording.Timeout,
cfg.Transcription.Provider,
cfg.Transcription.APIKey,
cfg.Transcription.Language,
cfg.Transcription.Model,
formatBackends(cfg.Injection.Backends),
cfg.Injection.YdotoolTimeout,
cfg.Injection.WtypeTimeout,
cfg.Injection.ClipboardTimeout,
cfg.Notifications.Enabled,
cfg.Notifications.Type,
)
if _, err := file.WriteString(configContent); err != nil {
return fmt.Errorf("failed to write config content: %w", err)
}
// Write notification messages if any are configured
msgs := cfg.Notifications.Messages
if hasCustomMessages(msgs) {
messagesContent := "\n [notifications.messages]\n"
if msgs.RecordingStarted.Title != "" || msgs.RecordingStarted.Body != "" {
messagesContent += fmt.Sprintf(" [notifications.messages.recording_started]\n title = %q\n body = %q\n",
msgs.RecordingStarted.Title, msgs.RecordingStarted.Body)
}
if msgs.Transcribing.Title != "" || msgs.Transcribing.Body != "" {
messagesContent += fmt.Sprintf(" [notifications.messages.transcribing]\n title = %q\n body = %q\n",
msgs.Transcribing.Title, msgs.Transcribing.Body)
}
if msgs.ConfigReloaded.Title != "" || msgs.ConfigReloaded.Body != "" {
messagesContent += fmt.Sprintf(" [notifications.messages.config_reloaded]\n title = %q\n body = %q\n",
msgs.ConfigReloaded.Title, msgs.ConfigReloaded.Body)
}
if msgs.OperationCancelled.Title != "" || msgs.OperationCancelled.Body != "" {
messagesContent += fmt.Sprintf(" [notifications.messages.operation_cancelled]\n title = %q\n body = %q\n",
msgs.OperationCancelled.Title, msgs.OperationCancelled.Body)
}
if msgs.RecordingAborted.Body != "" {
messagesContent += fmt.Sprintf(" [notifications.messages.recording_aborted]\n body = %q\n",
msgs.RecordingAborted.Body)
}
if msgs.InjectionAborted.Body != "" {
messagesContent += fmt.Sprintf(" [notifications.messages.injection_aborted]\n body = %q\n",
msgs.InjectionAborted.Body)
}
if _, err := file.WriteString(messagesContent); err != nil {
return fmt.Errorf("failed to write messages config: %w", err)
}
} }
path := whisper.GetModelPath(modelName)
fmt.Printf("\ndownload complete: %s\n", path)
return nil return nil
} }
func hasCustomMessages(msgs config.MessagesConfig) bool { func modelRemoveCmd() *cobra.Command {
return msgs.RecordingStarted.Title != "" || msgs.RecordingStarted.Body != "" || return &cobra.Command{
msgs.Transcribing.Title != "" || msgs.Transcribing.Body != "" || Use: "remove <model-name>",
msgs.ConfigReloaded.Title != "" || msgs.ConfigReloaded.Body != "" || Short: "Remove a downloaded local model",
msgs.OperationCancelled.Title != "" || msgs.OperationCancelled.Body != "" || Args: cobra.ExactArgs(1),
msgs.RecordingAborted.Body != "" || RunE: func(cmd *cobra.Command, args []string) error {
msgs.InjectionAborted.Body != "" return runModelRemove(args[0])
},
}
}
func runModelRemove(modelName string) error {
// find the model across all providers
model, _, err := provider.FindModelByID(modelName)
if err != nil {
return fmt.Errorf("unknown model: %s", modelName)
}
// check if it's a cloud model (nothing to remove)
if !model.NeedsDownload() {
fmt.Printf("model '%s' is a cloud model, nothing to remove\n", modelName)
return nil
}
// check if installed
if !whisper.IsInstalled(modelName) {
return fmt.Errorf("model '%s' is not installed", modelName)
}
// remove the model
if err := whisper.Remove(modelName); err != nil {
return fmt.Errorf("failed to remove model: %w", err)
}
fmt.Printf("model '%s' removed successfully\n", modelName)
return nil
} }
+111
View File
@@ -0,0 +1,111 @@
# Architecture
This doc describes how the CLI, daemon, pipeline, and adapters compose the system.
## Overview
Hyprvoice is split into a thin CLI and a long-lived daemon. The CLI sends single-character IPC commands to the daemon. The daemon owns lifecycle and runs a pipeline state machine that coordinates recording, transcription, optional LLM cleanup, and text injection.
## Components
- CLI: command parsing and IPC client (`cmd/hyprvoice/main.go`).
- Daemon: IPC server, lifecycle, pipeline ownership (`internal/daemon/daemon.go`).
- Pipeline: state machine orchestration (`internal/pipeline/`).
- Recording: PipeWire capture (`internal/recording/`).
- Transcription: batch + streaming adapters (`internal/transcriber/`).
- LLM post-processing: adapters and prompt builders (`internal/llm/`).
- Injection: wtype/ydotool/clipboard backends (`internal/injection/`).
- Provider registry: model metadata and adapter selection (`internal/provider/`).
- Config manager: load/validate + hot reload (`internal/config/`).
## IPC control plane
The daemon listens on a unix socket and accepts single-character commands.
- Socket path: `~/.cache/hyprvoice/control.sock` (see `internal/bus/bus.go`).
- Command bytes: `t` toggle, `c` cancel, `s` status, `v` version, `q` quit.
- Responses are line-based: `OK ...`, `STATUS ...`, or `ERR ...`.
The CLI writes one command byte and reads the response; the daemon maps commands to pipeline actions.
## Pipeline state machine
The pipeline is a long-lived goroutine managed by the daemon. It exposes a small interface and uses channels to coordinate actions and notifications.
States (from `internal/pipeline/pipeline.go`):
`idle -> recording -> transcribing -> processing -> injecting -> idle`
Key transitions:
- Toggle while idle: start recorder + transcriber, move to recording/transcribing.
- Inject action: stop recorder, finalize transcription, optional LLM processing, inject text.
- Cancel: stop current action and return to idle.
Key interface (simplified):
- `Pipeline.Run()` starts the pipeline loop.
- `Pipeline.Stop()` stops the current run.
- `Pipeline.GetActionCh()` receives actions (toggle inject).
- `Pipeline.GetNotifyCh()` emits user-facing events.
- `Pipeline.GetErrorCh()` emits errors for the daemon to handle.
## Recording
`internal/recording/recording.go` defines `Recorder` with `Start/Stop/IsRecording`.
The default implementation wraps `pw-record` and emits `AudioFrame` chunks on a buffered channel.
## Transcription
`internal/transcriber/transcriber.go` defines the core interfaces:
- `Transcriber`: lifecycle + `GetFinalTranscription()`.
- `BatchAdapter`: `Transcribe(audio, opts)` for full-file transcription.
- `StreamingAdapter`: `Start/SendChunk/Results/Finalize/Close` for realtime.
`NewTranscriber()` selects between `SimpleTranscriber` (batch) and `StreamingTranscriber` (streaming) based on provider model metadata. Streaming adapters deliver incremental `TranscriptionResult` events and a final transcript on stop/finalize.
## LLM post-processing
`internal/llm/llm.go` defines an `Adapter` interface with `Process(text, config)`.
Adapters (OpenAI, Groq) use a shared prompt builder in `internal/llm/prompt.go`.
The pipeline invokes LLM processing only if enabled in config.
## Injection
`internal/injection/injection.go` defines `Injector` and an ordered list of backends.
`internal/injection/backend.go` defines the `Backend` interface (`Name/Available/Inject`).
Backends include:
- `wtype` (Wayland typing)
- `ydotool` (uinput typing)
- `wl-clipboard` fallback
The injector tries backends in order and falls back to clipboard when typing fails.
## Provider registry and adapter selection
Providers register themselves via `internal/provider/provider.go` and return model catalogs.
Each `Model` includes:
- `AdapterType` (which adapter to use)
- `Endpoint` and optional `StreamingEndpoint`
- `SupportedLanguages` and model capabilities
`internal/provider/names.go` holds adapter constants and provider names. `internal/provider/model.go` implements language compatibility checks. `internal/provider/provider.go` exposes helpers like `GetModel`, `ModelsForLanguage`, and `ValidateModelLanguage`.
## Language compatibility
`internal/language/language.go` defines the canonical language list and provider-specific formatting (ex: Deepgram locale mapping). Model-level language filters enforce compatibility at config time and runtime.
## Config lifecycle and hot reload
`internal/config/load.go` loads config, applies defaults, and resolves env-based API keys. `internal/config/validate.go` enforces model/language compatibility and provider requirements. `internal/config/convert.go` converts config into runtime structs for the pipeline.
`internal/config/manager.go` watches `~/.config/hyprvoice/config.toml` and triggers reloads with a debounce. The daemon wires `onConfigReload` to stop any running pipeline, refresh notifiers, and apply new settings without a restart.
## Notifications and errors
The pipeline emits notification events and errors via channels. The daemon consumes them and uses `internal/notify` to display status changes to the user.
## Extending the system
Common extension points:
- Add a new transcription provider:
- Define a provider catalog in `internal/provider/`.
- Implement a `BatchAdapter` or `StreamingAdapter` in `internal/transcriber/`.
- Add adapter constants in `internal/provider/names.go`.
- Update provider docs in `docs/providers.md`.
- Run `hyprvoice test-models` to verify the integration (see `docs/testing.md`).
- Add a new injection backend:
- Implement `Backend` in `internal/injection/`.
- Register it in the injector order (config driven).
- Add a new LLM adapter:
- Implement `Adapter` in `internal/llm/`.
- Wire it in `NewAdapter()` and expose config knobs.
+674
View File
@@ -0,0 +1,674 @@
# Configuration Reference
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
hyprvoice configure
```
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
- [Unified Provider System](#unified-provider-system)
- [Transcription Providers](#transcription-providers)
- [Cloud Providers](#cloud-providers)
- [Local Transcription (whisper-cpp)](#local-transcription-whisper-cpp)
- [Streaming Transcription](#streaming-transcription)
- [Language Configuration](#language-configuration)
- [Model Management](#model-management)
- [LLM Post-Processing](#llm-post-processing)
- [Keywords](#keywords)
- [Recording Configuration](#recording-configuration)
- [Text Injection](#text-injection)
- [Notifications](#notifications)
- [Example Configurations](#example-configurations)
- [Legacy Configs](#legacy-configs)
## Unified Provider System
Hyprvoice uses a unified provider system where API keys are configured once and shared between transcription and LLM features:
```toml
# Configure API keys for providers you want to use
[providers.openai]
api_key = "sk-..." # Or set OPENAI_API_KEY env var
[providers.groq]
api_key = "gsk_..." # Or set GROQ_API_KEY env var
[providers.mistral]
api_key = "..." # Or set MISTRAL_API_KEY env var
[providers.elevenlabs]
api_key = "..." # Or set ELEVENLABS_API_KEY env var
[providers.deepgram]
api_key = "..." # Or set DEEPGRAM_API_KEY env var
```
**API key resolution order:**
1. `[providers.X]` section in config
2. Environment variable (`OPENAI_API_KEY`, `GROQ_API_KEY`, etc.)
## Transcription Providers
Hyprvoice supports multiple transcription backends. See [docs/providers.md](./providers.md) for detailed comparisons.
### Cloud Providers
### OpenAI Whisper API
Cloud-based transcription using OpenAI's Whisper API:
```toml
[transcription]
provider = "openai"
model = "whisper-1"
language = "" # Empty for auto-detect, or "en", "es", "fr", etc.
```
**Features:**
- High-quality transcription
- Supports 50+ languages
- Auto-detection or specify language for better accuracy
### Groq Whisper API (Transcription)
Fast cloud-based transcription using Groq's Whisper API:
```toml
[transcription]
provider = "groq-transcription"
model = "whisper-large-v3" # Or "whisper-large-v3-turbo" for faster processing
language = "" # Empty for auto-detect, or "en", "es", "fr", etc.
```
**Features:**
- Ultra-fast transcription (significantly faster than OpenAI)
- Same Whisper model quality
- Supports 50+ languages
- Free tier available with generous limits
### Mistral Voxtral
Transcription using Mistral's Voxtral API, excellent for European languages:
Note: Mistral's API supports streaming responses, but it is not real-time audio streaming. Hyprvoice treats Voxtral as batch-only.
```toml
[transcription]
provider = "mistral-transcription"
model = "voxtral-mini-latest"
language = "" # Empty for auto-detect
```
### ElevenLabs Scribe
Transcription using ElevenLabs' Scribe API with 57+ language support:
```toml
[transcription]
provider = "elevenlabs"
model = "scribe_v1" # Or "scribe_v2" for lower latency (batch)
language = "" # Empty for auto-detect
```
**Features:**
- 57+ languages supported
- Both batch and streaming models available
- Ultra-low latency streaming options
### Deepgram Nova
Fast streaming transcription using Deepgram's Nova models:
```toml
[providers.deepgram]
api_key = "..." # Or set DEEPGRAM_API_KEY env var
[transcription]
provider = "deepgram"
model = "nova-3" # Or "nova-2" for different language support
language = "" # Empty for auto-detect
```
**Features:**
- Flux: streaming-only, English with turn detection
- Nova-3: 42 languages, best accuracy (batch+streaming)
- Nova-2: 33 languages, faster with filler word detection (batch+streaming)
- Excellent for real-time transcription and live captions
### Local Transcription (whisper-cpp)
Run Whisper models locally on your machine. No API keys, no network latency, complete privacy.
**Prerequisites:**
1. Install whisper-cli: https://github.com/ggerganov/whisper.cpp
2. Download a model: `hyprvoice model download base.en`
```toml
[transcription]
provider = "whisper-cpp"
model = "base.en" # English-only model (fastest)
language = "" # Empty for auto-detect (use "en" for English-only models)
threads = 0 # 0 = auto (uses NumCPU - 1)
```
**Available models:**
| Model | Size | Languages | Best For |
|-------|------|-----------|----------|
| `tiny.en` | 75MB | English only | Quick tests, low-power devices |
| `base.en` | 142MB | English only | Daily use, good balance |
| `small.en` | 466MB | English only | Better accuracy |
| `medium.en` | 1.5GB | English only | Best English accuracy |
| `tiny` | 75MB | 57 languages | Quick multilingual |
| `base` | 142MB | 57 languages | Daily multilingual use |
| `small` | 466MB | 57 languages | Better multilingual |
| `medium` | 1.5GB | 57 languages | Great accuracy |
| `large-v1` | 2.9GB | 57 languages | Best accuracy |
| `large-v2` | 2.9GB | 57 languages | Best accuracy |
| `large-v3` | 3GB | 57 languages | Best accuracy |
| `large-v3-turbo` | 1.6GB | 57 languages | Faster large-v3 |
**Threads configuration:**
- `threads = 0` (default): auto-detects, uses NumCPU - 1 to leave one core free
- `threads = 4`: explicitly use 4 threads
- Higher thread count = faster transcription but more CPU usage
### Streaming Transcription
For real-time transcription, use streaming models:
```toml
# ElevenLabs streaming (realtime only)
[transcription]
provider = "elevenlabs"
model = "scribe_v2_realtime"
streaming = true
# Deepgram streaming (all models support streaming)
[transcription]
provider = "deepgram"
model = "nova-3"
# OpenAI Realtime
[transcription]
provider = "openai"
model = "gpt-4o-realtime-preview"
```
**Streaming models:**
| Provider | Model | Latency | Languages |
|----------|-------|---------|-----------|
| ElevenLabs | `scribe_v2_realtime` | <150ms | 57+ |
| Deepgram | `flux-general-en` | Very Low | en |
| Deepgram | `nova-3` | Low | 42 |
| Deepgram | `nova-2` | Very Low | 33 |
| OpenAI | `gpt-4o-realtime-preview` | Low | 57 |
### Language Configuration
Language is configured per transcription model in the `[transcription]` section:
```toml
[transcription]
provider = "openai"
model = "whisper-1"
language = "" # Empty for auto-detect (recommended)
# language = "en" # English
# language = "es" # Spanish
# language = "fr" # French
```
When using `hyprvoice configure`, you select the language after choosing the model. Only languages supported by the selected model are shown.
**Recommendations:**
- Use auto-detect (`language = ""`) for most cases - it works well
- Specify a language if you always speak the same language (slight accuracy boost)
- English-only models (e.g., `base.en`) only support `language = "en"` or auto-detect
### Supported Languages
Hyprvoice supports 57 languages:
Afrikaans (af), Arabic (ar), Armenian (hy), Azerbaijani (az), Belarusian (be), Bosnian (bs), Bulgarian (bg), Catalan (ca), Chinese (zh), Croatian (hr), Czech (cs), Danish (da), Dutch (nl), English (en), Estonian (et), Finnish (fi), French (fr), Galician (gl), German (de), Greek (el), Hebrew (he), Hindi (hi), Hungarian (hu), Icelandic (is), Indonesian (id), Italian (it), Japanese (ja), Kannada (kn), Kazakh (kk), Korean (ko), Latvian (lv), Lithuanian (lt), Macedonian (mk), Malay (ms), Marathi (mr), Maori (mi), Nepali (ne), Norwegian (no), Persian (fa), Polish (pl), Portuguese (pt), Romanian (ro), Russian (ru), Serbian (sr), Slovak (sk), Slovenian (sl), Spanish (es), Swahili (sw), Swedish (sv), Tagalog (tl), Tamil (ta), Thai (th), Turkish (tr), Ukrainian (uk), Urdu (ur), Vietnamese (vi), Welsh (cy)
### Language-Model Compatibility
Some models only support English. When configuring via `hyprvoice configure`, only supported languages are shown for selection.
**English-only models:**
| Provider | Model |
|----------|-------|
| whisper-cpp | `tiny.en`, `base.en`, `small.en`, `medium.en` |
**Deepgram models** support fewer languages than the full 57 - see [providers.md](./providers.md#deepgram-language-support).
**Validation behavior:**
1. **At config time (TUI):** Only languages supported by the selected model are shown
2. **At runtime:** If config was manually edited to an invalid combination, hyprvoice logs a warning, sends a desktop notification, and falls back to auto-detect
```toml
# This combination will be rejected at validation:
[transcription]
provider = "whisper-cpp"
model = "base.en" # English only!
language = "es" # Error: model does not support Spanish
```
## Model Management
Manage local whisper models with CLI commands:
### List Models
```bash
# List all models
hyprvoice model list
# Filter by provider
hyprvoice model list --provider whisper-cpp
# Filter by type
hyprvoice model list --type transcription
```
Shows installed status `[x]` for local models and model details.
### Download Models
```bash
# Download a whisper model
hyprvoice model download base.en
# Download with progress
hyprvoice model download large-v3
```
Cloud models (OpenAI, Groq, etc.) don't require download - this is for local models only.
### Remove Models
```bash
# Remove a downloaded model
hyprvoice model remove base.en
```
## LLM Post-Processing
LLM post-processing is **enabled by default** and significantly improves transcription quality. After transcription, the text is processed by an LLM to:
- Remove stutters and repeated words ("I I I want" → "I want")
- Add proper punctuation
- Fix grammar errors
- Remove filler words ("um", "uh", "like", "you know", etc.)
### Basic Configuration
```toml
[llm]
enabled = true # Disable with false if you want raw transcriptions
provider = "openai" # "openai" or "groq"
model = "gpt-4o-mini" # OpenAI: "gpt-4o-mini", Groq: "llama-3.3-70b-versatile"
```
### Post-Processing Options
All options are enabled by default. Disable specific ones as needed:
```toml
[llm.post_processing]
remove_stutters = true # "I I I want" → "I want"
add_punctuation = true # Adds periods, commas, etc.
fix_grammar = true # Fixes grammatical errors
remove_filler_words = true # Removes "um", "uh", "like", "you know"
```
### Custom Prompts
Add custom instructions for specific use cases:
```toml
[llm.custom_prompt]
enabled = true
prompt = "Format as bullet points"
```
**Use cases for custom prompts:**
- "Format as bullet points" - for note-taking
- "Keep technical terms exactly as spoken" - for programming dictation
- "Use formal language" - for professional documents
- "Translate to Spanish" - for translation workflows
### LLM Provider Recommendations
| Provider | Model | Best For |
| -------- | ----------------------- | ----------------------------------- |
| OpenAI | gpt-4o-mini | Best quality/cost balance (default) |
| Groq | llama-3.3-70b-versatile | Fastest processing, free tier |
## Keywords
Keywords help both transcription and LLM understand domain-specific terms, names, and technical vocabulary:
```toml
keywords = ["Hyprland", "Wayland", "PipeWire", "Claude", "TypeScript"]
```
**How keywords work:**
- **Transcription**: Passed as provider-specific hints (prompt/keyterms/keywords) when supported to improve recognition
- **LLM**: Included in the system prompt to ensure correct spelling
**When to use keywords:**
- Names of people, companies, or products
- Technical terminology specific to your field
- Acronyms or abbreviations
- Words commonly misheard by speech-to-text
## Recording Configuration
Audio capture settings:
```toml
[recording]
sample_rate = 16000 # Audio sample rate in Hz (16000 recommended for speech)
channels = 1 # Number of audio channels (1 = mono, 2 = stereo)
format = "s16" # Audio format (s16 = 16-bit signed integers)
buffer_size = 8192 # Internal buffer size in bytes (larger = less CPU, more latency)
device = "" # PipeWire device name (empty = default microphone)
channel_buffer_size = 30 # Audio frame buffer size (frames to buffer)
timeout = "5m" # Maximum recording duration (e.g., "30s", "2m", "5m")
```
### Recording Timeout
- Prevents accidental long recordings that could consume resources
- Default: 5 minutes (`"5m"`)
- Format: Go duration strings like `"30s"`, `"2m"`, `"10m"`
- Recording automatically stops when timeout is reached
## Text Injection
Configurable text injection with multiple backends:
```toml
[injection]
backends = ["ydotool", "wtype", "clipboard"] # Ordered fallback chain
ydotool_timeout = "5s"
wtype_timeout = "5s"
clipboard_timeout = "3s"
```
### Injection Backends
- **`ydotool`**: Uses ydotool (requires `ydotoold` daemon for ydotool v1.0.0+). Most compatible with Chromium/Electron apps.
- **`wtype`**: Uses wtype for Wayland. May have issues with some Chromium-based apps (known upstream bug).
- **`clipboard`**: Copies text to clipboard only. Most reliable, but requires manual paste.
### Fallback Chain
Backends are tried in order. The first successful one wins. Example configurations:
```toml
# Clipboard only (safest, always works)
backends = ["clipboard"]
# wtype with clipboard fallback
backends = ["wtype", "clipboard"]
# Full fallback chain (default) - best compatibility
backends = ["ydotool", "wtype", "clipboard"]
# ydotool only (if you have it set up)
backends = ["ydotool"]
```
### ydotool Setup
ydotool requires the `ydotoold` daemon running (for ydotool v1.0.0+) and access to `/dev/uinput`:
```bash
# Start ydotool daemon (systemd)
systemctl --user enable --now ydotool
# Or add user to input group
sudo usermod -aG input $USER
# Then logout/login
# For Hyprland, add to config to set correct keyboard layout:
# device:ydotoold-virtual-device {
# kb_layout = us
# }
```
## Notifications
Desktop notification settings:
```toml
[notifications]
enabled = true # Enable/disable notifications
type = "desktop" # "desktop", "log", or "none"
```
### Notification Types
- **`desktop`**: Use notify-send for desktop notifications
- **`log`**: Log messages to console only
- **`none`**: Disable all notifications
### Custom Notification Messages
You can customize notification text via the `[notifications.messages]` section:
```toml
[notifications.messages]
[notifications.messages.recording_started]
title = "Hyprvoice"
body = "Recording Started"
[notifications.messages.transcribing]
title = "Hyprvoice"
body = "Recording Ended... Transcribing"
[notifications.messages.llm_processing]
title = "Hyprvoice"
body = "Processing..."
[notifications.messages.config_reloaded]
title = "Hyprvoice"
body = "Config Reloaded"
[notifications.messages.operation_cancelled]
title = "Hyprvoice"
body = "Operation Cancelled"
[notifications.messages.recording_aborted]
body = "Recording Aborted"
[notifications.messages.injection_aborted]
body = "Injection Aborted"
```
**Emoji-only example** (for minimal pill-style notifications):
```toml
[notifications.messages.recording_started]
title = ""
body = "🎙️"
```
## Example Configurations
### Fast Transcription Only (No LLM)
```toml
[providers.groq]
api_key = "gsk_..."
[transcription]
provider = "groq-transcription"
model = "whisper-large-v3-turbo"
language = "" # Auto-detect
[llm]
enabled = false
```
### High Quality with OpenAI (Default)
```toml
[providers.openai]
api_key = "sk-..."
[transcription]
provider = "openai"
model = "whisper-1"
language = "" # Auto-detect
[llm]
enabled = true
provider = "openai"
model = "gpt-4o-mini"
```
### Budget-Friendly with Groq
```toml
[providers.groq]
api_key = "gsk_..."
[transcription]
provider = "groq-transcription"
model = "whisper-large-v3-turbo"
language = "" # Auto-detect
[llm]
enabled = true
provider = "groq"
model = "llama-3.3-70b-versatile"
```
### Mixed Providers (Groq Transcription + OpenAI LLM)
```toml
[providers.openai]
api_key = "sk-..."
[providers.groq]
api_key = "gsk_..."
[transcription]
provider = "groq-transcription"
model = "whisper-large-v3-turbo"
language = "" # Auto-detect
[llm]
enabled = true
provider = "openai"
model = "gpt-4o-mini"
```
### Local Transcription (Privacy-First)
```toml
# No API keys needed!
[transcription]
provider = "whisper-cpp"
model = "base.en"
language = "" # Auto-detect
threads = 0 # Auto-detect (NumCPU - 1)
[llm]
enabled = false # No LLM for full privacy
```
### Real-Time Streaming with Deepgram
```toml
[providers.deepgram]
api_key = "..."
[transcription]
provider = "deepgram"
model = "nova-3" # All Deepgram models are streaming
language = "" # Auto-detect
[llm]
enabled = false # Streaming doesn't need LLM post-processing
```
### Ultra-Low Latency Streaming
```toml
[providers.elevenlabs]
api_key = "..."
[transcription]
provider = "elevenlabs"
model = "scribe_v2_realtime" # <150ms latency
streaming = true
language = "" # Auto-detect
[llm]
enabled = false
```
### Specific Language Setup
```toml
[providers.openai]
api_key = "sk-..."
[transcription]
provider = "openai"
model = "whisper-1"
language = "es" # Always transcribe as Spanish
[llm]
enabled = true
provider = "openai"
model = "gpt-4o-mini"
```
## Legacy Configs
Older config formats are no longer supported. If your config uses any of these fields, rerun onboarding to regenerate a supported config:
- `transcription.api_key`
- `injection.mode`
- `general.language`
- `transcription.provider = "groq-translation"`
Run `hyprvoice onboarding` to generate a new config, then `hyprvoice configure` for advanced settings.
## Configuration Hot-Reloading
The daemon automatically watches the config file for changes and applies them immediately:
- **Notification settings**: Applied instantly
- **Injection settings**: Applied to current and future operations
- **Recording/Transcription/LLM settings**: Applied to new recording sessions
- **Invalid configs**: Rejected with error notification, daemon continues with previous config
+246
View File
@@ -0,0 +1,246 @@
# Provider Comparison Guide
This guide helps you choose the right transcription provider for your use case.
## Transcription Providers
| Provider | Type | Models | Languages | Streaming | Speed | Quality | Cost |
|----------|------|--------|-----------|-----------|-------|---------|------|
| **OpenAI** | Cloud | 4 | 57 | Yes | Fast | Excellent | $0.006/min |
| **Groq** | Cloud | 3 | 57 (1 EN-only) | No | Very Fast | Excellent | Free tier |
| **Mistral** | Cloud | 2 | 57 | No | Fast | Good | Pay per use |
| **ElevenLabs** | Cloud | 4 | 57+ | Yes | Fast | Excellent | Pay per use |
| **Deepgram** | Cloud | 4 | 33-42 | Yes | Very Fast | Excellent | Pay per use |
| **whisper-cpp** | Local | 12 | 57 (4 EN-only) | No | Varies | Excellent | Free |
### OpenAI
The original Whisper provider. Reliable and well-documented.
**Models:**
- `whisper-1` - Production speech-to-text (batch)
- `gpt-4o-transcribe` - High quality with GPT-4o (batch)
- `gpt-4o-mini-transcribe` - Faster with GPT-4o Mini (batch)
- `gpt-4o-realtime-preview` - Real-time streaming
**Best for:** General use, high accuracy requirements, streaming needs
### Groq
Extremely fast inference using specialized hardware. OpenAI-compatible API.
**Models:**
- `whisper-large-v3` - Full Whisper v3, best accuracy
- `whisper-large-v3-turbo` - Faster with slightly lower accuracy
**Best for:** Speed-critical applications, English-only use cases, budget-conscious users
### Mistral
European provider with Voxtral transcription models.
**Models:**
- `voxtral-mini-latest` - Latest Voxtral, recommended
**Notes:** Mistral's streaming responses are not real-time audio streaming; hyprvoice treats Voxtral as batch-only.
**Best for:** European data residency requirements, Mistral ecosystem users
### ElevenLabs
Known for voice synthesis, also offers excellent transcription via Scribe.
**Models:**
- `scribe_v1` - 90+ languages, best accuracy (batch)
- `scribe_v2` - Lower latency (batch)
- `scribe_v2_realtime` - Streaming-only realtime endpoint
**Best for:** Applications needing both TTS and STT, ultra-low latency streaming
### Deepgram
Streaming-first provider with Nova models. Excellent for real-time applications.
**Models:**
- `flux-general-en` - Streaming with turn detection (English)
- `nova-3` - Best accuracy, 42 languages
- `nova-2` - Fast, 33 languages, filler word detection
**Notes:** Flux is English-only.
**Language Support:** Nova-3 supports 42 languages, Nova-2 supports 33 languages. Not all 57 languages from the master list are available.
**Best for:** Real-time transcription, live captions, meeting transcription
### whisper-cpp (Local)
Run Whisper models locally on your machine. No API keys, no network latency, complete privacy.
**Requires:** `whisper-cli` binary installed on your system.
**English-only models (faster):**
| Model | Size | Speed | Quality |
|-------|------|-------|---------|
| `tiny.en` | 75MB | Fastest | Basic |
| `base.en` | 142MB | Fast | Good |
| `small.en` | 466MB | Medium | Better |
| `medium.en` | 1.5GB | Slow | Best EN |
**Multilingual models:**
| Model | Size | Speed | Quality |
|-------|------|-------|---------|
| `tiny` | 75MB | Fastest | Basic |
| `base` | 142MB | Fast | Good |
| `small` | 466MB | Medium | Better |
| `medium` | 1.5GB | Slow | Great |
| `large-v1` | 2.9GB | Slowest | Best |
| `large-v2` | 2.9GB | Slowest | Best |
| `large-v3` | 3GB | Slowest | Best |
| `large-v3-turbo` | 1.6GB | Slower | Great |
**Best for:** Privacy-sensitive applications, offline use, avoiding API costs
---
## LLM Providers
Used for post-processing transcriptions (formatting, summarization, etc.)
| Provider | Models | Quality | Cost |
|----------|--------|---------|------|
| **OpenAI** | gpt-4o, gpt-4o-mini | Excellent | Pay per token |
| **Groq** | llama-3.3-70b, llama-3.1-8b, mixtral-8x7b | Good-Excellent | Free tier |
---
## Choosing a Provider
### Decision Flowchart
```
Need complete privacy?
├─ Yes → whisper-cpp (local)
└─ No
└─ Need real-time streaming?
├─ Yes
│ └─ Latency critical (<150ms)?
│ ├─ Yes → ElevenLabs scribe_v2_realtime (streaming)
│ └─ No → Deepgram nova-3 or OpenAI realtime
└─ No (batch)
└─ Need fastest response?
├─ Yes → Groq whisper-large-v3-turbo
└─ No
└─ Need highest accuracy?
├─ Yes → OpenAI gpt-4o-transcribe or Groq whisper-large-v3
└─ No → OpenAI whisper-1 (reliable default)
```
### Quick Recommendations
| Use Case | Recommended Provider | Model |
|----------|---------------------|-------|
| General dictation | OpenAI | whisper-1 |
| Fast multilingual | Groq | whisper-large-v3-turbo |
| Live captions | Deepgram | nova-3 |
| Ultra-low latency | ElevenLabs | scribe_v2_realtime (streaming) |
| Offline/privacy | whisper-cpp | base.en or base |
| High accuracy | OpenAI | gpt-4o-transcribe |
---
## Language Support
All providers support **auto-detect mode** (recommended for most users) which automatically identifies the spoken language.
### Full Language Support (57 languages)
OpenAI, Groq, Mistral, ElevenLabs, and whisper-cpp multilingual models support all 57 languages:
Afrikaans, Arabic, Armenian, Azerbaijani, Belarusian, Bosnian, Bulgarian, Catalan, Chinese, Croatian, Czech, Danish, Dutch, English, Estonian, Finnish, French, Galician, German, Greek, Hebrew, Hindi, Hungarian, Icelandic, Indonesian, Italian, Japanese, Kannada, Kazakh, Korean, Latvian, Lithuanian, Macedonian, Malay, Marathi, Maori, Nepali, Norwegian, Persian, Polish, Portuguese, Romanian, Russian, Serbian, Slovak, Slovenian, Spanish, Swahili, Swedish, Tagalog, Tamil, Thai, Turkish, Ukrainian, Urdu, Vietnamese, Welsh
### English-Only Models
These models only support English but are faster:
| Provider | Model |
|----------|-------|
| whisper-cpp | `tiny.en`, `base.en`, `small.en`, `medium.en` |
If you select an English-only model with a non-English language, hyprvoice will:
1. **At config time:** Show an error and prevent saving
2. **At runtime:** Fall back to auto-detect with a warning notification
### Deepgram Language Support
Deepgram Nova models support a subset of languages:
**Nova-3 (42 languages):** Arabic, Belarusian, Bosnian, Bulgarian, Catalan, Croatian, Czech, Danish, Dutch, English, Estonian, Finnish, French, German, Greek, Hindi, Hungarian, Indonesian, Italian, Japanese, Kannada, Korean, Latvian, Lithuanian, Macedonian, Malay, Marathi, Norwegian, Polish, Portuguese, Romanian, Russian, Serbian, Slovak, Slovenian, Spanish, Swedish, Tagalog, Tamil, Turkish, Ukrainian, Vietnamese
**Nova-2 (33 languages):** Bulgarian, Catalan, Chinese, Czech, Danish, Dutch, English, Estonian, Finnish, French, German, Greek, Hindi, Hungarian, Indonesian, Italian, Japanese, Korean, Latvian, Lithuanian, Malay, Norwegian, Polish, Portuguese, Romanian, Russian, Slovak, Spanish, Swedish, Thai, Turkish, Ukrainian, Vietnamese
---
## Streaming vs Batch
### Batch Transcription
- Send complete audio file
- Wait for full transcription
- Higher accuracy
- Better for: recordings, file processing, dictation
### Streaming Transcription
- Send audio chunks in real-time
- Get partial results immediately
- Lower latency
- Better for: live captions, voice commands, interactive apps
**Streaming providers:** OpenAI (realtime model), ElevenLabs, Deepgram
---
## Local vs Cloud
### Cloud Providers
**Pros:**
- No setup required
- Always up-to-date models
- Scales automatically
- Professional support
**Cons:**
- Requires internet connection
- API costs
- Data leaves your machine
- Potential latency
### Local (whisper-cpp)
**Pros:**
- Complete privacy
- No API costs
- Works offline
- No network latency
- Your data stays on your machine
**Cons:**
- Requires setup (install whisper-cli)
- Need to download models (75MB-3GB)
- Uses local CPU/GPU resources
- Slower on modest hardware
### When to Choose Local
- Sensitive data (medical, legal, personal)
- Offline environments
- High-volume use (avoiding API costs)
- Privacy-first applications
- Air-gapped systems
### When to Choose Cloud
- Quick setup needed
- Best accuracy required
- Real-time streaming
- Light/occasional use
- Mobile or low-power devices
+59
View File
@@ -0,0 +1,59 @@
# Code Structure
This doc explains how the CLI, daemon, and pipeline fit together and where to start reading the code.
## Top-level layout
- cmd/hyprvoice: CLI entrypoint and commands
- internal/: core packages
- docs/: user and developer docs (config, providers, architecture, structure, testing)
- packaging/: AUR and systemd packaging
- .github/workflows/: CI and release workflows
## Control flow (high level)
1. CLI command sends a single-character IPC command over a unix socket.
2. Daemon receives the command and owns lifecycle and state transitions.
3. Pipeline runs: recording -> transcribing -> processing -> injecting.
4. Notifications reflect state changes and errors.
State machine: idle -> recording -> transcribing -> processing -> injecting -> idle
## Key packages
- internal/bus: unix socket IPC, pid file, and client helpers
- internal/daemon: command handling, lifecycle, pipeline ownership
- internal/config: load/save/validate config and hot reload
- internal/pipeline: state machine coordinating recording/transcriber/llm/injection
- internal/recording: PipeWire audio capture
- internal/transcriber: batch and streaming provider adapters
- internal/llm: post-processing adapters and prompts
- internal/injection: wtype/ydotool/clipboard injection
- internal/notify: desktop notifications
- internal/provider: provider registry and model metadata
- internal/models/whisper: local whisper model registry and downloads
- internal/language: language metadata and compatibility rules
- internal/deps: dependency detection (ffmpeg, whisper-cli, etc.)
- internal/tui: interactive configuration wizard
- internal/testutil: shared test helpers
## Entry points and key files
- cmd/hyprvoice/main.go: CLI entrypoint and command wiring
- internal/daemon/daemon.go: daemon lifecycle and command handling
- internal/config/manager.go: config manager and hot reload
- internal/pipeline/: pipeline orchestration and state machine
- internal/recording/: audio capture implementation
- internal/transcriber/: provider-specific adapters
## IPC protocol (daemon control)
- Socket: ~/.cache/hyprvoice/control.sock
- Commands: t=toggle, c=cancel, s=status, v=version, q=quit
## Data and config locations
- Config: ~/.config/hyprvoice/config.toml
- Models: ~/.local/share/hyprvoice/models/whisper/
- PID file: ~/.cache/hyprvoice/hyprvoice.pid
## Suggested reading order
1. cmd/hyprvoice/main.go for CLI command flow.
2. internal/daemon/daemon.go for lifecycle and IPC handling.
3. internal/pipeline for state transitions and orchestration.
4. internal/recording and internal/transcriber for audio and STT.
5. internal/llm and internal/injection for text cleanup and output.
+179
View File
@@ -0,0 +1,179 @@
# Integration Testing
This doc covers `test-models`, the e2e test command for validating provider APIs work correctly.
## When to Run
Run `test-models` when:
- adding a new provider or model
- updating provider adapters
- debugging API connectivity issues
- verifying API keys are valid
- before releases (CI runs this automatically)
## Quick Start
```bash
# test all configured providers (requires API keys)
hyprvoice test-models
# output results to json
hyprvoice test-models --output results.json
```
## What It Tests
The command validates:
1. **Transcription providers**: sends a sample audio file through each model and verifies a transcription is returned
2. **LLM providers**: sends a test phrase through each model and verifies post-processing works
For each model, it reports:
- pass: API responded with valid output
- fail: API error or timeout
- skip: missing API key or dependency (e.g. whisper-cli not installed)
## API Keys
Keys are resolved from config or environment variables:
- `OPENAI_API_KEY`
- `GROQ_API_KEY`
- `DEEPGRAM_API_KEY`
- `ELEVENLABS_API_KEY`
- `MISTRAL_API_KEY`
Models without a valid key are skipped (not failed).
## Options
| Flag | Default | Description |
|------|---------|-------------|
| `--audio` | (downloaded sample) | custom WAV file to use |
| `--record-seconds` | 0 | record mic instead of using a file (e.g. `5s`) |
| `--timeout` | 45s | per-model timeout |
| `--output` | (none) | write JSON report to file |
| `--realtime` | true | pace streaming chunks in real time |
| `--both-modes` | true | test batch+streaming models in both modes |
| `--local-model` | (smallest) | whisper-cpp model to test |
| `--download-local` | false | download local model if missing |
| `--language` | en | language code for tests |
| `--keywords` | Hyprvoice,transcription,dictation | keywords for provider hints |
| `--no-keywords` | false | skip keyword hints |
| `--no-language` | false | use auto-detect instead of explicit language |
## Examples
```bash
# basic run - uses downloaded sample audio
hyprvoice test-models
# use your own audio file
hyprvoice test-models --audio ~/voice-sample.wav
# record 5 seconds from mic
hyprvoice test-models --record-seconds 5s
# longer timeout for slow connections
hyprvoice test-models --timeout 90s
# test local whisper-cpp with specific model
hyprvoice test-models --local-model base.en --download-local
# json report for CI
hyprvoice test-models --output test-results.json
```
## Output
Terminal output shows pass/fail/skip for each model:
```
test-models: total=25 pass=18 fail=2 skip=5
audio: /home/user/.cache/hyprvoice/testaudio.wav
pass openai/whisper-1 batch 1234ms output="She had your dark suit..."
pass openai/gpt-4o-transcribe batch 2156ms output="She had your dark suit..."
pass groq-transcription/whisper-large-v3 batch 456ms output="She had your dark suit..."
skip deepgram/nova-3 batch error=missing api key
fail mistral-transcription/voxtral-mini-latest batch 45000ms error=context deadline exceeded
pass openai/gpt-4o-mini llm 892ms output="I want to test Hyprvoice..."
```
JSON report (`--output`) includes full details:
```json
{
"started_at": "2024-01-15T10:30:00Z",
"audio_src": "/home/user/.cache/hyprvoice/testaudio.wav",
"results": [
{
"provider": "openai",
"model": "whisper-1",
"type": "transcription",
"mode": "batch",
"local": false,
"status": "pass",
"duration_ms": 1234,
"output": "She had your dark suit...",
"output_chars": 45
}
],
"pass_count": 18,
"fail_count": 2,
"skip_count": 5,
"total_count": 25
}
```
## CI Integration
The repo includes a GitHub Actions workflow (`.github/workflows/e2e.yml`) that runs `test-models` on demand:
```yaml
# triggered manually via workflow_dispatch
./hyprvoice test-models \
--timeout=60s \
--output=test-models-report.json
```
Secrets required in repo settings:
- `OPENAI_API_KEY`
- `GROQ_API_KEY`
- `DEEPGRAM_API_KEY`
- `ELEVENLABS_API_KEY`
- `MISTRAL_API_KEY`
## Adding a New Provider
When adding a new provider:
1. implement the adapter in `internal/transcriber/` or `internal/llm/`
2. register models in `internal/provider/`
3. add env var mapping if needed
4. run `test-models` to verify:
```bash
hyprvoice test-models --output before-merge.json
```
5. add the API key to CI secrets
## Local Model Testing
whisper-cpp models require:
- `whisper-cli` binary installed
- model downloaded (`hyprvoice model download <model>`)
Use `--download-local` to auto-download during test:
```bash
hyprvoice test-models --local-model tiny.en --download-local
```
Only the smallest local model is tested by default to save time. If it works, larger models should work too.
## Troubleshooting
**All models skipped**: check API keys are set in env or config
**Timeouts**: increase `--timeout`, check network connectivity
**whisper-cpp skipped**: install `whisper-cli` and download a model
**Streaming failures**: some providers have separate streaming endpoints - check provider docs
+24 -1
View File
@@ -4,13 +4,36 @@ go 1.24.5
require ( require (
github.com/BurntSushi/toml v1.5.0 github.com/BurntSushi/toml v1.5.0
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/fsnotify/fsnotify v1.9.0 github.com/fsnotify/fsnotify v1.9.0
github.com/gorilla/websocket v1.5.3
github.com/sashabaranov/go-openai v1.41.1 github.com/sashabaranov/go-openai v1.41.1
github.com/spf13/cobra v1.9.1 github.com/spf13/cobra v1.9.1
golang.org/x/text v0.23.0
) )
require ( require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sahilm/fuzzy v0.1.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect github.com/spf13/pflag v1.0.6 // indirect
golang.org/x/sys v0.13.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
golang.org/x/sys v0.36.0 // indirect
) )
+57 -2
View File
@@ -1,18 +1,73 @@
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/sashabaranov/go-openai v1.41.1 h1:zf5tM+GuxpyiyD9XZg8nCqu52eYFQg9OOew0gnIuDy4= github.com/sashabaranov/go-openai v1.41.1 h1:zf5tM+GuxpyiyD9XZg8nCqu52eYFQg9OOew0gnIuDy4=
github.com/sashabaranov/go-openai v1.41.1/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= github.com/sashabaranov/go-openai v1.41.1/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-517
View File
@@ -1,517 +0,0 @@
package config
import (
"fmt"
"log"
"os"
"path/filepath"
"reflect"
"time"
"github.com/BurntSushi/toml"
"github.com/leonardotrapani/hyprvoice/internal/injection"
"github.com/leonardotrapani/hyprvoice/internal/notify"
"github.com/leonardotrapani/hyprvoice/internal/recording"
"github.com/leonardotrapani/hyprvoice/internal/transcriber"
)
type Config struct {
Recording RecordingConfig `toml:"recording"`
Transcription TranscriptionConfig `toml:"transcription"`
Injection InjectionConfig `toml:"injection"`
Notifications NotificationsConfig `toml:"notifications"`
}
type RecordingConfig struct {
SampleRate int `toml:"sample_rate"`
Channels int `toml:"channels"`
Format string `toml:"format"`
BufferSize int `toml:"buffer_size"`
Device string `toml:"device"`
ChannelBufferSize int `toml:"channel_buffer_size"`
Timeout time.Duration `toml:"timeout"`
}
type TranscriptionConfig struct {
Provider string `toml:"provider"`
APIKey string `toml:"api_key"`
Language string `toml:"language"`
Model string `toml:"model"`
}
type InjectionConfig struct {
Backends []string `toml:"backends"`
YdotoolTimeout time.Duration `toml:"ydotool_timeout"`
WtypeTimeout time.Duration `toml:"wtype_timeout"`
ClipboardTimeout time.Duration `toml:"clipboard_timeout"`
}
type NotificationsConfig struct {
Enabled bool `toml:"enabled"`
Type string `toml:"type"` // "desktop", "log", "none"
Messages MessagesConfig `toml:"messages"`
}
type MessageConfig struct {
Title string `toml:"title"`
Body string `toml:"body"`
}
type MessagesConfig struct {
RecordingStarted MessageConfig `toml:"recording_started"`
Transcribing MessageConfig `toml:"transcribing"`
ConfigReloaded MessageConfig `toml:"config_reloaded"`
OperationCancelled MessageConfig `toml:"operation_cancelled"`
RecordingAborted MessageConfig `toml:"recording_aborted"`
InjectionAborted MessageConfig `toml:"injection_aborted"`
}
// Resolve merges user config with defaults from MessageDefs
func (m *MessagesConfig) Resolve() map[notify.MessageType]notify.Message {
result := make(map[notify.MessageType]notify.Message)
// Build toml tag → field index map
v := reflect.ValueOf(m).Elem()
t := v.Type()
tagToField := make(map[string]int)
for i := 0; i < t.NumField(); i++ {
tagToField[t.Field(i).Tag.Get("toml")] = i
}
for _, def := range notify.MessageDefs {
msg := notify.Message{
Title: def.DefaultTitle,
Body: def.DefaultBody,
IsError: def.IsError,
}
if idx, ok := tagToField[def.ConfigKey]; ok {
userMsg := v.Field(idx).Interface().(MessageConfig)
if userMsg.Title != "" {
msg.Title = userMsg.Title
}
if userMsg.Body != "" {
msg.Body = userMsg.Body
}
}
result[def.Type] = msg
}
return result
}
func (c *Config) ToRecordingConfig() recording.Config {
return recording.Config{
SampleRate: c.Recording.SampleRate,
Channels: c.Recording.Channels,
Format: c.Recording.Format,
BufferSize: c.Recording.BufferSize,
Device: c.Recording.Device,
ChannelBufferSize: c.Recording.ChannelBufferSize,
Timeout: c.Recording.Timeout,
}
}
func (c *Config) ToTranscriberConfig() transcriber.Config {
config := transcriber.Config{
Provider: c.Transcription.Provider,
APIKey: c.Transcription.APIKey,
Language: c.Transcription.Language,
Model: c.Transcription.Model,
}
// Check for API key in environment variables if not in config
if config.APIKey == "" {
switch c.Transcription.Provider {
case "openai":
config.APIKey = os.Getenv("OPENAI_API_KEY")
case "groq-transcription", "groq-translation":
config.APIKey = os.Getenv("GROQ_API_KEY")
case "mistral-transcription":
config.APIKey = os.Getenv("MISTRAL_API_KEY")
case "elevenlabs":
config.APIKey = os.Getenv("ELEVENLABS_API_KEY")
}
}
return config
}
func (c *Config) ToInjectionConfig() injection.Config {
return injection.Config{
Backends: c.Injection.Backends,
YdotoolTimeout: c.Injection.YdotoolTimeout,
WtypeTimeout: c.Injection.WtypeTimeout,
ClipboardTimeout: c.Injection.ClipboardTimeout,
}
}
func (c *Config) Validate() error {
// Recording
if c.Recording.SampleRate <= 0 {
return fmt.Errorf("invalid recording.sample_rate: %d", c.Recording.SampleRate)
}
if c.Recording.Channels <= 0 {
return fmt.Errorf("invalid recording.channels: %d", c.Recording.Channels)
}
if c.Recording.BufferSize <= 0 {
return fmt.Errorf("invalid recording.buffer_size: %d", c.Recording.BufferSize)
}
if c.Recording.ChannelBufferSize <= 0 {
return fmt.Errorf("invalid recording.channel_buffer_size: %d", c.Recording.ChannelBufferSize)
}
if c.Recording.Format == "" {
return fmt.Errorf("invalid recording.format: empty")
}
if c.Recording.Timeout <= 0 {
return fmt.Errorf("invalid recording.timeout: %v", c.Recording.Timeout)
}
// Transcription
if c.Transcription.Provider == "" {
return fmt.Errorf("invalid transcription.provider: empty")
}
// Validate provider-specific settings
switch c.Transcription.Provider {
case "openai":
apiKey := c.Transcription.APIKey
if apiKey == "" {
apiKey = os.Getenv("OPENAI_API_KEY")
}
if apiKey == "" {
return fmt.Errorf("OpenAI API key required: not found in config (transcription.api_key) or environment variable (OPENAI_API_KEY)")
}
// Validate language code if provided (empty string means auto-detect)
if c.Transcription.Language != "" && !isValidLanguageCode(c.Transcription.Language) {
return fmt.Errorf("invalid transcription.language: %s (use empty string for auto-detect or ISO-639-1 codes like 'en', 'es', 'fr')", c.Transcription.Language)
}
case "groq-transcription":
apiKey := c.Transcription.APIKey
if apiKey == "" {
apiKey = os.Getenv("GROQ_API_KEY")
}
if apiKey == "" {
return fmt.Errorf("Groq API key required: not found in config (transcription.api_key) or environment variable (GROQ_API_KEY)")
}
// Validate language code if provided (empty string means auto-detect)
if c.Transcription.Language != "" && !isValidLanguageCode(c.Transcription.Language) {
return fmt.Errorf("invalid transcription.language: %s (use empty string for auto-detect or ISO-639-1 codes like 'en', 'es', 'fr')", c.Transcription.Language)
}
// Validate Groq model
validGroqModels := map[string]bool{"whisper-large-v3": true, "whisper-large-v3-turbo": true}
if c.Transcription.Model != "" && !validGroqModels[c.Transcription.Model] {
return fmt.Errorf("invalid model for groq-transcription: %s (must be whisper-large-v3 or whisper-large-v3-turbo)", c.Transcription.Model)
}
case "groq-translation":
apiKey := c.Transcription.APIKey
if apiKey == "" {
apiKey = os.Getenv("GROQ_API_KEY")
}
if apiKey == "" {
return fmt.Errorf("Groq API key required: not found in config (transcription.api_key) or environment variable (GROQ_API_KEY)")
}
// For translation, language field hints at source language (output is always English)
if c.Transcription.Language != "" && !isValidLanguageCode(c.Transcription.Language) {
return fmt.Errorf("invalid transcription.language: %s (use empty string for auto-detect or ISO-639-1 codes like 'en', 'es', 'fr')", c.Transcription.Language)
}
// Validate Groq translation model - only whisper-large-v3 is supported (no turbo)
if c.Transcription.Model != "" && c.Transcription.Model != "whisper-large-v3" {
return fmt.Errorf("invalid model for groq-translation: %s (must be whisper-large-v3, turbo version not supported for translation)", c.Transcription.Model)
}
case "mistral-transcription":
apiKey := c.Transcription.APIKey
if apiKey == "" {
apiKey = os.Getenv("MISTRAL_API_KEY")
}
if apiKey == "" {
return fmt.Errorf("Mistral API key required: not found in config (transcription.api_key) or environment variable (MISTRAL_API_KEY)")
}
// Validate language code if provided (empty string means auto-detect)
if c.Transcription.Language != "" && !isValidLanguageCode(c.Transcription.Language) {
return fmt.Errorf("invalid transcription.language: %s (use empty string for auto-detect or ISO-639-1 codes like 'en', 'es', 'fr')", c.Transcription.Language)
}
// Validate Mistral model
validMistralModels := map[string]bool{"voxtral-mini-latest": true, "voxtral-mini-2507": true}
if c.Transcription.Model != "" && !validMistralModels[c.Transcription.Model] {
return fmt.Errorf("invalid model for mistral-transcription: %s (must be voxtral-mini-latest or voxtral-mini-2507)", c.Transcription.Model)
}
case "elevenlabs":
apiKey := c.Transcription.APIKey
if apiKey == "" {
apiKey = os.Getenv("ELEVENLABS_API_KEY")
}
if apiKey == "" {
return fmt.Errorf("ElevenLabs API key required: not found in config (transcription.api_key) or environment variable (ELEVENLABS_API_KEY)")
}
// Validate language code if provided (empty string means auto-detect)
if c.Transcription.Language != "" && !isValidLanguageCode(c.Transcription.Language) {
return fmt.Errorf("invalid transcription.language: %s (use empty string for auto-detect or ISO-639-1 codes like 'en', 'pt', 'es')", c.Transcription.Language)
}
// Validate Eleven Labs model
validModels := map[string]bool{"scribe_v1": true, "scribe_v2": true}
if c.Transcription.Model != "" && !validModels[c.Transcription.Model] {
return fmt.Errorf("invalid model for elevenlabs: %s (must be scribe_v1 or scribe_v2)", c.Transcription.Model)
}
default:
return fmt.Errorf("unsupported transcription.provider: %s (must be openai, groq-transcription, groq-translation, mistral-transcription, or elevenlabs)", c.Transcription.Provider)
}
if c.Transcription.Model == "" {
return fmt.Errorf("invalid transcription.model: empty")
}
// Injection
if len(c.Injection.Backends) == 0 {
return fmt.Errorf("invalid injection.backends: empty (must have at least one backend)")
}
validBackends := map[string]bool{"ydotool": true, "wtype": true, "clipboard": true}
for _, backend := range c.Injection.Backends {
if !validBackends[backend] {
return fmt.Errorf("invalid injection.backends: unknown backend %q (must be ydotool, wtype, or clipboard)", backend)
}
}
if c.Injection.YdotoolTimeout <= 0 {
return fmt.Errorf("invalid injection.ydotool_timeout: %v", c.Injection.YdotoolTimeout)
}
if c.Injection.WtypeTimeout <= 0 {
return fmt.Errorf("invalid injection.wtype_timeout: %v", c.Injection.WtypeTimeout)
}
if c.Injection.ClipboardTimeout <= 0 {
return fmt.Errorf("invalid injection.clipboard_timeout: %v", c.Injection.ClipboardTimeout)
}
// Notifications
validTypes := map[string]bool{"desktop": true, "log": true, "none": true}
if !validTypes[c.Notifications.Type] {
return fmt.Errorf("invalid notifications.type: %s (must be desktop, log, or none)", c.Notifications.Type)
}
return nil
}
func isValidLanguageCode(code string) bool {
validCodes := map[string]bool{
"en": true, "es": true, "fr": true, "de": true, "it": true, "pt": true,
"ru": true, "ja": true, "ko": true, "zh": true, "ar": true, "hi": true,
"nl": true, "sv": true, "da": true, "no": true, "fi": true, "pl": true,
"tr": true, "he": true, "th": true, "vi": true, "id": true, "ms": true,
"uk": true, "cs": true, "hu": true, "ro": true, "bg": true, "hr": true,
"sk": true, "sl": true, "et": true, "lv": true, "lt": true, "mt": true,
"cy": true, "ga": true, "eu": true, "ca": true, "gl": true, "is": true,
"mk": true, "sq": true, "az": true, "be": true, "ka": true, "hy": true,
"kk": true, "ky": true, "tg": true, "uz": true, "mn": true, "ne": true,
"si": true, "km": true, "lo": true, "my": true, "fa": true, "ps": true,
"ur": true, "bn": true, "ta": true, "te": true, "ml": true, "kn": true,
"gu": true, "pa": true, "or": true, "as": true, "mr": true, "sa": true,
"sw": true, "yo": true, "ig": true, "ha": true, "zu": true, "xh": true,
"af": true, "am": true, "mg": true, "so": true, "sn": true, "rw": true,
}
return validCodes[code]
}
func GetConfigPath() (string, error) {
configDir, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("failed to get user config directory: %w", err)
}
hyprvoiceDir := filepath.Join(configDir, "hyprvoice")
if err := os.MkdirAll(hyprvoiceDir, 0755); err != nil {
return "", fmt.Errorf("failed to create config directory: %w", err)
}
return filepath.Join(hyprvoiceDir, "config.toml"), nil
}
// legacyInjectionConfig for migration from old mode-based config
type legacyInjectionConfig struct {
Mode string `toml:"mode"`
}
type legacyConfig struct {
Injection legacyInjectionConfig `toml:"injection"`
}
func Load() (*Config, error) {
configPath, err := GetConfigPath()
if err != nil {
return nil, err
}
// If config file doesn't exist, create it with defaults
if _, err := os.Stat(configPath); os.IsNotExist(err) {
log.Printf("Config: no config file found at %s, creating with defaults", configPath)
if err := SaveDefaultConfig(); err != nil {
return nil, fmt.Errorf("failed to create default config: %w", err)
}
log.Printf("Config: default configuration created successfully")
return Load() // Recursively load the config, now file will exist
}
log.Printf("Config: loading configuration from %s", configPath)
var config Config
if _, err := toml.DecodeFile(configPath, &config); err != nil {
return nil, fmt.Errorf("failed to parse config file %s: %w", configPath, err)
}
// Migrate legacy mode-based config to backends
if len(config.Injection.Backends) == 0 {
var legacy legacyConfig
toml.DecodeFile(configPath, &legacy)
config.migrateInjectionMode(legacy.Injection.Mode)
}
log.Printf("Config: configuration loaded successfully")
return &config, nil
}
// migrateInjectionMode converts old mode field to new backends array
func (c *Config) migrateInjectionMode(mode string) {
switch mode {
case "clipboard":
c.Injection.Backends = []string{"clipboard"}
log.Printf("Config: migrated injection.mode='clipboard' to backends=['clipboard']")
case "type":
c.Injection.Backends = []string{"wtype"}
log.Printf("Config: migrated injection.mode='type' to backends=['wtype']")
case "fallback":
c.Injection.Backends = []string{"wtype", "clipboard"}
log.Printf("Config: migrated injection.mode='fallback' to backends=['wtype', 'clipboard']")
default:
// Default for new installs or unknown modes
c.Injection.Backends = []string{"ydotool", "wtype", "clipboard"}
if mode != "" {
log.Printf("Config: unknown injection.mode='%s', using default backends", mode)
}
}
// Set default ydotool timeout if not set
if c.Injection.YdotoolTimeout == 0 {
c.Injection.YdotoolTimeout = 5 * time.Second
}
log.Printf("Config: legacy 'mode' config detected - please update your config.toml to use 'backends' instead")
}
func SaveDefaultConfig() error {
configPath, err := GetConfigPath()
if err != nil {
return err
}
file, err := os.Create(configPath)
if err != nil {
return fmt.Errorf("failed to create config file: %w", err)
}
defer file.Close()
configContent := `# Hyprvoice Configuration
# This file is automatically generated with defaults.
# Edit values as needed - changes are applied immediately without daemon restart.
# Audio Recording Configuration
[recording]
sample_rate = 16000 # Audio sample rate in Hz (16000 recommended for speech)
channels = 1 # Number of audio channels (1 = mono, 2 = stereo)
format = "s16" # Audio format (s16 = 16-bit signed integers)
buffer_size = 8192 # Internal buffer size in bytes (larger = less CPU, more latency)
device = "" # PipeWire audio device (empty = use default microphone)
channel_buffer_size = 30 # Audio frame buffer size (frames to buffer)
timeout = "5m" # Maximum recording duration (e.g., "30s", "2m", "5m")
# Speech Transcription Configuration
[transcription]
provider = "openai" # Transcription service: "openai", "groq-transcription", "groq-translation", "mistral-transcription", or "elevenlabs"
api_key = "" # API key (or set OPENAI_API_KEY/GROQ_API_KEY/MISTRAL_API_KEY/ELEVENLABS_API_KEY environment variable)
language = "" # Language code (empty for auto-detect, "en", "it", "es", "fr", etc.)
model = "whisper-1" # Model: OpenAI="whisper-1", Groq="whisper-large-v3", Mistral="voxtral-mini-latest", ElevenLabs="scribe_v1"
# Text Injection Configuration
[injection]
backends = ["ydotool", "wtype", "clipboard"] # Ordered fallback chain (tries each until one succeeds)
ydotool_timeout = "5s" # Timeout for ydotool commands
wtype_timeout = "5s" # Timeout for wtype commands
clipboard_timeout = "3s" # Timeout for clipboard operations
# Desktop Notification Configuration
[notifications]
enabled = true # Enable desktop notifications
type = "desktop" # Notification type ("desktop", "log", "none")
# Custom notification messages (optional - defaults shown below)
# Uncomment and modify to customize notification text
# [notifications.messages]
# [notifications.messages.recording_started]
# title = "Hyprvoice"
# body = "Recording Started"
# [notifications.messages.transcribing]
# title = "Hyprvoice"
# body = "Recording Ended... Transcribing"
# [notifications.messages.config_reloaded]
# title = "Hyprvoice"
# body = "Config Reloaded"
# [notifications.messages.operation_cancelled]
# title = "Hyprvoice"
# body = "Operation Cancelled"
# [notifications.messages.recording_aborted]
# body = "Recording Aborted"
# [notifications.messages.injection_aborted]
# body = "Injection Aborted"
#
# Emoji-only example (for minimal pill-style notifications):
# [notifications.messages.recording_started]
# title = ""
# body = "🎤"
# [notifications.messages.transcribing]
# title = ""
# body = "⏳"
# [notifications.messages.config_reloaded]
# title = ""
# body = "🔧"
# Backend explanations:
# - "ydotool": Uses ydotool (requires ydotoold daemon running for ydotool v1.0.0+). Most compatible with Chromium/Electron apps.
# - "wtype": Uses wtype for Wayland. May have issues with some Chromium-based apps.
# - "clipboard": Copies text to clipboard only (most reliable, but requires manual paste).
#
# The backends are tried in order. First successful one wins.
# Example configurations:
# backends = ["clipboard"] # Clipboard only (safest)
# backends = ["wtype", "clipboard"] # wtype with clipboard fallback
# backends = ["ydotool", "wtype", "clipboard"] # Full fallback chain (default)
#
# Provider explanations:
# - "openai": OpenAI Whisper API (cloud-based, requires OPENAI_API_KEY)
# - "groq-transcription": Groq Whisper API for transcription (fast, requires GROQ_API_KEY)
# Models: whisper-large-v3 or whisper-large-v3-turbo
# - "groq-translation": Groq Whisper API for translation to English (always outputs English text)
# Models: whisper-large-v3 only (turbo not supported for translation)
# - "mistral-transcription": Mistral Voxtral API (excellent for European languages, requires MISTRAL_API_KEY)
# Models: voxtral-mini-latest or voxtral-mini-2507
# - "elevenlabs": ElevenLabs Scribe API (excellent accuracy, 99 languages, requires ELEVENLABS_API_KEY)
# Models: scribe_v1 (99 languages, best accuracy) or scribe_v2 (90 languages, real-time)
#
# Language codes: Use empty string ("") for automatic detection, or specific codes like:
# "en" (English), "it" (Italian), "es" (Spanish), "fr" (French), "de" (German), etc.
# For groq-translation, the language field hints at the source audio language for better accuracy.
`
if _, err := file.WriteString(configContent); err != nil {
return fmt.Errorf("failed to write config content: %w", err)
}
return nil
}
File diff suppressed because it is too large Load Diff
+114
View File
@@ -0,0 +1,114 @@
package config
import (
"os"
"github.com/leonardotrapani/hyprvoice/internal/injection"
"github.com/leonardotrapani/hyprvoice/internal/provider"
"github.com/leonardotrapani/hyprvoice/internal/recording"
"github.com/leonardotrapani/hyprvoice/internal/transcriber"
)
func (c *Config) ToRecordingConfig() recording.Config {
return recording.Config{
SampleRate: c.Recording.SampleRate,
Channels: c.Recording.Channels,
Format: c.Recording.Format,
BufferSize: c.Recording.BufferSize,
Device: c.Recording.Device,
ChannelBufferSize: c.Recording.ChannelBufferSize,
Timeout: c.Recording.Timeout,
}
}
func (c *Config) ToTranscriberConfig() transcriber.Config {
config := transcriber.Config{
Provider: c.Transcription.Provider,
Language: c.resolveEffectiveLanguage(),
Model: c.Transcription.Model,
Keywords: c.Keywords,
Threads: c.Transcription.Threads,
Streaming: c.Transcription.Streaming,
}
config.APIKey = c.resolveAPIKeyForProvider(c.Transcription.Provider)
return config
}
// resolveEffectiveLanguage returns the language for transcription
func (c *Config) resolveEffectiveLanguage() string {
return c.Transcription.Language
}
// resolveAPIKeyForProvider returns the API key for a provider from config or env
func (c *Config) resolveAPIKeyForProvider(providerName string) string {
baseName := provider.BaseProviderName(providerName)
envVar := provider.EnvVarForProvider(providerName)
if c.Providers != nil {
if pc, ok := c.Providers[baseName]; ok && pc.APIKey != "" {
return pc.APIKey
}
}
if envVar != "" {
return os.Getenv(envVar)
}
return ""
}
// ToLLMConfig returns the LLM adapter configuration
func (c *Config) ToLLMConfig() LLMAdapterConfig {
config := LLMAdapterConfig{
Provider: c.LLM.Provider,
Model: c.LLM.Model,
RemoveStutters: c.LLM.PostProcessing.RemoveStutters,
AddPunctuation: c.LLM.PostProcessing.AddPunctuation,
FixGrammar: c.LLM.PostProcessing.FixGrammar,
RemoveFillerWords: c.LLM.PostProcessing.RemoveFillerWords,
Keywords: c.Keywords,
}
if c.LLM.Provider != "" {
config.APIKey = c.resolveAPIKeyForLLMProvider(c.LLM.Provider)
}
if c.LLM.CustomPrompt.Enabled && c.LLM.CustomPrompt.Prompt != "" {
config.CustomPrompt = c.LLM.CustomPrompt.Prompt
}
return config
}
// resolveAPIKeyForLLMProvider returns the API key for an LLM provider
func (c *Config) resolveAPIKeyForLLMProvider(providerName string) string {
envVar := provider.EnvVarForProvider(providerName)
if c.Providers != nil {
if pc, ok := c.Providers[providerName]; ok && pc.APIKey != "" {
return pc.APIKey
}
}
if envVar != "" {
return os.Getenv(envVar)
}
return ""
}
// IsLLMEnabled returns true if LLM post-processing is enabled and configured
func (c *Config) IsLLMEnabled() bool {
return c.LLM.Enabled && c.LLM.Provider != "" && c.LLM.Model != ""
}
func (c *Config) ToInjectionConfig() injection.Config {
return injection.Config{
Backends: c.Injection.Backends,
YdotoolTimeout: c.Injection.YdotoolTimeout,
WtypeTimeout: c.Injection.WtypeTimeout,
ClipboardTimeout: c.Injection.ClipboardTimeout,
}
}
+38
View File
@@ -0,0 +1,38 @@
package config
import "time"
// DefaultConfig returns the initial configuration used for onboarding.
func DefaultConfig() *Config {
return &Config{
Recording: RecordingConfig{
SampleRate: 16000,
Channels: 1,
Format: "s16",
BufferSize: 8192,
Device: "",
ChannelBufferSize: 30,
Timeout: 5 * time.Minute,
},
Transcription: TranscriptionConfig{
Language: "",
Streaming: false,
Threads: 0,
},
Injection: InjectionConfig{
Backends: []string{"ydotool", "wtype", "clipboard"},
YdotoolTimeout: 5 * time.Second,
WtypeTimeout: 5 * time.Second,
ClipboardTimeout: 3 * time.Second,
},
Notifications: NotificationsConfig{
Enabled: false,
Type: "",
},
Providers: make(map[string]ProviderConfig),
Keywords: nil,
LLM: LLMConfig{
Enabled: false,
},
}
}
+114
View File
@@ -0,0 +1,114 @@
package config
import (
"errors"
"fmt"
"log"
"os"
"path/filepath"
"runtime"
"github.com/BurntSushi/toml"
)
var ErrConfigNotFound = errors.New("config not found")
func GetConfigPath() (string, error) {
configDir, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("failed to get user config directory: %w", err)
}
hyprvoiceDir := filepath.Join(configDir, "hyprvoice")
if err := os.MkdirAll(hyprvoiceDir, 0755); err != nil {
return "", fmt.Errorf("failed to create config directory: %w", err)
}
return filepath.Join(hyprvoiceDir, "config.toml"), nil
}
func Load() (*Config, error) {
config, legacy, err := LoadOrLegacy()
if err != nil {
return nil, err
}
if legacy {
log.Printf("Config: legacy configuration detected - run hyprvoice onboarding")
return nil, fmt.Errorf("%w: run hyprvoice onboarding", ErrConfigNotFound)
}
return config, nil
}
// LoadOrLegacy loads config and returns (config, isLegacy, error).
// If config is legacy, returns default config with isLegacy=true instead of error.
func LoadOrLegacy() (*Config, bool, error) {
configPath, err := GetConfigPath()
if err != nil {
return nil, false, err
}
if _, err := os.Stat(configPath); os.IsNotExist(err) {
return nil, false, fmt.Errorf("%w: run hyprvoice onboarding", ErrConfigNotFound)
} else if err != nil {
return nil, false, fmt.Errorf("failed to stat config file %s: %w", configPath, err)
}
log.Printf("Config: loading configuration from %s", configPath)
var config Config
meta, err := toml.DecodeFile(configPath, &config)
if err != nil {
return nil, false, fmt.Errorf("failed to parse config file %s: %w", configPath, err)
}
if isLegacyConfig(meta, &config) {
log.Printf("Config: legacy configuration detected - run hyprvoice onboarding")
return DefaultConfig(), true, nil
}
if config.Providers == nil {
config.Providers = make(map[string]ProviderConfig)
}
config.applyLLMDefaults()
config.applyThreadsDefault()
log.Printf("Config: configuration loaded successfully")
return &config, false, nil
}
func isLegacyConfig(meta toml.MetaData, config *Config) bool {
if meta.IsDefined("transcription", "api_key") {
return true
}
if meta.IsDefined("injection", "mode") {
return true
}
if meta.IsDefined("general", "language") {
return true
}
if config.Transcription.Provider == "groq-translation" {
return true
}
return false
}
// applyThreadsDefault sets default threads for local transcription if not explicitly set
func (c *Config) applyThreadsDefault() {
if c.Transcription.Threads == 0 {
threads := runtime.NumCPU() - 1
if threads < 1 {
threads = 1
}
c.Transcription.Threads = threads
}
}
// applyLLMDefaults sets default values for LLM config
func (c *Config) applyLLMDefaults() {
pp := &c.LLM.PostProcessing
if !pp.RemoveStutters && !pp.AddPunctuation && !pp.FixGrammar && !pp.RemoveFillerWords {
pp.RemoveStutters = true
pp.AddPunctuation = true
pp.FixGrammar = true
pp.RemoveFillerWords = true
}
}
+26 -5
View File
@@ -22,25 +22,33 @@ type Manager struct {
debounceTimer *time.Timer debounceTimer *time.Timer
debounceMutex sync.Mutex debounceMutex sync.Mutex
debounceDelay time.Duration debounceDelay time.Duration
// legacy tracks if config is in legacy format (needs onboarding)
legacy bool
} }
func NewManager() (*Manager, error) { func NewManager() (*Manager, error) {
log.Printf("Config manager: initializing configuration system...") log.Printf("Config manager: initializing configuration system...")
config, err := Load() config, legacy, err := LoadOrLegacy()
if err != nil { if err != nil {
log.Printf("Config manager: failed to load initial configuration: %v", err) log.Printf("Config manager: failed to load initial configuration: %v", err)
return nil, err return nil, err
} }
log.Printf("Config manager: validating initial configuration...") if legacy {
if err := config.Validate(); err != nil { log.Printf("Config manager: legacy config detected, daemon will prompt for onboarding")
log.Printf("Config manager: validation warning: %v", err) } else {
log.Printf("Config manager: validating initial configuration...")
if err := config.Validate(); err != nil {
log.Printf("Config manager: validation warning: %v", err)
}
} }
m := &Manager{ m := &Manager{
config: config, config: config,
debounceDelay: 500 * time.Millisecond, // 500ms debounce delay debounceDelay: 500 * time.Millisecond, // 500ms debounce delay
legacy: legacy,
} }
log.Printf("Config manager: initialization completed successfully") log.Printf("Config manager: initialization completed successfully")
@@ -56,6 +64,13 @@ func (m *Manager) GetConfig() *Config {
return &configCopy return &configCopy
} }
// IsLegacy returns true if the config is in legacy format and needs onboarding
func (m *Manager) IsLegacy() bool {
m.mu.RLock()
defer m.mu.RUnlock()
return m.legacy
}
func (m *Manager) StartWatching(ctx context.Context) error { func (m *Manager) StartWatching(ctx context.Context) error {
configPath, err := GetConfigPath() configPath, err := GetConfigPath()
if err != nil { if err != nil {
@@ -136,12 +151,17 @@ func (m *Manager) watchLoop(ctx context.Context, configPath string) {
func (m *Manager) reloadConfig() { func (m *Manager) reloadConfig() {
log.Printf("Config manager: starting configuration reload...") log.Printf("Config manager: starting configuration reload...")
newConfig, err := Load() newConfig, legacy, err := LoadOrLegacy()
if err != nil { if err != nil {
log.Printf("Config manager: failed to reload config: %v", err) log.Printf("Config manager: failed to reload config: %v", err)
return return
} }
if legacy {
log.Printf("Config manager: config still in legacy format, skipping reload")
return
}
log.Printf("Config manager: validating new configuration...") log.Printf("Config manager: validating new configuration...")
if err := newConfig.Validate(); err != nil { if err := newConfig.Validate(); err != nil {
log.Printf("Config manager: invalid config after reload: %v", err) log.Printf("Config manager: invalid config after reload: %v", err)
@@ -150,6 +170,7 @@ func (m *Manager) reloadConfig() {
m.mu.Lock() m.mu.Lock()
m.config = newConfig m.config = newConfig
m.legacy = false // clear legacy flag on successful reload
onConfigReload := m.onConfigReload onConfigReload := m.onConfigReload
m.mu.Unlock() m.mu.Unlock()
+344
View File
@@ -0,0 +1,344 @@
package config
import (
"fmt"
"os"
"strings"
)
// Save writes the config to the config file with formatted TOML output
func Save(cfg *Config) error {
configPath, err := GetConfigPath()
if err != nil {
return err
}
file, err := os.Create(configPath)
if err != nil {
return fmt.Errorf("failed to create config file: %w", err)
}
defer file.Close()
var sb strings.Builder
// Header
sb.WriteString(`# Hyprvoice Configuration
# Generated by hyprvoice onboarding or configure
# Changes are applied immediately without daemon restart.
`)
// Keywords (must be before any table definitions in TOML)
if len(cfg.Keywords) > 0 {
sb.WriteString("# Keywords help transcription and LLM spell names/terms correctly\n")
sb.WriteString("keywords = [")
for i, kw := range cfg.Keywords {
if i > 0 {
sb.WriteString(", ")
}
sb.WriteString(fmt.Sprintf("%q", kw))
}
sb.WriteString("]\n\n")
}
// Providers section
if len(cfg.Providers) > 0 {
sb.WriteString("# API Keys for providers\n")
for name, pc := range cfg.Providers {
sb.WriteString(fmt.Sprintf("[providers.%s]\n", name))
sb.WriteString(fmt.Sprintf(" api_key = %q\n", pc.APIKey))
sb.WriteString("\n")
}
}
// Recording
sb.WriteString(`# Audio Recording Configuration
[recording]
`)
sb.WriteString(fmt.Sprintf(" sample_rate = %d\n", cfg.Recording.SampleRate))
sb.WriteString(fmt.Sprintf(" channels = %d\n", cfg.Recording.Channels))
sb.WriteString(fmt.Sprintf(" format = %q\n", cfg.Recording.Format))
sb.WriteString(fmt.Sprintf(" buffer_size = %d\n", cfg.Recording.BufferSize))
sb.WriteString(fmt.Sprintf(" device = %q\n", cfg.Recording.Device))
sb.WriteString(fmt.Sprintf(" channel_buffer_size = %d\n", cfg.Recording.ChannelBufferSize))
sb.WriteString(fmt.Sprintf(" timeout = %q\n", cfg.Recording.Timeout.String()))
sb.WriteString("\n")
// Transcription
sb.WriteString(`# Speech Transcription Configuration
[transcription]
`)
sb.WriteString(fmt.Sprintf(" provider = %q\n", cfg.Transcription.Provider))
sb.WriteString(fmt.Sprintf(" language = %q\n", cfg.Transcription.Language))
sb.WriteString(fmt.Sprintf(" model = %q\n", cfg.Transcription.Model))
sb.WriteString(fmt.Sprintf(" streaming = %v\n", cfg.Transcription.Streaming))
sb.WriteString(fmt.Sprintf(" threads = %d\n", cfg.Transcription.Threads))
sb.WriteString("\n")
// LLM
sb.WriteString(`# LLM Post-Processing Configuration
[llm]
`)
sb.WriteString(fmt.Sprintf(" enabled = %v\n", cfg.LLM.Enabled))
if cfg.LLM.Provider != "" {
sb.WriteString(fmt.Sprintf(" provider = %q\n", cfg.LLM.Provider))
}
if cfg.LLM.Model != "" {
sb.WriteString(fmt.Sprintf(" model = %q\n", cfg.LLM.Model))
}
sb.WriteString("\n")
sb.WriteString(" [llm.post_processing]\n")
sb.WriteString(fmt.Sprintf(" remove_stutters = %v\n", cfg.LLM.PostProcessing.RemoveStutters))
sb.WriteString(fmt.Sprintf(" add_punctuation = %v\n", cfg.LLM.PostProcessing.AddPunctuation))
sb.WriteString(fmt.Sprintf(" fix_grammar = %v\n", cfg.LLM.PostProcessing.FixGrammar))
sb.WriteString(fmt.Sprintf(" remove_filler_words = %v\n", cfg.LLM.PostProcessing.RemoveFillerWords))
sb.WriteString("\n")
sb.WriteString(" [llm.custom_prompt]\n")
sb.WriteString(fmt.Sprintf(" enabled = %v\n", cfg.LLM.CustomPrompt.Enabled))
if cfg.LLM.CustomPrompt.Prompt != "" {
sb.WriteString(fmt.Sprintf(" prompt = %q\n", cfg.LLM.CustomPrompt.Prompt))
}
sb.WriteString("\n")
// Injection
sb.WriteString(`# Text Injection Configuration
[injection]
`)
sb.WriteString(" backends = [")
for i, b := range cfg.Injection.Backends {
if i > 0 {
sb.WriteString(", ")
}
sb.WriteString(fmt.Sprintf("%q", b))
}
sb.WriteString("]\n")
sb.WriteString(fmt.Sprintf(" ydotool_timeout = %q\n", cfg.Injection.YdotoolTimeout.String()))
sb.WriteString(fmt.Sprintf(" wtype_timeout = %q\n", cfg.Injection.WtypeTimeout.String()))
sb.WriteString(fmt.Sprintf(" clipboard_timeout = %q\n", cfg.Injection.ClipboardTimeout.String()))
sb.WriteString("\n")
// Notifications
sb.WriteString(`# Desktop Notification Configuration
[notifications]
`)
sb.WriteString(fmt.Sprintf(" enabled = %v\n", cfg.Notifications.Enabled))
sb.WriteString(fmt.Sprintf(" type = %q\n", cfg.Notifications.Type))
// Write custom messages if any
msgs := cfg.Notifications.Messages
if hasCustomMessages(msgs) {
sb.WriteString("\n [notifications.messages]\n")
if msgs.RecordingStarted.Title != "" || msgs.RecordingStarted.Body != "" {
sb.WriteString(" [notifications.messages.recording_started]\n")
sb.WriteString(fmt.Sprintf(" title = %q\n", msgs.RecordingStarted.Title))
sb.WriteString(fmt.Sprintf(" body = %q\n", msgs.RecordingStarted.Body))
}
if msgs.Transcribing.Title != "" || msgs.Transcribing.Body != "" {
sb.WriteString(" [notifications.messages.transcribing]\n")
sb.WriteString(fmt.Sprintf(" title = %q\n", msgs.Transcribing.Title))
sb.WriteString(fmt.Sprintf(" body = %q\n", msgs.Transcribing.Body))
}
if msgs.LLMProcessing.Title != "" || msgs.LLMProcessing.Body != "" {
sb.WriteString(" [notifications.messages.llm_processing]\n")
sb.WriteString(fmt.Sprintf(" title = %q\n", msgs.LLMProcessing.Title))
sb.WriteString(fmt.Sprintf(" body = %q\n", msgs.LLMProcessing.Body))
}
if msgs.ConfigReloaded.Title != "" || msgs.ConfigReloaded.Body != "" {
sb.WriteString(" [notifications.messages.config_reloaded]\n")
sb.WriteString(fmt.Sprintf(" title = %q\n", msgs.ConfigReloaded.Title))
sb.WriteString(fmt.Sprintf(" body = %q\n", msgs.ConfigReloaded.Body))
}
if msgs.OperationCancelled.Title != "" || msgs.OperationCancelled.Body != "" {
sb.WriteString(" [notifications.messages.operation_cancelled]\n")
sb.WriteString(fmt.Sprintf(" title = %q\n", msgs.OperationCancelled.Title))
sb.WriteString(fmt.Sprintf(" body = %q\n", msgs.OperationCancelled.Body))
}
if msgs.RecordingAborted.Body != "" {
sb.WriteString(" [notifications.messages.recording_aborted]\n")
sb.WriteString(fmt.Sprintf(" body = %q\n", msgs.RecordingAborted.Body))
}
if msgs.InjectionAborted.Body != "" {
sb.WriteString(" [notifications.messages.injection_aborted]\n")
sb.WriteString(fmt.Sprintf(" body = %q\n", msgs.InjectionAborted.Body))
}
}
if _, err := file.WriteString(sb.String()); err != nil {
return fmt.Errorf("failed to write config content: %w", err)
}
return nil
}
func hasCustomMessages(msgs MessagesConfig) bool {
return msgs.RecordingStarted.Title != "" || msgs.RecordingStarted.Body != "" ||
msgs.Transcribing.Title != "" || msgs.Transcribing.Body != "" ||
msgs.LLMProcessing.Title != "" || msgs.LLMProcessing.Body != "" ||
msgs.ConfigReloaded.Title != "" || msgs.ConfigReloaded.Body != "" ||
msgs.OperationCancelled.Title != "" || msgs.OperationCancelled.Body != "" ||
msgs.RecordingAborted.Body != "" ||
msgs.InjectionAborted.Body != ""
}
// SaveDefaultConfig writes the default config template to the config file
func SaveDefaultConfig() error {
configPath, err := GetConfigPath()
if err != nil {
return err
}
file, err := os.Create(configPath)
if err != nil {
return fmt.Errorf("failed to create config file: %w", err)
}
defer file.Close()
configContent := `# Hyprvoice Configuration
# This file is automatically generated with defaults.
# Edit values as needed - changes are applied immediately without daemon restart.
# Keywords help both transcription and LLM understand domain-specific terms
# Add names, technical terms, or brand names that might be misheard
keywords = []
# ─────────────────────────────────────────────────────────────────────────────
# Provider API Keys
# Configure API keys for each provider you want to use.
# Keys can also be set via environment variables: OPENAI_API_KEY, GROQ_API_KEY, etc.
# ─────────────────────────────────────────────────────────────────────────────
[providers.openai]
api_key = "" # OpenAI API key (or set OPENAI_API_KEY env var)
[providers.groq]
api_key = "" # Groq API key (or set GROQ_API_KEY env var)
# Uncomment to configure additional providers:
# [providers.mistral]
# api_key = "" # Mistral API key (or set MISTRAL_API_KEY env var)
# [providers.elevenlabs]
# api_key = "" # ElevenLabs API key (or set ELEVENLABS_API_KEY env var)
# [providers.deepgram]
# api_key = "" # Deepgram API key (or set DEEPGRAM_API_KEY env var)
# ─────────────────────────────────────────────────────────────────────────────
# Audio Recording
# ─────────────────────────────────────────────────────────────────────────────
[recording]
sample_rate = 16000 # Audio sample rate in Hz (16000 recommended for speech)
channels = 1 # Number of audio channels (1 = mono, 2 = stereo)
format = "s16" # Audio format (s16 = 16-bit signed integers)
buffer_size = 8192 # Internal buffer size in bytes (larger = less CPU, more latency)
device = "" # PipeWire audio device (empty = use default microphone)
channel_buffer_size = 30 # Audio frame buffer size (frames to buffer)
timeout = "5m" # Maximum recording duration (e.g., "30s", "2m", "5m")
# ─────────────────────────────────────────────────────────────────────────────
# Speech Transcription
# Converts audio to text using speech-to-text APIs
# ─────────────────────────────────────────────────────────────────────────────
[transcription]
provider = "openai" # "openai", "groq-transcription", "mistral-transcription", "elevenlabs", "whisper-cpp"
model = "whisper-1" # Model: OpenAI="whisper-1", Groq="whisper-large-v3", Mistral="voxtral-mini-latest", ElevenLabs="scribe_v1"
language = "" # ISO 639-1 code (e.g., en, es, de). Empty for auto-detect.
threads = 0 # CPU threads for local transcription (0 = auto: uses NumCPU-1)
# ─────────────────────────────────────────────────────────────────────────────
# LLM Post-Processing (Recommended)
# Cleans up transcribed text: removes stutters, adds punctuation, fixes grammar
# ─────────────────────────────────────────────────────────────────────────────
[llm]
enabled = true # Enable LLM post-processing (highly recommended)
provider = "openai" # "openai" or "groq" (must have API key configured above)
model = "gpt-4o-mini" # OpenAI: "gpt-4o-mini", Groq: "llama-3.3-70b-versatile"
[llm.post_processing]
remove_stutters = true # Remove "um", "uh", repeated words
add_punctuation = true # Add proper punctuation
fix_grammar = true # Fix grammatical errors
remove_filler_words = true # Remove "like", "you know", "basically"
[llm.custom_prompt]
enabled = false # Enable custom instructions for LLM
prompt = "" # Additional instructions (e.g., "Format as bullet points")
# ─────────────────────────────────────────────────────────────────────────────
# Text Injection
# How transcribed text is inserted into applications
# ─────────────────────────────────────────────────────────────────────────────
[injection]
backends = ["ydotool", "wtype", "clipboard"] # Ordered fallback chain (tries each until one succeeds)
ydotool_timeout = "5s" # Timeout for ydotool commands
wtype_timeout = "5s" # Timeout for wtype commands
clipboard_timeout = "3s" # Timeout for clipboard operations
# ─────────────────────────────────────────────────────────────────────────────
# Desktop Notifications
# ─────────────────────────────────────────────────────────────────────────────
[notifications]
enabled = true # Enable desktop notifications
type = "desktop" # "desktop", "log", or "none"
# Custom notification messages (optional - defaults shown below)
# Uncomment and modify to customize notification text
# [notifications.messages]
# [notifications.messages.recording_started]
# title = "Hyprvoice"
# body = "Recording Started"
# [notifications.messages.transcribing]
# title = "Hyprvoice"
# body = "Recording Ended... Transcribing"
# [notifications.messages.llm_processing]
# title = "Hyprvoice"
# body = "Processing..."
# [notifications.messages.config_reloaded]
# title = "Hyprvoice"
# body = "Config Reloaded"
# [notifications.messages.operation_cancelled]
# title = "Hyprvoice"
# body = "Operation Cancelled"
# [notifications.messages.recording_aborted]
# body = "Recording Aborted"
# [notifications.messages.injection_aborted]
# body = "Injection Aborted"
#
# Emoji-only example (for minimal pill-style notifications):
# [notifications.messages.recording_started]
# title = ""
# body = "..."
# ─────────────────────────────────────────────────────────────────────────────
# Reference: Provider Details
# ─────────────────────────────────────────────────────────────────────────────
#
# Transcription providers:
# - "openai": OpenAI Whisper API (cloud-based, excellent accuracy)
# - "groq-transcription": Groq Whisper API (very fast, models: whisper-large-v3, whisper-large-v3-turbo)
# - "mistral-transcription": Mistral Voxtral API (excellent for European languages, model: voxtral-mini-latest)
# - "elevenlabs": ElevenLabs Scribe API (99 languages, models: scribe_v1, scribe_v2, scribe_v2_realtime)
#
# LLM providers (for post-processing):
# - "openai": GPT models (gpt-4o-mini recommended for cost/quality balance)
# - "groq": Fast inference (llama-3.3-70b-versatile recommended)
#
# Injection backends:
# - "ydotool": Uses ydotool (requires ydotoold daemon). Best for Chromium/Electron apps.
# - "wtype": Uses wtype for Wayland. May have issues with some Chromium apps.
# - "clipboard": Copies to clipboard only (most reliable, requires manual paste).
#
# Language codes: "" (auto-detect), "en", "it", "es", "fr", "de", "pt", etc.
# Language is configured per transcription model - only supported languages are shown during setup.
`
if _, err := file.WriteString(configContent); err != nil {
return fmt.Errorf("failed to write config content: %w", err)
}
return nil
}
+142
View File
@@ -0,0 +1,142 @@
package config
import (
"reflect"
"time"
"github.com/leonardotrapani/hyprvoice/internal/notify"
)
// GeneralConfig holds global settings that apply across the application
type GeneralConfig struct {
// reserved for future use
}
type Config struct {
General GeneralConfig `toml:"general"`
Recording RecordingConfig `toml:"recording"`
Transcription TranscriptionConfig `toml:"transcription"`
Injection InjectionConfig `toml:"injection"`
Notifications NotificationsConfig `toml:"notifications"`
Providers map[string]ProviderConfig `toml:"providers"`
Keywords []string `toml:"keywords"`
LLM LLMConfig `toml:"llm"`
}
// ProviderConfig holds API key for a provider
type ProviderConfig struct {
APIKey string `toml:"api_key"`
}
// LLMConfig configures the LLM post-processing phase
type LLMConfig struct {
Enabled bool `toml:"enabled"`
Provider string `toml:"provider"`
Model string `toml:"model"`
PostProcessing LLMPostProcessingConfig `toml:"post_processing"`
CustomPrompt LLMCustomPromptConfig `toml:"custom_prompt"`
}
// LLMPostProcessingConfig controls text cleanup options
type LLMPostProcessingConfig struct {
RemoveStutters bool `toml:"remove_stutters"`
AddPunctuation bool `toml:"add_punctuation"`
FixGrammar bool `toml:"fix_grammar"`
RemoveFillerWords bool `toml:"remove_filler_words"`
}
// LLMCustomPromptConfig allows custom prompts
type LLMCustomPromptConfig struct {
Enabled bool `toml:"enabled"`
Prompt string `toml:"prompt"`
}
type RecordingConfig struct {
SampleRate int `toml:"sample_rate"`
Channels int `toml:"channels"`
Format string `toml:"format"`
BufferSize int `toml:"buffer_size"`
Device string `toml:"device"`
ChannelBufferSize int `toml:"channel_buffer_size"`
Timeout time.Duration `toml:"timeout"`
}
type TranscriptionConfig struct {
Provider string `toml:"provider"`
Language string `toml:"language"`
Model string `toml:"model"`
Streaming bool `toml:"streaming"` // use streaming mode if model supports it
Threads int `toml:"threads"` // CPU threads for local transcription (0 = auto: NumCPU-1)
}
type InjectionConfig struct {
Backends []string `toml:"backends"`
YdotoolTimeout time.Duration `toml:"ydotool_timeout"`
WtypeTimeout time.Duration `toml:"wtype_timeout"`
ClipboardTimeout time.Duration `toml:"clipboard_timeout"`
}
type NotificationsConfig struct {
Enabled bool `toml:"enabled"`
Type string `toml:"type"` // "desktop", "log", "none"
Messages MessagesConfig `toml:"messages"`
}
type MessageConfig struct {
Title string `toml:"title"`
Body string `toml:"body"`
}
type MessagesConfig struct {
RecordingStarted MessageConfig `toml:"recording_started"`
Transcribing MessageConfig `toml:"transcribing"`
LLMProcessing MessageConfig `toml:"llm_processing"`
ConfigReloaded MessageConfig `toml:"config_reloaded"`
OperationCancelled MessageConfig `toml:"operation_cancelled"`
RecordingAborted MessageConfig `toml:"recording_aborted"`
InjectionAborted MessageConfig `toml:"injection_aborted"`
}
// Resolve merges user config with defaults from MessageDefs
func (m *MessagesConfig) Resolve() map[notify.MessageType]notify.Message {
result := make(map[notify.MessageType]notify.Message)
v := reflect.ValueOf(m).Elem()
t := v.Type()
tagToField := make(map[string]int)
for i := 0; i < t.NumField(); i++ {
tagToField[t.Field(i).Tag.Get("toml")] = i
}
for _, def := range notify.MessageDefs {
msg := notify.Message{
Title: def.DefaultTitle,
Body: def.DefaultBody,
IsError: def.IsError,
}
if idx, ok := tagToField[def.ConfigKey]; ok {
userMsg := v.Field(idx).Interface().(MessageConfig)
if userMsg.Title != "" {
msg.Title = userMsg.Title
}
if userMsg.Body != "" {
msg.Body = userMsg.Body
}
}
result[def.Type] = msg
}
return result
}
// LLMAdapterConfig is the configuration passed to the LLM adapter
type LLMAdapterConfig struct {
Provider string
APIKey string
Model string
RemoveStutters bool
AddPunctuation bool
FixGrammar bool
RemoveFillerWords bool
CustomPrompt string
Keywords []string
}
+223
View File
@@ -0,0 +1,223 @@
package config
import (
"fmt"
"strings"
"github.com/leonardotrapani/hyprvoice/internal/provider"
)
// mapConfigProviderToRegistryName maps config provider names to provider registry names
// Config uses names like "groq-transcription", "mistral-transcription"
// Registry uses base names like "groq", "mistral"
func mapConfigProviderToRegistryName(configProvider string) string {
switch configProvider {
case "groq-transcription":
return "groq"
case "mistral-transcription":
return "mistral"
default:
return configProvider
}
}
// envVarForProvider returns the environment variable name for a provider's API key
func envVarForProvider(registryName string) string {
switch registryName {
case "openai":
return "OPENAI_API_KEY"
case "groq":
return "GROQ_API_KEY"
case "mistral":
return "MISTRAL_API_KEY"
case "elevenlabs":
return "ELEVENLABS_API_KEY"
case "deepgram":
return "DEEPGRAM_API_KEY"
default:
return ""
}
}
func (c *Config) Validate() error {
if c.Recording.SampleRate <= 0 {
return fmt.Errorf("invalid recording.sample_rate: %d", c.Recording.SampleRate)
}
if c.Recording.Channels <= 0 {
return fmt.Errorf("invalid recording.channels: %d", c.Recording.Channels)
}
if c.Recording.BufferSize <= 0 {
return fmt.Errorf("invalid recording.buffer_size: %d", c.Recording.BufferSize)
}
if c.Recording.ChannelBufferSize <= 0 {
return fmt.Errorf("invalid recording.channel_buffer_size: %d", c.Recording.ChannelBufferSize)
}
if c.Recording.Format == "" {
return fmt.Errorf("invalid recording.format: empty")
}
if c.Recording.Timeout <= 0 {
return fmt.Errorf("invalid recording.timeout: %v", c.Recording.Timeout)
}
if c.Transcription.Provider == "" {
return fmt.Errorf("invalid transcription.provider: empty")
}
// map config provider name to registry provider name
registryName := mapConfigProviderToRegistryName(c.Transcription.Provider)
// validate provider exists in registry
p := provider.GetProvider(registryName)
if p == nil {
providers := provider.ListProvidersWithTranscription()
return fmt.Errorf("unknown transcription.provider: %s (available: %s)", c.Transcription.Provider, strings.Join(providers, ", "))
}
// validate API key requirement using registry
if p.RequiresAPIKey() {
apiKey := c.resolveAPIKeyForProvider(c.Transcription.Provider)
if apiKey == "" {
envVar := envVarForProvider(registryName)
return fmt.Errorf("%s API key required: not found in config (providers.%s.api_key) or environment variable (%s)",
strings.Title(registryName), registryName, envVar)
}
}
// validate model exists
if c.Transcription.Model == "" {
return fmt.Errorf("invalid transcription.model: empty")
}
// validate model exists in provider
_, err := provider.GetModel(registryName, c.Transcription.Model)
if err != nil {
models := provider.ModelsOfType(p, provider.Transcription)
modelIDs := make([]string, len(models))
for i, m := range models {
modelIDs[i] = m.ID
}
return fmt.Errorf("invalid model for %s: %s (available: %s)", c.Transcription.Provider, c.Transcription.Model, strings.Join(modelIDs, ", "))
}
// validate language-model compatibility using effective language (transcription overrides general)
effectiveLanguage := c.resolveEffectiveLanguage()
if err := ValidateModelLanguageCompatibility(registryName, c.Transcription.Model, effectiveLanguage); err != nil {
return err
}
// LLM validation
if c.LLM.Enabled {
if c.LLM.Provider == "" {
return fmt.Errorf("llm.provider required when llm.enabled = true")
}
if c.LLM.Model == "" {
return fmt.Errorf("llm.model required when llm.enabled = true")
}
// validate LLM provider exists
llmProvider := provider.GetProvider(c.LLM.Provider)
if llmProvider == nil {
providers := provider.ListProvidersWithLLM()
return fmt.Errorf("invalid llm.provider: %s (available: %s)", c.LLM.Provider, strings.Join(providers, ", "))
}
// validate LLM model exists
llmModel, err := provider.GetModel(c.LLM.Provider, c.LLM.Model)
if err != nil {
models := provider.ModelsOfType(llmProvider, provider.LLM)
modelIDs := make([]string, len(models))
for i, m := range models {
modelIDs[i] = m.ID
}
return fmt.Errorf("invalid llm.model: %s (available for %s: %s)", c.LLM.Model, c.LLM.Provider, strings.Join(modelIDs, ", "))
}
// verify model is actually an LLM
if llmModel.Type != provider.LLM {
return fmt.Errorf("invalid llm.model: %s is not an LLM model", c.LLM.Model)
}
// validate LLM API key
if llmProvider.RequiresAPIKey() {
llmAPIKey := c.resolveAPIKeyForLLMProvider(c.LLM.Provider)
if llmAPIKey == "" {
envVar := envVarForProvider(c.LLM.Provider)
return fmt.Errorf("%s API key required for LLM: not found in config (providers.%s.api_key) or environment variable (%s)",
strings.Title(c.LLM.Provider), c.LLM.Provider, envVar)
}
}
}
if len(c.Injection.Backends) == 0 {
return fmt.Errorf("invalid injection.backends: empty (must have at least one backend)")
}
validBackends := map[string]bool{"ydotool": true, "wtype": true, "clipboard": true}
for _, backend := range c.Injection.Backends {
if !validBackends[backend] {
return fmt.Errorf("invalid injection.backends: unknown backend %q (must be ydotool, wtype, or clipboard)", backend)
}
}
if c.Injection.YdotoolTimeout <= 0 {
return fmt.Errorf("invalid injection.ydotool_timeout: %v", c.Injection.YdotoolTimeout)
}
if c.Injection.WtypeTimeout <= 0 {
return fmt.Errorf("invalid injection.wtype_timeout: %v", c.Injection.WtypeTimeout)
}
if c.Injection.ClipboardTimeout <= 0 {
return fmt.Errorf("invalid injection.clipboard_timeout: %v", c.Injection.ClipboardTimeout)
}
validTypes := map[string]bool{"desktop": true, "log": true, "none": true}
if !validTypes[c.Notifications.Type] {
return fmt.Errorf("invalid notifications.type: %s (must be desktop, log, or none)", c.Notifications.Type)
}
return nil
}
// ValidateModelLanguageCompatibility validates that a model supports the given language.
// Returns error if the language is not supported, nil if supported or if langCode is empty (auto).
func ValidateModelLanguageCompatibility(registryProvider, modelID, langCode string) error {
// empty language code means auto-detect, always supported
if langCode == "" {
return nil
}
model, err := provider.GetModel(registryProvider, modelID)
if err != nil {
return err // model not found errors handled elsewhere
}
if model.SupportsLanguage(langCode) {
return nil
}
// language not supported - build helpful error message
// truncate supported languages for error message
supported := model.SupportedLanguages
suffix := ""
if len(supported) > 5 {
supported = supported[:5]
suffix = "..."
}
// build error with docs URL if available
docsHint := ""
if model.DocsURL != "" {
docsHint = fmt.Sprintf(" See %s for full list.", model.DocsURL)
}
langLabel := provider.LanguageLabel(langCode)
if langLabel == "" {
langLabel = fmt.Sprintf("language '%s'", langCode)
}
return fmt.Errorf(
"model %s does not support %s.%s Supported: %s%s",
model.Name,
langLabel,
docsHint,
strings.Join(supported, ", "),
suffix,
)
}
+24 -1
View File
@@ -39,8 +39,14 @@ func New() (*Daemon, error) {
conf := configMgr.GetConfig() conf := configMgr.GetConfig()
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
// force desktop notifications when legacy config so user sees the onboarding prompt
notifType := conf.Notifications.Type
if configMgr.IsLegacy() {
notifType = "desktop"
}
d := &Daemon{ d := &Daemon{
notifier: notify.NewNotifier(conf.Notifications.Type, conf.Notifications.Messages.Resolve()), notifier: notify.NewNotifier(notifType, conf.Notifications.Messages.Resolve()),
configMgr: configMgr, configMgr: configMgr,
ctx: ctx, ctx: ctx,
cancel: cancel, cancel: cancel,
@@ -178,6 +184,10 @@ func (d *Daemon) handle(c net.Conn) {
} }
func (d *Daemon) toggle() { func (d *Daemon) toggle() {
if d.configMgr.IsLegacy() {
d.notifier.Error("Legacy config detected. Run: hyprvoice onboarding")
return
}
conf := d.configMgr.GetConfig() conf := d.configMgr.GetConfig()
switch d.status() { switch d.status() {
case pipeline.Idle: case pipeline.Idle:
@@ -190,6 +200,7 @@ func (d *Daemon) toggle() {
go d.notifier.Send(notify.MsgRecordingStarted) go d.notifier.Send(notify.MsgRecordingStarted)
go d.monitorPipelineErrors(p) go d.monitorPipelineErrors(p)
go d.monitorPipelineNotifications(p)
case pipeline.Recording: case pipeline.Recording:
d.stopPipeline() d.stopPipeline()
@@ -240,3 +251,15 @@ func (d *Daemon) monitorPipelineErrors(p pipeline.Pipeline) {
} }
} }
} }
func (d *Daemon) monitorPipelineNotifications(p pipeline.Pipeline) {
notifyCh := p.GetNotifyCh()
for {
select {
case mt := <-notifyCh:
d.notifier.Send(mt)
case <-d.ctx.Done():
return
}
}
}
+36 -147
View File
@@ -9,9 +9,35 @@ import (
"testing" "testing"
"time" "time"
"github.com/leonardotrapani/hyprvoice/internal/notify"
"github.com/leonardotrapani/hyprvoice/internal/pipeline" "github.com/leonardotrapani/hyprvoice/internal/pipeline"
) )
const testConfigContent = `[recording]
sample_rate = 16000
channels = 1
format = "s16"
buffer_size = 8192
channel_buffer_size = 30
timeout = "5m"
[providers.openai]
api_key = "test-key"
[transcription]
provider = "openai"
model = "whisper-1"
[injection]
backends = ["ydotool", "wtype", "clipboard"]
ydotool_timeout = "5s"
wtype_timeout = "5s"
clipboard_timeout = "3s"
[notifications]
enabled = true
type = "log"`
func TestNew(t *testing.T) { func TestNew(t *testing.T) {
// Set up a temporary config directory // Set up a temporary config directory
tempDir := t.TempDir() tempDir := t.TempDir()
@@ -28,27 +54,7 @@ func TestNew(t *testing.T) {
// Create a basic config file // Create a basic config file
configPath := filepath.Join(tempDir, "hyprvoice", "config.toml") configPath := filepath.Join(tempDir, "hyprvoice", "config.toml")
os.MkdirAll(filepath.Dir(configPath), 0755) os.MkdirAll(filepath.Dir(configPath), 0755)
configContent := `[recording] configContent := testConfigContent
sample_rate = 16000
channels = 1
format = "s16"
buffer_size = 8192
channel_buffer_size = 30
timeout = "5m"
[transcription]
provider = "openai"
api_key = "test-key"
model = "whisper-1"
[injection]
mode = "fallback"
wtype_timeout = "5s"
clipboard_timeout = "3s"
[notifications]
enabled = true
type = "log"`
os.WriteFile(configPath, []byte(configContent), 0644) os.WriteFile(configPath, []byte(configContent), 0644)
daemon, err := New() daemon, err := New()
@@ -88,27 +94,7 @@ func TestDaemon_Status(t *testing.T) {
// Create a basic config file // Create a basic config file
configPath := filepath.Join(tempDir, "hyprvoice", "config.toml") configPath := filepath.Join(tempDir, "hyprvoice", "config.toml")
os.MkdirAll(filepath.Dir(configPath), 0755) os.MkdirAll(filepath.Dir(configPath), 0755)
configContent := `[recording] configContent := testConfigContent
sample_rate = 16000
channels = 1
format = "s16"
buffer_size = 8192
channel_buffer_size = 30
timeout = "5m"
[transcription]
provider = "openai"
api_key = "test-key"
model = "whisper-1"
[injection]
mode = "fallback"
wtype_timeout = "5s"
clipboard_timeout = "3s"
[notifications]
enabled = true
type = "log"`
os.WriteFile(configPath, []byte(configContent), 0644) os.WriteFile(configPath, []byte(configContent), 0644)
daemon, err := New() daemon, err := New()
@@ -139,27 +125,7 @@ func TestDaemon_Toggle(t *testing.T) {
// Create a basic config file // Create a basic config file
configPath := filepath.Join(tempDir, "hyprvoice", "config.toml") configPath := filepath.Join(tempDir, "hyprvoice", "config.toml")
os.MkdirAll(filepath.Dir(configPath), 0755) os.MkdirAll(filepath.Dir(configPath), 0755)
configContent := `[recording] configContent := testConfigContent
sample_rate = 16000
channels = 1
format = "s16"
buffer_size = 8192
channel_buffer_size = 30
timeout = "5m"
[transcription]
provider = "openai"
api_key = "test-key"
model = "whisper-1"
[injection]
mode = "fallback"
wtype_timeout = "5s"
clipboard_timeout = "3s"
[notifications]
enabled = true
type = "log"`
os.WriteFile(configPath, []byte(configContent), 0644) os.WriteFile(configPath, []byte(configContent), 0644)
daemon, err := New() daemon, err := New()
@@ -194,27 +160,7 @@ func TestDaemon_Handle(t *testing.T) {
// Create a basic config file // Create a basic config file
configPath := filepath.Join(tempDir, "hyprvoice", "config.toml") configPath := filepath.Join(tempDir, "hyprvoice", "config.toml")
os.MkdirAll(filepath.Dir(configPath), 0755) os.MkdirAll(filepath.Dir(configPath), 0755)
configContent := `[recording] configContent := testConfigContent
sample_rate = 16000
channels = 1
format = "s16"
buffer_size = 8192
channel_buffer_size = 30
timeout = "5m"
[transcription]
provider = "openai"
api_key = "test-key"
model = "whisper-1"
[injection]
mode = "fallback"
wtype_timeout = "5s"
clipboard_timeout = "3s"
[notifications]
enabled = true
type = "log"`
os.WriteFile(configPath, []byte(configContent), 0644) os.WriteFile(configPath, []byte(configContent), 0644)
daemon, err := New() daemon, err := New()
@@ -287,27 +233,7 @@ func TestDaemon_OnConfigReload(t *testing.T) {
// Create a basic config file // Create a basic config file
configPath := filepath.Join(tempDir, "hyprvoice", "config.toml") configPath := filepath.Join(tempDir, "hyprvoice", "config.toml")
os.MkdirAll(filepath.Dir(configPath), 0755) os.MkdirAll(filepath.Dir(configPath), 0755)
configContent := `[recording] configContent := testConfigContent
sample_rate = 16000
channels = 1
format = "s16"
buffer_size = 8192
channel_buffer_size = 30
timeout = "5m"
[transcription]
provider = "openai"
api_key = "test-key"
model = "whisper-1"
[injection]
mode = "fallback"
wtype_timeout = "5s"
clipboard_timeout = "3s"
[notifications]
enabled = true
type = "log"`
os.WriteFile(configPath, []byte(configContent), 0644) os.WriteFile(configPath, []byte(configContent), 0644)
daemon, err := New() daemon, err := New()
@@ -338,27 +264,7 @@ func TestDaemon_StopPipeline(t *testing.T) {
// Create a basic config file // Create a basic config file
configPath := filepath.Join(tempDir, "hyprvoice", "config.toml") configPath := filepath.Join(tempDir, "hyprvoice", "config.toml")
os.MkdirAll(filepath.Dir(configPath), 0755) os.MkdirAll(filepath.Dir(configPath), 0755)
configContent := `[recording] configContent := testConfigContent
sample_rate = 16000
channels = 1
format = "s16"
buffer_size = 8192
channel_buffer_size = 30
timeout = "5m"
[transcription]
provider = "openai"
api_key = "test-key"
model = "whisper-1"
[injection]
mode = "fallback"
wtype_timeout = "5s"
clipboard_timeout = "3s"
[notifications]
enabled = true
type = "log"`
os.WriteFile(configPath, []byte(configContent), 0644) os.WriteFile(configPath, []byte(configContent), 0644)
daemon, err := New() daemon, err := New()
@@ -401,27 +307,7 @@ func TestDaemon_Handle_Commands(t *testing.T) {
// Create a basic config file // Create a basic config file
configPath := filepath.Join(tempDir, "hyprvoice", "config.toml") configPath := filepath.Join(tempDir, "hyprvoice", "config.toml")
os.MkdirAll(filepath.Dir(configPath), 0755) os.MkdirAll(filepath.Dir(configPath), 0755)
configContent := `[recording] configContent := testConfigContent
sample_rate = 16000
channels = 1
format = "s16"
buffer_size = 8192
channel_buffer_size = 30
timeout = "5m"
[transcription]
provider = "openai"
api_key = "test-key"
model = "whisper-1"
[injection]
mode = "fallback"
wtype_timeout = "5s"
clipboard_timeout = "3s"
[notifications]
enabled = true
type = "log"`
os.WriteFile(configPath, []byte(configContent), 0644) os.WriteFile(configPath, []byte(configContent), 0644)
daemon, err := New() daemon, err := New()
@@ -480,3 +366,6 @@ func (m *MockPipeline) GetErrorCh() <-chan pipeline.PipelineError {
return make(chan pipeline.PipelineError) return make(chan pipeline.PipelineError)
} }
func (m *MockPipeline) GetActionCh() chan<- pipeline.Action { return make(chan pipeline.Action) } func (m *MockPipeline) GetActionCh() chan<- pipeline.Action { return make(chan pipeline.Action) }
func (m *MockPipeline) GetNotifyCh() <-chan notify.MessageType {
return make(chan notify.MessageType)
}
+64
View File
@@ -0,0 +1,64 @@
package deps
import (
"os/exec"
"strings"
)
// Status represents the installation status of a dependency
type Status struct {
Installed bool
Path string
Version string
}
// CheckWhisperCli checks if whisper-cli is installed and returns its status
func CheckWhisperCli() Status {
path, err := exec.LookPath("whisper-cli")
if err != nil {
return Status{Installed: false}
}
status := Status{
Installed: true,
Path: path,
}
// try to get version - whisper-cli --version outputs version info
cmd := exec.Command(path, "--version")
output, err := cmd.Output()
if err == nil {
// parse first line as version
lines := strings.Split(string(output), "\n")
if len(lines) > 0 {
status.Version = strings.TrimSpace(lines[0])
}
}
return status
}
// CheckFFmpeg checks if ffmpeg is installed and returns its status
func CheckFFmpeg() Status {
path, err := exec.LookPath("ffmpeg")
if err != nil {
return Status{Installed: false}
}
status := Status{
Installed: true,
Path: path,
}
// ffmpeg -version outputs version info on first line
cmd := exec.Command(path, "-version")
output, err := cmd.Output()
if err == nil {
lines := strings.Split(string(output), "\n")
if len(lines) > 0 {
status.Version = strings.TrimSpace(lines[0])
}
}
return status
}
+71
View File
@@ -0,0 +1,71 @@
package deps
import (
"os/exec"
"testing"
)
func TestCheckWhisperCli(t *testing.T) {
status := CheckWhisperCli()
// behavior depends on system - just verify no panic and correct structure
if status.Installed {
if status.Path == "" {
t.Error("installed but path empty")
}
} else {
if status.Path != "" {
t.Error("not installed but path non-empty")
}
}
}
func TestCheckWhisperCli_NotInstalled(t *testing.T) {
// if whisper-cli is not in PATH, should return Installed=false
_, err := exec.LookPath("whisper-cli")
if err != nil {
status := CheckWhisperCli()
if status.Installed {
t.Error("expected Installed=false when whisper-cli not in PATH")
}
if status.Path != "" {
t.Error("expected empty path when not installed")
}
} else {
t.Skip("whisper-cli is installed, can't test not-installed case")
}
}
func TestCheckFFmpeg(t *testing.T) {
status := CheckFFmpeg()
if status.Installed {
if status.Path == "" {
t.Error("installed but path empty")
}
} else {
if status.Path != "" {
t.Error("not installed but path non-empty")
}
}
}
func TestCheckFFmpeg_Installed(t *testing.T) {
// ffmpeg is commonly installed - test if available
_, err := exec.LookPath("ffmpeg")
if err == nil {
status := CheckFFmpeg()
if !status.Installed {
t.Error("ffmpeg in PATH but Installed=false")
}
if status.Path == "" {
t.Error("ffmpeg installed but path empty")
}
// version should be populated
if status.Version == "" {
t.Error("ffmpeg installed but version empty")
}
} else {
t.Skip("ffmpeg not installed, can't test installed case")
}
}
+73
View File
@@ -0,0 +1,73 @@
package llm
import (
"context"
"fmt"
"log"
"time"
"github.com/sashabaranov/go-openai"
)
// GroqAdapter implements Adapter using Groq's OpenAI-compatible API
type GroqAdapter struct {
client *openai.Client
config Config
}
// NewGroqAdapter creates a new Groq LLM adapter
func NewGroqAdapter(cfg Config) *GroqAdapter {
clientConfig := openai.DefaultConfig(cfg.APIKey)
clientConfig.BaseURL = "https://api.groq.com/openai/v1"
return &GroqAdapter{
client: openai.NewClientWithConfig(clientConfig),
config: cfg,
}
}
func (a *GroqAdapter) Process(ctx context.Context, text string) (string, error) {
if text == "" {
return "", nil
}
opts := PostProcessingOptions{
RemoveStutters: a.config.RemoveStutters,
AddPunctuation: a.config.AddPunctuation,
FixGrammar: a.config.FixGrammar,
RemoveFillerWords: a.config.RemoveFillerWords,
}
systemPrompt := BuildSystemPrompt(opts, a.config.Keywords)
userPrompt := BuildUserPrompt(text, a.config.CustomPrompt)
model := a.config.Model
if model == "" {
model = "llama-3.3-70b-versatile"
}
req := openai.ChatCompletionRequest{
Model: model,
Messages: []openai.ChatCompletionMessage{
{Role: openai.ChatMessageRoleSystem, Content: systemPrompt},
{Role: openai.ChatMessageRoleUser, Content: userPrompt},
},
Temperature: 0.3, // Low temperature for consistent cleanup
}
start := time.Now()
resp, err := a.client.CreateChatCompletion(ctx, req)
duration := time.Since(start)
if err != nil {
log.Printf("groq-llm-adapter: API call failed after %v: %v", duration, err)
return "", fmt.Errorf("groq chat completion: %w", err)
}
if len(resp.Choices) == 0 {
return "", fmt.Errorf("groq chat completion: no response choices")
}
result := resp.Choices[0].Message.Content
log.Printf("groq-llm-adapter: processed in %v: %q -> %q", duration, text, result)
return result, nil
}
+71
View File
@@ -0,0 +1,71 @@
package llm
import (
"context"
"fmt"
"log"
"time"
"github.com/sashabaranov/go-openai"
)
// OpenAIAdapter implements Adapter using OpenAI's chat completions API
type OpenAIAdapter struct {
client *openai.Client
config Config
}
// NewOpenAIAdapter creates a new OpenAI LLM adapter
func NewOpenAIAdapter(cfg Config) *OpenAIAdapter {
return &OpenAIAdapter{
client: openai.NewClient(cfg.APIKey),
config: cfg,
}
}
func (a *OpenAIAdapter) Process(ctx context.Context, text string) (string, error) {
if text == "" {
return "", nil
}
opts := PostProcessingOptions{
RemoveStutters: a.config.RemoveStutters,
AddPunctuation: a.config.AddPunctuation,
FixGrammar: a.config.FixGrammar,
RemoveFillerWords: a.config.RemoveFillerWords,
}
systemPrompt := BuildSystemPrompt(opts, a.config.Keywords)
userPrompt := BuildUserPrompt(text, a.config.CustomPrompt)
model := a.config.Model
if model == "" {
model = "gpt-4o-mini"
}
req := openai.ChatCompletionRequest{
Model: model,
Messages: []openai.ChatCompletionMessage{
{Role: openai.ChatMessageRoleSystem, Content: systemPrompt},
{Role: openai.ChatMessageRoleUser, Content: userPrompt},
},
Temperature: 0.3, // Low temperature for consistent cleanup
}
start := time.Now()
resp, err := a.client.CreateChatCompletion(ctx, req)
duration := time.Since(start)
if err != nil {
log.Printf("openai-llm-adapter: API call failed after %v: %v", duration, err)
return "", fmt.Errorf("openai chat completion: %w", err)
}
if len(resp.Choices) == 0 {
return "", fmt.Errorf("openai chat completion: no response choices")
}
result := resp.Choices[0].Message.Content
log.Printf("openai-llm-adapter: processed in %v: %q -> %q", duration, text, result)
return result, nil
}
+42
View File
@@ -0,0 +1,42 @@
package llm
import (
"context"
"fmt"
)
// Adapter interface for LLM text processing
type Adapter interface {
Process(ctx context.Context, text string) (string, error)
}
// Config holds LLM adapter configuration
type Config struct {
Provider string
APIKey string
Model string
RemoveStutters bool
AddPunctuation bool
FixGrammar bool
RemoveFillerWords bool
CustomPrompt string
Keywords []string
}
// NewAdapter creates an LLM adapter based on the provider
func NewAdapter(cfg Config) (Adapter, error) {
switch cfg.Provider {
case "openai":
if cfg.APIKey == "" {
return nil, fmt.Errorf("OpenAI API key required")
}
return NewOpenAIAdapter(cfg), nil
case "groq":
if cfg.APIKey == "" {
return nil, fmt.Errorf("Groq API key required")
}
return NewGroqAdapter(cfg), nil
default:
return nil, fmt.Errorf("unsupported LLM provider: %s", cfg.Provider)
}
}
+155
View File
@@ -0,0 +1,155 @@
package llm
import (
"strings"
"testing"
)
func TestBuildSystemPrompt(t *testing.T) {
tests := []struct {
name string
opts PostProcessingOptions
keywords []string
contains []string
}{
{
name: "all options enabled",
opts: PostProcessingOptions{
RemoveStutters: true,
AddPunctuation: true,
FixGrammar: true,
RemoveFillerWords: true,
},
keywords: nil,
contains: []string{
"Remove stutters",
"Add proper punctuation",
"Fix grammar",
"Remove filler words",
},
},
{
name: "only grammar",
opts: PostProcessingOptions{
FixGrammar: true,
},
keywords: nil,
contains: []string{
"Fix grammar",
},
},
{
name: "with keywords",
opts: PostProcessingOptions{
RemoveStutters: true,
},
keywords: []string{"Kubernetes", "TypeScript", "hyprvoice"},
contains: []string{
"Kubernetes",
"TypeScript",
"hyprvoice",
"Context keywords",
},
},
{
name: "no options - should have default",
opts: PostProcessingOptions{},
keywords: nil,
contains: []string{
"Clean up the text",
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := BuildSystemPrompt(tc.opts, tc.keywords)
for _, expected := range tc.contains {
if !strings.Contains(result, expected) {
t.Errorf("expected prompt to contain %q, got: %s", expected, result)
}
}
})
}
}
func TestBuildUserPrompt(t *testing.T) {
tests := []struct {
name string
text string
customPrompt string
expected string
}{
{
name: "no custom prompt",
text: "hello world",
customPrompt: "",
expected: "hello world",
},
{
name: "with custom prompt",
text: "hello world",
customPrompt: "Format as a haiku",
expected: "Format as a haiku\n\nText to process:\nhello world",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := BuildUserPrompt(tc.text, tc.customPrompt)
if result != tc.expected {
t.Errorf("expected %q, got %q", tc.expected, result)
}
})
}
}
func TestNewAdapter(t *testing.T) {
// Test OpenAI adapter creation
openaiCfg := Config{
Provider: "openai",
APIKey: "sk-test-key",
Model: "gpt-4o-mini",
}
adapter, err := NewAdapter(openaiCfg)
if err != nil {
t.Fatalf("failed to create openai adapter: %v", err)
}
if _, ok := adapter.(*OpenAIAdapter); !ok {
t.Error("expected OpenAIAdapter type")
}
// Test Groq adapter creation
groqCfg := Config{
Provider: "groq",
APIKey: "gsk_test-key",
Model: "llama-3.3-70b-versatile",
}
adapter, err = NewAdapter(groqCfg)
if err != nil {
t.Fatalf("failed to create groq adapter: %v", err)
}
if _, ok := adapter.(*GroqAdapter); !ok {
t.Error("expected GroqAdapter type")
}
// Test missing API key
noKeyCfg := Config{
Provider: "openai",
APIKey: "",
}
_, err = NewAdapter(noKeyCfg)
if err == nil {
t.Error("expected error for missing API key")
}
// Test unsupported provider
badCfg := Config{
Provider: "unsupported",
APIKey: "key",
}
_, err = NewAdapter(badCfg)
if err == nil {
t.Error("expected error for unsupported provider")
}
}
+65
View File
@@ -0,0 +1,65 @@
package llm
import (
"fmt"
"strings"
)
// PostProcessingOptions controls which cleanup operations to request
type PostProcessingOptions struct {
RemoveStutters bool
AddPunctuation bool
FixGrammar bool
RemoveFillerWords bool
}
// BuildSystemPrompt generates the system prompt for text cleanup
func BuildSystemPrompt(opts PostProcessingOptions, keywords []string) string {
var tasks []string
if opts.RemoveStutters {
tasks = append(tasks, "Remove stutters and repeated words/phrases")
}
if opts.AddPunctuation {
tasks = append(tasks, "Add proper punctuation")
}
if opts.FixGrammar {
tasks = append(tasks, "Fix grammar errors")
}
if opts.RemoveFillerWords {
tasks = append(tasks, "Remove filler words (um, uh, like, you know, etc.)")
}
// If no tasks, just clean up generally
if len(tasks) == 0 {
tasks = append(tasks, "Clean up the text while preserving meaning")
}
prompt := "You are a text cleanup assistant. Your job is to clean up speech-to-text transcriptions.\n\n"
prompt += "Tasks:\n"
for _, task := range tasks {
prompt += fmt.Sprintf("- %s\n", task)
}
prompt += "\nRules:\n"
prompt += "- Preserve the original meaning and intent\n"
prompt += "- Keep the same language as the input\n"
prompt += "- Do not add any new information\n"
prompt += "- Do not remove meaningful content\n"
prompt += "- Output ONLY the cleaned text, nothing else\n"
prompt += "- If the input is empty or nonsensical, return it as-is\n"
if len(keywords) > 0 {
prompt += fmt.Sprintf("\nContext keywords (use correct spelling for these terms): %s\n", strings.Join(keywords, ", "))
}
return prompt
}
// BuildUserPrompt generates the user prompt with the text to process
func BuildUserPrompt(text string, customPrompt string) string {
if customPrompt != "" {
return fmt.Sprintf("%s\n\nText to process:\n%s", customPrompt, text)
}
return text
}
+123
View File
@@ -0,0 +1,123 @@
package whisper
import (
"os"
"path/filepath"
)
// ModelInfo holds metadata for a whisper model
type ModelInfo struct {
ID string // model identifier (e.g., "base.en")
Name string // display name (e.g., "Base English")
Filename string // file name (e.g., "ggml-base.en.bin")
Size string // human readable size
SizeBytes int64 // size in bytes for progress tracking
Multilingual bool // true if supports multiple languages
}
// available whisper models from huggingface.co/ggerganov/whisper.cpp
var models = []ModelInfo{
// english-only models (faster, smaller)
{ID: "tiny.en", Name: "Tiny English", Filename: "ggml-tiny.en.bin", Size: "75MB", SizeBytes: 75_000_000, Multilingual: false},
{ID: "base.en", Name: "Base English", Filename: "ggml-base.en.bin", Size: "142MB", SizeBytes: 142_000_000, Multilingual: false},
{ID: "small.en", Name: "Small English", Filename: "ggml-small.en.bin", Size: "466MB", SizeBytes: 466_000_000, Multilingual: false},
{ID: "medium.en", Name: "Medium English", Filename: "ggml-medium.en.bin", Size: "1.5GB", SizeBytes: 1_500_000_000, Multilingual: false},
// multilingual models
{ID: "tiny", Name: "Tiny", Filename: "ggml-tiny.bin", Size: "75MB", SizeBytes: 75_000_000, Multilingual: true},
{ID: "base", Name: "Base", Filename: "ggml-base.bin", Size: "142MB", SizeBytes: 142_000_000, Multilingual: true},
{ID: "small", Name: "Small", Filename: "ggml-small.bin", Size: "466MB", SizeBytes: 466_000_000, Multilingual: true},
{ID: "medium", Name: "Medium", Filename: "ggml-medium.bin", Size: "1.5GB", SizeBytes: 1_500_000_000, Multilingual: true},
{ID: "large-v1", Name: "Large V1", Filename: "ggml-large-v1.bin", Size: "2.9GB", SizeBytes: 2_900_000_000, Multilingual: true},
{ID: "large-v2", Name: "Large V2", Filename: "ggml-large-v2.bin", Size: "2.9GB", SizeBytes: 2_900_000_000, Multilingual: true},
{ID: "large-v3", Name: "Large V3", Filename: "ggml-large-v3.bin", Size: "3GB", SizeBytes: 3_000_000_000, Multilingual: true},
{ID: "large-v3-turbo", Name: "Large V3 Turbo", Filename: "ggml-large-v3-turbo.bin", Size: "1.6GB", SizeBytes: 1_600_000_000, Multilingual: true},
}
// modelByID maps model ID to ModelInfo for quick lookup
var modelByID = func() map[string]ModelInfo {
m := make(map[string]ModelInfo, len(models))
for _, model := range models {
m[model.ID] = model
}
return m
}()
const (
// base URL for downloading models from huggingface
baseDownloadURL = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main"
)
// GetModelsDir returns the directory where whisper models are stored.
// Creates the directory if it doesn't exist.
func GetModelsDir() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
dir := filepath.Join(home, ".local", "share", "hyprvoice", "models", "whisper")
return dir, nil
}
// GetModelPath returns the full path to a model file.
// Returns empty string if model ID is unknown.
func GetModelPath(modelID string) string {
info, ok := modelByID[modelID]
if !ok {
return ""
}
dir, err := GetModelsDir()
if err != nil {
return ""
}
return filepath.Join(dir, info.Filename)
}
// GetDownloadURL returns the full download URL for a model.
// Returns empty string if model ID is unknown.
func GetDownloadURL(modelID string) string {
info, ok := modelByID[modelID]
if !ok {
return ""
}
return baseDownloadURL + "/" + info.Filename
}
// GetModel returns info for a model by ID.
// Returns nil if model ID is unknown.
func GetModel(modelID string) *ModelInfo {
info, ok := modelByID[modelID]
if !ok {
return nil
}
return &info
}
// ListModels returns all available whisper models
func ListModels() []ModelInfo {
result := make([]ModelInfo, len(models))
copy(result, models)
return result
}
// ListMultilingualModels returns models that support multiple languages
func ListMultilingualModels() []ModelInfo {
var result []ModelInfo
for _, m := range models {
if m.Multilingual {
result = append(result, m)
}
}
return result
}
// ListEnglishOnlyModels returns english-only models
func ListEnglishOnlyModels() []ModelInfo {
var result []ModelInfo
for _, m := range models {
if !m.Multilingual {
result = append(result, m)
}
}
return result
}
+164
View File
@@ -0,0 +1,164 @@
package whisper
import (
"context"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
)
// ProgressFunc is called during download with bytes downloaded and total
type ProgressFunc func(downloaded, total int64)
// IsInstalled returns true if the model is downloaded and available
func IsInstalled(modelID string) bool {
path := GetModelPath(modelID)
if path == "" {
return false
}
info, err := os.Stat(path)
return err == nil && info.Size() > 0
}
// ListInstalled returns IDs of all installed models
func ListInstalled() []string {
var installed []string
for _, m := range models {
if IsInstalled(m.ID) {
installed = append(installed, m.ID)
}
}
return installed
}
// Download downloads a model from huggingface.
// Progress callback is optional (can be nil).
// Uses context for cancellation.
func Download(ctx context.Context, modelID string, onProgress ProgressFunc) error {
info := GetModel(modelID)
if info == nil {
return fmt.Errorf("unknown model: %s", modelID)
}
url := GetDownloadURL(modelID)
if url == "" {
return fmt.Errorf("no download URL for model: %s", modelID)
}
// ensure directory exists
dir, err := GetModelsDir()
if err != nil {
return fmt.Errorf("failed to get models directory: %w", err)
}
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create models directory: %w", err)
}
destPath := filepath.Join(dir, info.Filename)
tempPath := destPath + ".downloading"
// create temp file
out, err := os.Create(tempPath)
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
}
defer func() {
out.Close()
os.Remove(tempPath) // clean up temp file on error
}()
// create request with context
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("failed to download: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download failed with status: %s", resp.Status)
}
total := resp.ContentLength
if total < 0 {
total = info.SizeBytes // fall back to expected size
}
var downloaded int64
buf := make([]byte, 32*1024) // 32KB buffer
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
n, err := resp.Body.Read(buf)
if n > 0 {
_, writeErr := out.Write(buf[:n])
if writeErr != nil {
return fmt.Errorf("failed to write: %w", writeErr)
}
downloaded += int64(n)
if onProgress != nil {
onProgress(downloaded, total)
}
}
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("failed to read: %w", err)
}
}
// close file before rename
if err := out.Close(); err != nil {
return fmt.Errorf("failed to close file: %w", err)
}
// rename temp file to final destination
if err := os.Rename(tempPath, destPath); err != nil {
return fmt.Errorf("failed to finalize download: %w", err)
}
return nil
}
// Remove deletes a downloaded model
func Remove(modelID string) error {
info := GetModel(modelID)
if info == nil {
return fmt.Errorf("unknown model: %s", modelID)
}
path := GetModelPath(modelID)
if path == "" {
return fmt.Errorf("failed to get model path")
}
if !IsInstalled(modelID) {
return fmt.Errorf("model not installed: %s", modelID)
}
if err := os.Remove(path); err != nil {
return fmt.Errorf("failed to remove model: %w", err)
}
return nil
}
// GetInstalledPath returns the path to an installed model, or error if not installed
func GetInstalledPath(modelID string) (string, error) {
if !IsInstalled(modelID) {
return "", fmt.Errorf("model not installed: %s", modelID)
}
return GetModelPath(modelID), nil
}
+277
View File
@@ -0,0 +1,277 @@
package whisper
import (
"context"
"path/filepath"
"strings"
"testing"
)
func TestGetModelsDir(t *testing.T) {
dir, err := GetModelsDir()
if err != nil {
t.Fatalf("GetModelsDir() error = %v", err)
}
// should not contain ~ (should be expanded)
if strings.Contains(dir, "~") {
t.Errorf("GetModelsDir() contains ~, got %s", dir)
}
// should end with expected path
if !strings.HasSuffix(dir, filepath.Join(".local", "share", "hyprvoice", "models", "whisper")) {
t.Errorf("GetModelsDir() = %s, want path ending with .local/share/hyprvoice/models/whisper", dir)
}
}
func TestGetModelPath(t *testing.T) {
tests := []struct {
modelID string
wantEnd string
}{
{"base.en", "ggml-base.en.bin"},
{"tiny", "ggml-tiny.bin"},
{"large-v3", "ggml-large-v3.bin"},
{"unknown", ""},
}
for _, tt := range tests {
t.Run(tt.modelID, func(t *testing.T) {
got := GetModelPath(tt.modelID)
if tt.wantEnd == "" {
if got != "" {
t.Errorf("GetModelPath(%q) = %s, want empty", tt.modelID, got)
}
return
}
if !strings.HasSuffix(got, tt.wantEnd) {
t.Errorf("GetModelPath(%q) = %s, want ending with %s", tt.modelID, got, tt.wantEnd)
}
})
}
}
func TestGetDownloadURL(t *testing.T) {
tests := []struct {
modelID string
wantURL string
}{
{"base.en", "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin"},
{"tiny", "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.bin"},
{"unknown", ""},
}
for _, tt := range tests {
t.Run(tt.modelID, func(t *testing.T) {
got := GetDownloadURL(tt.modelID)
if got != tt.wantURL {
t.Errorf("GetDownloadURL(%q) = %s, want %s", tt.modelID, got, tt.wantURL)
}
})
}
}
func TestGetModel(t *testing.T) {
t.Run("known model", func(t *testing.T) {
info := GetModel("base.en")
if info == nil {
t.Fatal("GetModel(base.en) = nil, want non-nil")
}
if info.ID != "base.en" {
t.Errorf("info.ID = %s, want base.en", info.ID)
}
if info.Filename != "ggml-base.en.bin" {
t.Errorf("info.Filename = %s, want ggml-base.en.bin", info.Filename)
}
if info.Multilingual {
t.Error("base.en should not be multilingual")
}
})
t.Run("multilingual model", func(t *testing.T) {
info := GetModel("base")
if info == nil {
t.Fatal("GetModel(base) = nil, want non-nil")
}
if !info.Multilingual {
t.Error("base should be multilingual")
}
})
t.Run("unknown model", func(t *testing.T) {
info := GetModel("unknown")
if info != nil {
t.Errorf("GetModel(unknown) = %v, want nil", info)
}
})
}
func TestListModels(t *testing.T) {
models := ListModels()
if len(models) != 12 {
t.Errorf("ListModels() returned %d models, want 12", len(models))
}
// verify known models exist
ids := make(map[string]bool)
for _, m := range models {
ids[m.ID] = true
}
expected := []string{"tiny.en", "base.en", "small.en", "medium.en", "tiny", "base", "small", "medium", "large-v1", "large-v2", "large-v3", "large-v3-turbo"}
for _, id := range expected {
if !ids[id] {
t.Errorf("ListModels() missing model %s", id)
}
}
}
func TestListMultilingualModels(t *testing.T) {
models := ListMultilingualModels()
if len(models) != 8 {
t.Errorf("ListMultilingualModels() returned %d models, want 8", len(models))
}
for _, m := range models {
if !m.Multilingual {
t.Errorf("ListMultilingualModels() returned non-multilingual model %s", m.ID)
}
}
}
func TestListEnglishOnlyModels(t *testing.T) {
models := ListEnglishOnlyModels()
if len(models) != 4 {
t.Errorf("ListEnglishOnlyModels() returned %d models, want 4", len(models))
}
for _, m := range models {
if m.Multilingual {
t.Errorf("ListEnglishOnlyModels() returned multilingual model %s", m.ID)
}
if !strings.HasSuffix(m.ID, ".en") {
t.Errorf("ListEnglishOnlyModels() returned model without .en suffix: %s", m.ID)
}
}
}
func TestIsInstalled(t *testing.T) {
// should return false for non-existent model
if IsInstalled("base.en") {
// this might actually be true if the user has it installed
// just skip this test if model exists
t.Skip("base.en is installed, skipping test")
}
// should return false for unknown model
if IsInstalled("unknown-model") {
t.Error("IsInstalled(unknown-model) = true, want false")
}
}
func TestListInstalled(t *testing.T) {
// just verify it doesn't crash
installed := ListInstalled()
t.Logf("Installed models: %v", installed)
}
func TestDownload_UnknownModel(t *testing.T) {
err := Download(context.Background(), "unknown-model", nil)
if err == nil {
t.Error("Download(unknown-model) = nil, want error")
}
if !strings.Contains(err.Error(), "unknown model") {
t.Errorf("Download error = %v, want error containing 'unknown model'", err)
}
}
func TestDownload_Cancelled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel immediately
err := Download(ctx, "tiny.en", nil)
if err == nil {
t.Error("Download with cancelled context = nil, want error")
}
}
func TestRemove_NotInstalled(t *testing.T) {
// use a model that's unlikely to be installed
err := Remove("large-v3")
if err == nil {
t.Skip("large-v3 is installed, skipping test")
}
if !strings.Contains(err.Error(), "not installed") {
t.Errorf("Remove error = %v, want error containing 'not installed'", err)
}
}
func TestRemove_UnknownModel(t *testing.T) {
err := Remove("unknown-model")
if err == nil {
t.Error("Remove(unknown-model) = nil, want error")
}
if !strings.Contains(err.Error(), "unknown model") {
t.Errorf("Remove error = %v, want error containing 'unknown model'", err)
}
}
func TestGetInstalledPath_NotInstalled(t *testing.T) {
// use a model that's unlikely to be installed
_, err := GetInstalledPath("large-v3")
if err == nil {
t.Skip("large-v3 is installed, skipping test")
}
if !strings.Contains(err.Error(), "not installed") {
t.Errorf("GetInstalledPath error = %v, want error containing 'not installed'", err)
}
}
func TestDownloadAndRemove_Integration(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
// create a temp directory for this test
tempDir := t.TempDir()
// override GetModelsDir for this test
origGetModelsDir := GetModelsDir
_ = origGetModelsDir // acknowledge we're shadowing
// we can't easily override GetModelsDir since it's a function not a var
// so we'll just check the download flow works conceptually
// actual download testing would need network and is slow
t.Log("Integration test would download a model here")
t.Log("Temp dir:", tempDir)
}
// TestModelInfo_SizeBytes verifies size bytes are reasonable
func TestModelInfo_SizeBytes(t *testing.T) {
models := ListModels()
for _, m := range models {
if m.SizeBytes <= 0 {
t.Errorf("Model %s has invalid SizeBytes: %d", m.ID, m.SizeBytes)
}
}
}
// TestModelInfo_HasAllFields verifies all models have required fields
func TestModelInfo_HasAllFields(t *testing.T) {
models := ListModels()
for _, m := range models {
if m.ID == "" {
t.Error("Model has empty ID")
}
if m.Name == "" {
t.Errorf("Model %s has empty Name", m.ID)
}
if m.Filename == "" {
t.Errorf("Model %s has empty Filename", m.ID)
}
if m.Size == "" {
t.Errorf("Model %s has empty Size", m.ID)
}
}
}
+2
View File
@@ -6,6 +6,7 @@ type MessageType int
const ( const (
MsgRecordingStarted MessageType = iota MsgRecordingStarted MessageType = iota
MsgTranscribing MsgTranscribing
MsgLLMProcessing
MsgConfigReloaded MsgConfigReloaded
MsgOperationCancelled MsgOperationCancelled
MsgRecordingAborted MsgRecordingAborted
@@ -25,6 +26,7 @@ type MessageDef struct {
var MessageDefs = []MessageDef{ var MessageDefs = []MessageDef{
{MsgRecordingStarted, "recording_started", "Hyprvoice", "Recording Started", false}, {MsgRecordingStarted, "recording_started", "Hyprvoice", "Recording Started", false},
{MsgTranscribing, "transcribing", "Hyprvoice", "Recording Ended... Transcribing", false}, {MsgTranscribing, "transcribing", "Hyprvoice", "Recording Ended... Transcribing", false},
{MsgLLMProcessing, "llm_processing", "Hyprvoice", "Processing...", false},
{MsgConfigReloaded, "config_reloaded", "Hyprvoice", "Config Reloaded", false}, {MsgConfigReloaded, "config_reloaded", "Hyprvoice", "Config Reloaded", false},
{MsgOperationCancelled, "operation_cancelled", "Hyprvoice", "Operation Cancelled", false}, {MsgOperationCancelled, "operation_cancelled", "Hyprvoice", "Operation Cancelled", false},
{MsgRecordingAborted, "recording_aborted", "", "Recording Aborted", true}, {MsgRecordingAborted, "recording_aborted", "", "Recording Aborted", true},
+9 -2
View File
@@ -1,6 +1,7 @@
package notify package notify
import ( import (
"os"
"testing" "testing"
) )
@@ -16,6 +17,9 @@ func testMessages() map[MessageType]Message {
} }
func TestDesktop_Send(t *testing.T) { func TestDesktop_Send(t *testing.T) {
if os.Getenv("CI") == "true" {
t.Skip("Skipping Desktop test in CI - calls notify-send")
}
desktop := NewDesktop(testMessages()) desktop := NewDesktop(testMessages())
// Test Send for different message types (won't actually send, just verify no panic) // Test Send for different message types (won't actually send, just verify no panic)
@@ -25,6 +29,9 @@ func TestDesktop_Send(t *testing.T) {
} }
func TestDesktop_Error(t *testing.T) { func TestDesktop_Error(t *testing.T) {
if os.Getenv("CI") == "true" {
t.Skip("Skipping Desktop test in CI - calls notify-send")
}
desktop := NewDesktop(testMessages()) desktop := NewDesktop(testMessages())
desktop.Error("Test Error Message") desktop.Error("Test Error Message")
} }
@@ -98,8 +105,8 @@ func TestNotifierInterface(t *testing.T) {
func TestMessageDefs(t *testing.T) { func TestMessageDefs(t *testing.T) {
// Verify MessageDefs contains expected entries // Verify MessageDefs contains expected entries
if len(MessageDefs) != 6 { if len(MessageDefs) != 7 {
t.Errorf("Expected 6 MessageDefs, got %d", len(MessageDefs)) t.Errorf("Expected 7 MessageDefs, got %d", len(MessageDefs))
} }
// Verify each has required fields // Verify each has required fields
+114 -7
View File
@@ -8,6 +8,8 @@ import (
"github.com/leonardotrapani/hyprvoice/internal/config" "github.com/leonardotrapani/hyprvoice/internal/config"
"github.com/leonardotrapani/hyprvoice/internal/injection" "github.com/leonardotrapani/hyprvoice/internal/injection"
"github.com/leonardotrapani/hyprvoice/internal/llm"
"github.com/leonardotrapani/hyprvoice/internal/notify"
"github.com/leonardotrapani/hyprvoice/internal/recording" "github.com/leonardotrapani/hyprvoice/internal/recording"
"github.com/leonardotrapani/hyprvoice/internal/transcriber" "github.com/leonardotrapani/hyprvoice/internal/transcriber"
) )
@@ -25,6 +27,7 @@ const (
Idle Status = "idle" Idle Status = "idle"
Recording Status = "recording" Recording Status = "recording"
Transcribing Status = "transcribing" Transcribing Status = "transcribing"
Processing Status = "processing" // LLM post-processing
Injecting Status = "injecting" Injecting Status = "injecting"
) )
@@ -39,12 +42,51 @@ type Pipeline interface {
Status() Status Status() Status
GetActionCh() chan<- Action GetActionCh() chan<- Action
GetErrorCh() <-chan PipelineError GetErrorCh() <-chan PipelineError
GetNotifyCh() <-chan notify.MessageType
}
// Factory types for dependency injection
type RecorderFactory func(cfg recording.Config) recording.Recorder
type TranscriberFactory func(cfg transcriber.Config) (transcriber.Transcriber, error)
type InjectorFactory func(cfg injection.Config) injection.Injector
type LLMAdapterFactory func(cfg llm.Config) (llm.Adapter, error)
// Option configures the pipeline
type Option func(*pipeline)
// WithRecorderFactory sets a custom recorder factory
func WithRecorderFactory(f RecorderFactory) Option {
return func(p *pipeline) {
p.recorderFactory = f
}
}
// WithTranscriberFactory sets a custom transcriber factory
func WithTranscriberFactory(f TranscriberFactory) Option {
return func(p *pipeline) {
p.transcriberFactory = f
}
}
// WithInjectorFactory sets a custom injector factory
func WithInjectorFactory(f InjectorFactory) Option {
return func(p *pipeline) {
p.injectorFactory = f
}
}
// WithLLMAdapterFactory sets a custom LLM adapter factory
func WithLLMAdapterFactory(f LLMAdapterFactory) Option {
return func(p *pipeline) {
p.llmAdapterFactory = f
}
} }
type pipeline struct { type pipeline struct {
status Status status Status
actionCh chan Action actionCh chan Action
errorCh chan PipelineError errorCh chan PipelineError
notifyCh chan notify.MessageType
config *config.Config config *config.Config
mu sync.RWMutex mu sync.RWMutex
@@ -53,14 +95,32 @@ type pipeline struct {
stopOnce sync.Once stopOnce sync.Once
running atomic.Bool running atomic.Bool
// dependency factories (for testing)
recorderFactory RecorderFactory
transcriberFactory TranscriberFactory
injectorFactory InjectorFactory
llmAdapterFactory LLMAdapterFactory
} }
func New(cfg *config.Config) Pipeline { func New(cfg *config.Config, opts ...Option) Pipeline {
return &pipeline{ p := &pipeline{
actionCh: make(chan Action, 1), actionCh: make(chan Action, 1),
errorCh: make(chan PipelineError, 10), errorCh: make(chan PipelineError, 10),
notifyCh: make(chan notify.MessageType, 10),
config: cfg, config: cfg,
// default factories
recorderFactory: recording.NewRecorder,
transcriberFactory: transcriber.NewTranscriber,
injectorFactory: injection.NewInjector,
llmAdapterFactory: llm.NewAdapter,
} }
for _, opt := range opts {
opt(p)
}
return p
} }
func (p *pipeline) Run(ctx context.Context) { func (p *pipeline) Run(ctx context.Context) {
if !p.running.CompareAndSwap(false, true) { if !p.running.CompareAndSwap(false, true) {
@@ -85,7 +145,7 @@ func (p *pipeline) run(ctx context.Context) {
log.Printf("Pipeline: Starting recording") log.Printf("Pipeline: Starting recording")
p.setStatus(Recording) p.setStatus(Recording)
recorder := recording.NewRecorder(p.config.ToRecordingConfig()) recorder := p.recorderFactory(p.config.ToRecordingConfig())
frameCh, rErrCh, err := recorder.Start(ctx) frameCh, rErrCh, err := recorder.Start(ctx)
if err != nil { if err != nil {
@@ -96,7 +156,7 @@ func (p *pipeline) run(ctx context.Context) {
defer recorder.Stop() defer recorder.Stop()
t, err := transcriber.NewTranscriber(p.config.ToTranscriberConfig()) t, err := p.transcriberFactory(p.config.ToTranscriberConfig())
if err != nil { if err != nil {
log.Printf("Pipeline: Failed to create transcriber: %v", err) log.Printf("Pipeline: Failed to create transcriber: %v", err)
p.sendError("Transcription Error", "Failed to create transcriber", err) p.sendError("Transcription Error", "Failed to create transcriber", err)
@@ -187,6 +247,12 @@ func (p *pipeline) GetErrorCh() <-chan PipelineError {
return p.errorCh return p.errorCh
} }
func (p *pipeline) GetNotifyCh() <-chan notify.MessageType {
p.mu.RLock()
defer p.mu.RUnlock()
return p.notifyCh
}
func (p *pipeline) sendError(title, message string, err error) { func (p *pipeline) sendError(title, message string, err error) {
pipelineErr := PipelineError{ pipelineErr := PipelineError{
Title: title, Title: title,
@@ -201,7 +267,15 @@ func (p *pipeline) sendError(title, message string, err error) {
} }
} }
func (p *pipeline) handleInjectAction(ctx context.Context, recorder *recording.Recorder, t transcriber.Transcriber) { func (p *pipeline) sendNotify(mt notify.MessageType) {
select {
case p.notifyCh <- mt:
default:
log.Printf("Pipeline: Notify channel full, dropping notification")
}
}
func (p *pipeline) handleInjectAction(ctx context.Context, recorder recording.Recorder, t transcriber.Transcriber) {
status := p.Status() status := p.Status()
if status != Transcribing { if status != Transcribing {
@@ -226,9 +300,42 @@ func (p *pipeline) handleInjectAction(ctx context.Context, recorder *recording.R
} }
log.Printf("Pipeline: Final transcription text: %s", transcriptionText) log.Printf("Pipeline: Final transcription text: %s", transcriptionText)
injector := injection.NewInjector(p.config.ToInjectionConfig()) // LLM post-processing phase
textToInject := transcriptionText
if p.config.IsLLMEnabled() {
p.setStatus(Processing)
p.sendNotify(notify.MsgLLMProcessing)
log.Printf("Pipeline: LLM post-processing enabled, processing text")
if err := injector.Inject(ctx, transcriptionText); err != nil { llmCfg := p.config.ToLLMConfig()
adapter, err := p.llmAdapterFactory(llm.Config{
Provider: llmCfg.Provider,
APIKey: llmCfg.APIKey,
Model: llmCfg.Model,
RemoveStutters: llmCfg.RemoveStutters,
AddPunctuation: llmCfg.AddPunctuation,
FixGrammar: llmCfg.FixGrammar,
RemoveFillerWords: llmCfg.RemoveFillerWords,
CustomPrompt: llmCfg.CustomPrompt,
Keywords: llmCfg.Keywords,
})
if err != nil {
log.Printf("Pipeline: Failed to create LLM adapter: %v, using raw transcription", err)
} else {
processed, err := adapter.Process(ctx, transcriptionText)
if err != nil {
log.Printf("Pipeline: LLM processing failed: %v, using raw transcription", err)
} else {
textToInject = processed
log.Printf("Pipeline: LLM processed text: %s", textToInject)
}
}
p.setStatus(Injecting)
}
injector := p.injectorFactory(p.config.ToInjectionConfig())
if err := injector.Inject(ctx, textToInject); err != nil {
p.sendError("Injection Error", "Failed to inject text", err) p.sendError("Injection Error", "Failed to inject text", err)
} else { } else {
log.Printf("Pipeline: Text injection completed successfully") log.Printf("Pipeline: Text injection completed successfully")
+158 -7
View File
@@ -6,6 +6,7 @@ import (
"time" "time"
"github.com/leonardotrapani/hyprvoice/internal/config" "github.com/leonardotrapani/hyprvoice/internal/config"
"github.com/leonardotrapani/hyprvoice/internal/testutil"
) )
func TestNew(t *testing.T) { func TestNew(t *testing.T) {
@@ -20,10 +21,12 @@ func TestNew(t *testing.T) {
}, },
Transcription: config.TranscriptionConfig{ Transcription: config.TranscriptionConfig{
Provider: "openai", Provider: "openai",
APIKey: "test-key",
Language: "en", Language: "en",
Model: "whisper-1", Model: "whisper-1",
}, },
Providers: map[string]config.ProviderConfig{
"openai": {APIKey: "test-key"},
},
Injection: config.InjectionConfig{ Injection: config.InjectionConfig{
Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second, Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second,
WtypeTimeout: 5 * time.Second, WtypeTimeout: 5 * time.Second,
@@ -58,10 +61,12 @@ func TestPipeline_Status(t *testing.T) {
}, },
Transcription: config.TranscriptionConfig{ Transcription: config.TranscriptionConfig{
Provider: "openai", Provider: "openai",
APIKey: "test-key",
Language: "en", Language: "en",
Model: "whisper-1", Model: "whisper-1",
}, },
Providers: map[string]config.ProviderConfig{
"openai": {APIKey: "test-key"},
},
Injection: config.InjectionConfig{ Injection: config.InjectionConfig{
Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second, Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second,
WtypeTimeout: 5 * time.Second, WtypeTimeout: 5 * time.Second,
@@ -104,10 +109,12 @@ func TestPipeline_GetActionCh(t *testing.T) {
}, },
Transcription: config.TranscriptionConfig{ Transcription: config.TranscriptionConfig{
Provider: "openai", Provider: "openai",
APIKey: "test-key",
Language: "en", Language: "en",
Model: "whisper-1", Model: "whisper-1",
}, },
Providers: map[string]config.ProviderConfig{
"openai": {APIKey: "test-key"},
},
Injection: config.InjectionConfig{ Injection: config.InjectionConfig{
Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second, Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second,
WtypeTimeout: 5 * time.Second, WtypeTimeout: 5 * time.Second,
@@ -148,10 +155,12 @@ func TestPipeline_GetErrorCh(t *testing.T) {
}, },
Transcription: config.TranscriptionConfig{ Transcription: config.TranscriptionConfig{
Provider: "openai", Provider: "openai",
APIKey: "test-key",
Language: "en", Language: "en",
Model: "whisper-1", Model: "whisper-1",
}, },
Providers: map[string]config.ProviderConfig{
"openai": {APIKey: "test-key"},
},
Injection: config.InjectionConfig{ Injection: config.InjectionConfig{
Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second, Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second,
WtypeTimeout: 5 * time.Second, WtypeTimeout: 5 * time.Second,
@@ -192,10 +201,12 @@ func TestPipeline_Stop(t *testing.T) {
}, },
Transcription: config.TranscriptionConfig{ Transcription: config.TranscriptionConfig{
Provider: "openai", Provider: "openai",
APIKey: "test-key",
Language: "en", Language: "en",
Model: "whisper-1", Model: "whisper-1",
}, },
Providers: map[string]config.ProviderConfig{
"openai": {APIKey: "test-key"},
},
Injection: config.InjectionConfig{ Injection: config.InjectionConfig{
Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second, Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second,
WtypeTimeout: 5 * time.Second, WtypeTimeout: 5 * time.Second,
@@ -229,10 +240,12 @@ func TestPipeline_Run(t *testing.T) {
}, },
Transcription: config.TranscriptionConfig{ Transcription: config.TranscriptionConfig{
Provider: "openai", Provider: "openai",
APIKey: "test-key",
Language: "en", Language: "en",
Model: "whisper-1", Model: "whisper-1",
}, },
Providers: map[string]config.ProviderConfig{
"openai": {APIKey: "test-key"},
},
Injection: config.InjectionConfig{ Injection: config.InjectionConfig{
Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second, Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second,
WtypeTimeout: 5 * time.Second, WtypeTimeout: 5 * time.Second,
@@ -339,10 +352,12 @@ func TestPipeline_ConcurrentAccess(t *testing.T) {
}, },
Transcription: config.TranscriptionConfig{ Transcription: config.TranscriptionConfig{
Provider: "openai", Provider: "openai",
APIKey: "test-key",
Language: "en", Language: "en",
Model: "whisper-1", Model: "whisper-1",
}, },
Providers: map[string]config.ProviderConfig{
"openai": {APIKey: "test-key"},
},
Injection: config.InjectionConfig{ Injection: config.InjectionConfig{
Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second, Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second,
WtypeTimeout: 5 * time.Second, WtypeTimeout: 5 * time.Second,
@@ -377,3 +392,139 @@ func TestPipeline_ConcurrentAccess(t *testing.T) {
<-done <-done
<-done <-done
} }
func TestPipeline_WithMocks(t *testing.T) {
cfg := &config.Config{
Recording: config.RecordingConfig{
SampleRate: 16000,
Channels: 1,
Format: "s16",
BufferSize: 8192,
ChannelBufferSize: 30,
Timeout: 5 * time.Minute,
},
Transcription: config.TranscriptionConfig{
Provider: "openai",
Language: "en",
Model: "whisper-1",
},
Providers: map[string]config.ProviderConfig{
"openai": {APIKey: "test-key"},
},
Injection: config.InjectionConfig{
Backends: []string{"clipboard"},
ClipboardTimeout: 3 * time.Second,
},
Notifications: config.NotificationsConfig{
Enabled: true,
Type: "log",
},
}
mockRecorder := testutil.NewMockRecorder()
mockTranscriber := testutil.NewMockTranscriber("hello world")
mockInjector := testutil.NewMockInjector()
p := New(cfg,
WithRecorderFactory(testutil.MockRecorderFactory(mockRecorder)),
WithTranscriberFactory(testutil.MockTranscriberFactory(mockTranscriber)),
WithInjectorFactory(testutil.MockInjectorFactory(mockInjector)),
)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
p.Run(ctx)
// wait for pipeline to start recording/transcribing
time.Sleep(50 * time.Millisecond)
// send inject action
p.GetActionCh() <- Inject
// wait for injection to complete
time.Sleep(100 * time.Millisecond)
// verify injection happened
injected := mockInjector.GetInjectedTexts()
if len(injected) != 1 {
t.Errorf("expected 1 injected text, got %d", len(injected))
} else if injected[0] != "hello world" {
t.Errorf("expected injected text 'hello world', got %q", injected[0])
}
p.Stop()
}
func TestPipeline_WithMocks_LLMProcessing(t *testing.T) {
cfg := &config.Config{
Recording: config.RecordingConfig{
SampleRate: 16000,
Channels: 1,
Format: "s16",
BufferSize: 8192,
ChannelBufferSize: 30,
Timeout: 5 * time.Minute,
},
Transcription: config.TranscriptionConfig{
Provider: "openai",
Language: "en",
Model: "whisper-1",
},
Injection: config.InjectionConfig{
Backends: []string{"clipboard"},
ClipboardTimeout: 3 * time.Second,
},
Notifications: config.NotificationsConfig{
Enabled: true,
Type: "log",
},
LLM: config.LLMConfig{
Enabled: true,
Provider: "openai",
Model: "gpt-4",
},
Providers: map[string]config.ProviderConfig{
"openai": {APIKey: "test-key"},
},
}
mockRecorder := testutil.NewMockRecorder()
mockTranscriber := testutil.NewMockTranscriber("um hello um world")
mockInjector := testutil.NewMockInjector()
mockLLM := testutil.NewMockLLMAdapter("Hello, World!")
p := New(cfg,
WithRecorderFactory(testutil.MockRecorderFactory(mockRecorder)),
WithTranscriberFactory(testutil.MockTranscriberFactory(mockTranscriber)),
WithInjectorFactory(testutil.MockInjectorFactory(mockInjector)),
WithLLMAdapterFactory(testutil.MockLLMAdapterFactory(mockLLM)),
)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
p.Run(ctx)
time.Sleep(50 * time.Millisecond)
p.GetActionCh() <- Inject
time.Sleep(100 * time.Millisecond)
// verify LLM was called with transcription
if !mockLLM.ProcessCalled {
t.Error("expected LLM.Process to be called")
}
if mockLLM.InputText != "um hello um world" {
t.Errorf("expected LLM input 'um hello um world', got %q", mockLLM.InputText)
}
// verify injection used LLM output
injected := mockInjector.GetInjectedTexts()
if len(injected) != 1 {
t.Errorf("expected 1 injected text, got %d", len(injected))
} else if injected[0] != "Hello, World!" {
t.Errorf("expected injected text 'Hello, World!', got %q", injected[0])
}
p.Stop()
}
+72
View File
@@ -0,0 +1,72 @@
package provider
// DeepgramProvider implements Provider for Deepgram transcription services
type DeepgramProvider struct{}
func (p *DeepgramProvider) Name() string {
return ProviderDeepgram
}
func (p *DeepgramProvider) RequiresAPIKey() bool {
return true
}
func (p *DeepgramProvider) ValidateAPIKey(key string) bool {
// Deepgram API keys are alphanumeric, just check non-empty
return len(key) > 0
}
func (p *DeepgramProvider) APIKeyURL() string {
return "https://console.deepgram.com/project/keys"
}
func (p *DeepgramProvider) IsLocal() bool {
return false
}
func (p *DeepgramProvider) Models() []Model {
// https://developers.deepgram.com/docs/models-languages-overview
nova3Langs := deepgramNova3Languages
nova2Langs := deepgramNova2Languages
docsURL := "https://developers.deepgram.com/docs/language"
return []Model{
{
ID: "nova-3",
Name: "Nova-3",
Description: "Best accuracy; streaming available for faster response",
Type: Transcription,
SupportsBatch: true,
SupportsStreaming: true,
Local: false,
AdapterType: AdapterDeepgram,
SupportedLanguages: nova3Langs,
Endpoint: &EndpointConfig{BaseURL: "https://api.deepgram.com", Path: "/v1/listen"},
StreamingEndpoint: &EndpointConfig{BaseURL: "wss://api.deepgram.com", Path: "/v1/listen"},
DocsURL: docsURL,
},
{
ID: "nova-2",
Name: "Nova-2",
Description: "Cheaper legacy model; still solid accuracy",
Type: Transcription,
SupportsBatch: true,
SupportsStreaming: true,
Local: false,
AdapterType: AdapterDeepgram,
SupportedLanguages: nova2Langs,
Endpoint: &EndpointConfig{BaseURL: "https://api.deepgram.com", Path: "/v1/listen"},
StreamingEndpoint: &EndpointConfig{BaseURL: "wss://api.deepgram.com", Path: "/v1/listen"},
DocsURL: docsURL,
},
}
}
func (p *DeepgramProvider) DefaultModel(t ModelType) string {
switch t {
case Transcription:
return "nova-3"
}
return ""
}
+126
View File
@@ -0,0 +1,126 @@
package provider
import "testing"
func TestDeepgramProvider(t *testing.T) {
p := GetProvider("deepgram")
if p == nil {
t.Fatal("deepgram provider not registered")
}
if p.Name() != "deepgram" {
t.Errorf("Name() = %q, want %q", p.Name(), "deepgram")
}
if !p.RequiresAPIKey() {
t.Error("RequiresAPIKey() should return true")
}
if p.IsLocal() {
t.Error("IsLocal() should return false")
}
}
func TestDeepgramProvider_Models(t *testing.T) {
p := &DeepgramProvider{}
models := p.Models()
if len(models) != 2 {
t.Errorf("Models() returned %d models, want 2", len(models))
}
// all models should support both streaming and batch
for _, m := range models {
if !m.SupportsStreaming {
t.Errorf("model %s should support streaming", m.ID)
}
if !m.SupportsBothModes() {
t.Errorf("model %s should support both modes", m.ID)
}
if m.AdapterType != "deepgram" {
t.Errorf("model %s has AdapterType %q, want 'deepgram'", m.ID, m.AdapterType)
}
if m.Local {
t.Errorf("model %s should not be local", m.ID)
}
}
}
func TestDeepgramProvider_Nova3Languages(t *testing.T) {
p := &DeepgramProvider{}
models := p.Models()
var nova3 *Model
for i := range models {
if models[i].ID == "nova-3" {
nova3 = &models[i]
break
}
}
if nova3 == nil {
t.Fatal("nova-3 model not found")
}
// nova-3 should support many languages from our list
supportedTests := []struct {
code string
want bool
}{
{"en", true},
{"es", true},
{"fr", true},
{"de", true},
{"ja", true},
{"", true}, // auto always supported
}
for _, tt := range supportedTests {
got := nova3.SupportsLanguage(tt.code)
if got != tt.want {
t.Errorf("nova-3.SupportsLanguage(%q) = %v, want %v", tt.code, got, tt.want)
}
}
}
func TestDeepgramProvider_DefaultModel(t *testing.T) {
p := &DeepgramProvider{}
if got := p.DefaultModel(Transcription); got != "nova-3" {
t.Errorf("DefaultModel(Transcription) = %q, want 'nova-3'", got)
}
if got := p.DefaultModel(LLM); got != "" {
t.Errorf("DefaultModel(LLM) = %q, want empty (no LLM support)", got)
}
}
func TestDeepgramProvider_Endpoint(t *testing.T) {
p := &DeepgramProvider{}
models := p.Models()
for _, m := range models {
// batch endpoint (HTTP)
if m.Endpoint == nil {
t.Errorf("model %s has nil Endpoint", m.ID)
continue
}
if m.Endpoint.BaseURL != "https://api.deepgram.com" {
t.Errorf("model %s has Endpoint.BaseURL %q, want 'https://api.deepgram.com'", m.ID, m.Endpoint.BaseURL)
}
if m.Endpoint.Path != "/v1/listen" {
t.Errorf("model %s has Endpoint.Path %q, want '/v1/listen'", m.ID, m.Endpoint.Path)
}
// streaming endpoint (WebSocket)
if m.StreamingEndpoint == nil {
t.Errorf("model %s has nil StreamingEndpoint", m.ID)
continue
}
if m.StreamingEndpoint.BaseURL != "wss://api.deepgram.com" {
t.Errorf("model %s has StreamingEndpoint.BaseURL %q, want 'wss://api.deepgram.com'", m.ID, m.StreamingEndpoint.BaseURL)
}
if m.StreamingEndpoint.Path != "/v1/listen" {
t.Errorf("model %s has StreamingEndpoint.Path %q, want '/v1/listen'", m.ID, m.StreamingEndpoint.Path)
}
}
}
+87
View File
@@ -0,0 +1,87 @@
package provider
// ElevenLabsProvider implements Provider for ElevenLabs services (transcription only)
type ElevenLabsProvider struct{}
func (p *ElevenLabsProvider) Name() string {
return ProviderElevenLabs
}
func (p *ElevenLabsProvider) RequiresAPIKey() bool {
return true
}
func (p *ElevenLabsProvider) ValidateAPIKey(key string) bool {
// ElevenLabs API keys don't have a consistent prefix, just check non-empty
return len(key) > 0
}
func (p *ElevenLabsProvider) APIKeyURL() string {
return "https://elevenlabs.io/app/settings/api-keys"
}
func (p *ElevenLabsProvider) IsLocal() bool {
return false
}
func (p *ElevenLabsProvider) Models() []Model {
// https://elevenlabs.io/speech-to-text
allLangs := elevenLabsTranscriptionLanguages
docsURL := "https://elevenlabs.io/speech-to-text"
return []Model{
{
ID: "scribe_v1",
Name: "Scribe v1",
Description: "Most accurate; best for precision-critical work",
Type: Transcription,
SupportsBatch: true,
SupportsStreaming: false,
Local: false,
AdapterType: AdapterElevenLabs,
StreamingAdapter: AdapterElevenLabsStream,
SupportedLanguages: allLangs,
Endpoint: &EndpointConfig{BaseURL: "https://api.elevenlabs.io", Path: "/v1/speech-to-text"},
StreamingEndpoint: &EndpointConfig{BaseURL: "wss://api.elevenlabs.io", Path: "/v1/speech-to-text/realtime"},
DocsURL: docsURL,
},
{
ID: "scribe_v2",
Name: "Scribe v2",
Description: "Faster processing with good accuracy",
Type: Transcription,
SupportsBatch: true,
SupportsStreaming: false,
Local: false,
AdapterType: AdapterElevenLabs,
StreamingAdapter: AdapterElevenLabsStream,
SupportedLanguages: allLangs,
Endpoint: &EndpointConfig{BaseURL: "https://api.elevenlabs.io", Path: "/v1/speech-to-text"},
StreamingEndpoint: &EndpointConfig{BaseURL: "wss://api.elevenlabs.io", Path: "/v1/speech-to-text/realtime"},
DocsURL: docsURL,
},
{
ID: "scribe_v2_realtime",
Name: "Scribe v2 Realtime",
Description: "Instant words as you speak; faster but costs more",
Type: Transcription,
SupportsBatch: false,
SupportsStreaming: true,
Local: false,
AdapterType: AdapterElevenLabs,
StreamingAdapter: AdapterElevenLabsStream,
SupportedLanguages: allLangs,
Endpoint: &EndpointConfig{BaseURL: "https://api.elevenlabs.io", Path: "/v1/speech-to-text"},
StreamingEndpoint: &EndpointConfig{BaseURL: "wss://api.elevenlabs.io", Path: "/v1/speech-to-text/realtime"},
DocsURL: docsURL,
},
}
}
func (p *ElevenLabsProvider) DefaultModel(t ModelType) string {
switch t {
case Transcription:
return "scribe_v1"
}
return ""
}
+95
View File
@@ -0,0 +1,95 @@
package provider
import "strings"
// GroqProvider implements Provider for Groq services
type GroqProvider struct{}
func (p *GroqProvider) Name() string {
return ProviderGroq
}
func (p *GroqProvider) RequiresAPIKey() bool {
return true
}
func (p *GroqProvider) ValidateAPIKey(key string) bool {
return strings.HasPrefix(key, "gsk_")
}
func (p *GroqProvider) APIKeyURL() string {
return "https://console.groq.com/keys"
}
func (p *GroqProvider) IsLocal() bool {
return false
}
func (p *GroqProvider) Models() []Model {
// https://console.groq.com/docs/speech-to-text#supported-languages
allLangs := groqTranscriptionLanguages
docsURL := "https://console.groq.com/docs/speech-to-text#supported-languages"
return []Model{
// transcription models
{
ID: "whisper-large-v3",
Name: "Whisper Large v3",
Description: "Best accuracy; generous free tier makes this great default",
Type: Transcription,
SupportsBatch: true,
SupportsStreaming: false,
Local: false,
AdapterType: AdapterOpenAI,
SupportedLanguages: allLangs,
Endpoint: &EndpointConfig{BaseURL: "https://api.groq.com/openai", Path: "/v1/audio/transcriptions"},
DocsURL: docsURL,
},
{
ID: "whisper-large-v3-turbo",
Name: "Whisper Large v3 Turbo",
Description: "Faster with slight accuracy tradeoff; still very good",
Type: Transcription,
SupportsBatch: true,
SupportsStreaming: false,
Local: false,
AdapterType: AdapterOpenAI,
SupportedLanguages: allLangs,
Endpoint: &EndpointConfig{BaseURL: "https://api.groq.com/openai", Path: "/v1/audio/transcriptions"},
DocsURL: docsURL,
},
// LLM models
{
ID: "llama-3.3-70b-versatile",
Name: "Llama 3.3 70B Versatile",
Description: "Best quality cleanup; smart rewrites, free tier available",
Type: LLM,
SupportsBatch: true,
SupportsStreaming: false,
Local: false,
AdapterType: AdapterOpenAI,
Endpoint: &EndpointConfig{BaseURL: "https://api.groq.com/openai", Path: "/v1/chat/completions"},
},
{
ID: "llama-3.1-8b-instant",
Name: "Llama 3.1 8B Instant",
Description: "Very fast; good for simple cleanup tasks",
Type: LLM,
SupportsBatch: true,
SupportsStreaming: false,
Local: false,
AdapterType: AdapterOpenAI,
Endpoint: &EndpointConfig{BaseURL: "https://api.groq.com/openai", Path: "/v1/chat/completions"},
},
}
}
func (p *GroqProvider) DefaultModel(t ModelType) string {
switch t {
case Transcription:
return "whisper-large-v3-turbo"
case LLM:
return "llama-3.3-70b-versatile"
}
return ""
}
+30
View File
@@ -0,0 +1,30 @@
package provider
import (
"fmt"
"strings"
"golang.org/x/text/language"
"golang.org/x/text/language/display"
)
// LanguageLabel returns a human-readable label for a language code.
// Example: "es" -> "Spanish (es)", "en-US" -> "English (United States) (en-US)".
func LanguageLabel(code string) string {
if code == "" {
return ""
}
normalized := strings.ReplaceAll(code, "_", "-")
tag, err := language.Parse(normalized)
if err != nil {
return fmt.Sprintf("language '%s'", code)
}
name := display.English.Tags().Name(tag)
if name == "" || strings.EqualFold(name, code) {
return fmt.Sprintf("language '%s'", code)
}
return fmt.Sprintf("%s (%s)", name, code)
}
+49
View File
@@ -0,0 +1,49 @@
package provider
var openaiTranscriptionLanguages = []string{
"af", "ar", "hy", "az", "be", "bs", "bg", "ca", "zh", "hr", "cs", "da",
"nl", "en", "et", "fi", "fr", "gl", "de", "el", "he", "hi", "hu", "is",
"id", "it", "ja", "kn", "kk", "ko", "lv", "lt", "mk", "ms", "mr", "mi",
"ne", "no", "fa", "pl", "pt", "ro", "ru", "sr", "sk", "sl", "es", "sw",
"sv", "tl", "ta", "th", "tr", "uk", "ur", "vi", "cy",
}
var groqTranscriptionLanguages = openaiTranscriptionLanguages
var mistralTranscriptionLanguages = openaiTranscriptionLanguages
var whisperTranscriptionLanguages = openaiTranscriptionLanguages
var whisperEnglishOnlyLanguages = []string{"en"}
var deepgramNova3Languages = []string{
"multi",
"ar", "ar-AE", "ar-SA", "ar-QA", "ar-KW", "ar-SY", "ar-LB", "ar-PS", "ar-JO", "ar-EG", "ar-SD", "ar-TD", "ar-MA", "ar-DZ", "ar-TN", "ar-IQ", "ar-IR",
"be", "bn", "bs", "bg", "ca", "hr", "cs", "da", "da-DK", "nl", "nl-BE",
"en", "en-US", "en-AU", "en-GB", "en-IN", "en-NZ", "et", "fi", "fr", "fr-CA",
"de", "de-CH", "el", "hi", "hu", "id", "it", "ja", "kn", "ko", "ko-KR",
"lv", "lt", "mk", "ms", "mr", "no", "pl", "pt", "pt-BR", "pt-PT", "ro",
"ru", "sr", "sk", "sl", "es", "es-419", "sv", "sv-SE", "tl", "ta", "te",
"tr", "uk", "vi",
}
var deepgramNova2Languages = []string{
"multi",
"bg", "ca", "zh", "zh-CN", "zh-Hans", "zh-TW", "zh-Hant", "zh-HK", "cs",
"da", "da-DK", "nl", "nl-BE", "en", "en-US", "en-AU", "en-GB", "en-NZ", "en-IN",
"et", "fi", "fr", "fr-CA", "de", "de-CH", "el", "hi", "hu", "id", "it", "ja",
"ko", "ko-KR", "lv", "lt", "ms", "no", "pl", "pt", "pt-BR", "pt-PT", "ro",
"ru", "sk", "es", "es-419", "sv", "sv-SE", "th", "th-TH", "tr", "uk", "vi",
}
var deepgramFluxLanguages = []string{"en"}
var elevenLabsTranscriptionLanguages = []string{
"bel", "bos", "bul", "cat", "hrv", "ces", "dan", "nld", "eng", "est", "fin", "fra",
"glg", "deu", "ell", "hun", "isl", "ind", "ita", "jpn", "kan", "lav", "mkd", "msa",
"mal", "nor", "pol", "por", "ron", "rus", "slk", "spa", "swe", "tur", "ukr", "vie",
"hye", "aze", "ben", "yue", "fil", "kat", "guj", "hin", "kaz", "lit", "mlt", "cmn",
"mar", "nep", "ori", "fas", "srp", "slv", "swa", "tam", "tel",
"afr", "ara", "asm", "ast", "mya", "hau", "heb", "jav", "kor", "kir", "ltz", "mri",
"oci", "pan", "tgk", "tha", "uzb", "cym",
"amh", "lug", "ibo", "gle", "khm", "kur", "lao", "mon", "nso", "pus", "sna", "snd",
"som", "urd", "wol", "xho", "yor", "zul",
}
+55
View File
@@ -0,0 +1,55 @@
package provider
// MistralProvider implements Provider for Mistral services (transcription only)
type MistralProvider struct{}
func (p *MistralProvider) Name() string {
return ProviderMistral
}
func (p *MistralProvider) RequiresAPIKey() bool {
return true
}
func (p *MistralProvider) ValidateAPIKey(key string) bool {
// Mistral API keys don't have a consistent prefix, just check non-empty
return len(key) > 0
}
func (p *MistralProvider) APIKeyURL() string {
return "https://admin.mistral.ai/organization/api-keys"
}
func (p *MistralProvider) IsLocal() bool {
return false
}
func (p *MistralProvider) Models() []Model {
// https://docs.mistral.ai/capabilities/audio/
allLangs := mistralTranscriptionLanguages
docsURL := "https://docs.mistral.ai/capabilities/audio/"
return []Model{
{
ID: "voxtral-mini-latest",
Name: "Voxtral Mini Latest",
Description: "EU-hosted; good for data residency or Mistral ecosystem",
Type: Transcription,
SupportsBatch: true,
SupportsStreaming: false,
Local: false,
AdapterType: AdapterOpenAI,
SupportedLanguages: allLangs,
Endpoint: &EndpointConfig{BaseURL: "https://api.mistral.ai", Path: "/v1/audio/transcriptions"},
DocsURL: docsURL,
},
}
}
func (p *MistralProvider) DefaultModel(t ModelType) string {
switch t {
case Transcription:
return "voxtral-mini-latest"
}
return ""
}
+69
View File
@@ -0,0 +1,69 @@
package provider
// ModelType represents the type of a model
type ModelType int
const (
Transcription ModelType = iota
LLM
)
// Model represents a model with full metadata
type Model struct {
ID string // unique identifier (e.g., "whisper-1", "gpt-4o-mini")
Name string // display name (e.g., "Whisper 1", "GPT-4o Mini")
Description string // short description
Type ModelType // transcription or LLM
SupportsBatch bool // can do batch/non-streaming transcription
SupportsStreaming bool // can do real-time streaming transcription
Local bool // runs locally (no API call)
AdapterType string // which adapter to use (e.g., "openai", "elevenlabs", "whisper-cpp")
StreamingAdapter string // adapter for streaming mode (if different from AdapterType)
StreamingEndpoint *EndpointConfig // endpoint for streaming mode (if different from Endpoint)
SupportedLanguages []string // explicit list of provider language codes
Endpoint *EndpointConfig // nil for local models
LocalInfo *LocalModelInfo // nil for cloud models
DocsURL string // URL to provider's language support documentation
}
// EndpointConfig holds HTTP/WebSocket endpoint configuration
type EndpointConfig struct {
BaseURL string // e.g., "https://api.openai.com" or "wss://api.deepgram.com"
Path string // e.g., "/v1/audio/transcriptions"
}
// LocalModelInfo holds metadata for downloadable local models
type LocalModelInfo struct {
Filename string // e.g., "ggml-base.en.bin"
Size string // human readable size (e.g., "142MB")
DownloadURL string // full URL to download from
}
// NeedsDownload returns true if this is a local model that requires downloading
func (m *Model) NeedsDownload() bool {
return m.LocalInfo != nil
}
// IsStreaming returns true if this model supports streaming
func (m *Model) IsStreaming() bool {
return m.SupportsStreaming
}
// SupportsBothModes returns true if this model supports both batch and streaming
func (m *Model) SupportsBothModes() bool {
return m.SupportsBatch && m.SupportsStreaming
}
// SupportsLanguage returns true if the model supports the given language code.
// Auto-detect (empty string) is always supported.
func (m *Model) SupportsLanguage(code string) bool {
if code == "" {
return true // auto always supported
}
for _, supported := range m.SupportedLanguages {
if supported == code {
return true
}
}
return false
}
+350
View File
@@ -0,0 +1,350 @@
package provider
import "testing"
func TestModel_NeedsDownload(t *testing.T) {
tests := []struct {
name string
model Model
expected bool
}{
{
name: "local model with LocalInfo",
model: Model{
ID: "base.en",
Local: true,
LocalInfo: &LocalModelInfo{
Filename: "ggml-base.en.bin",
Size: "142MB",
DownloadURL: "https://example.com/model.bin",
},
},
expected: true,
},
{
name: "cloud model without LocalInfo",
model: Model{
ID: "whisper-1",
Local: false,
Endpoint: &EndpointConfig{BaseURL: "https://api.openai.com", Path: "/v1/audio/transcriptions"},
},
expected: false,
},
{
name: "model with nil LocalInfo",
model: Model{
ID: "gpt-4o",
LocalInfo: nil,
},
expected: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := tc.model.NeedsDownload(); got != tc.expected {
t.Errorf("NeedsDownload() = %v, want %v", got, tc.expected)
}
})
}
}
func TestModel_IsStreaming(t *testing.T) {
tests := []struct {
name string
model Model
expected bool
}{
{
name: "streaming-only model",
model: Model{ID: "flux-general-en", SupportsBatch: false, SupportsStreaming: true},
expected: true,
},
{
name: "batch-only model",
model: Model{ID: "whisper-1", SupportsBatch: true, SupportsStreaming: false},
expected: false,
},
{
name: "both modes model",
model: Model{ID: "nova-3", SupportsBatch: true, SupportsStreaming: true},
expected: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := tc.model.IsStreaming(); got != tc.expected {
t.Errorf("IsStreaming() = %v, want %v", got, tc.expected)
}
})
}
}
func TestModel_SupportsBothModes(t *testing.T) {
tests := []struct {
name string
model Model
expected bool
}{
{
name: "streaming-only model",
model: Model{ID: "flux-general-en", SupportsBatch: false, SupportsStreaming: true},
expected: false,
},
{
name: "batch-only model",
model: Model{ID: "whisper-1", SupportsBatch: true, SupportsStreaming: false},
expected: false,
},
{
name: "both modes model",
model: Model{ID: "nova-3", SupportsBatch: true, SupportsStreaming: true},
expected: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := tc.model.SupportsBothModes(); got != tc.expected {
t.Errorf("SupportsBothModes() = %v, want %v", got, tc.expected)
}
})
}
}
func TestModel_SupportsLanguage(t *testing.T) {
multilingualModel := Model{
ID: "whisper-large-v3",
SupportedLanguages: []string{"en", "es", "zh"},
}
englishOnlyModel := Model{
ID: "base.en",
SupportedLanguages: []string{"en"},
}
tests := []struct {
name string
model Model
code string
expected bool
}{
{
name: "multilingual supports en",
model: multilingualModel,
code: "en",
expected: true,
},
{
name: "multilingual supports es",
model: multilingualModel,
code: "es",
expected: true,
},
{
name: "multilingual supports zh",
model: multilingualModel,
code: "zh",
expected: true,
},
{
name: "english-only supports en",
model: englishOnlyModel,
code: "en",
expected: true,
},
{
name: "english-only does not support es",
model: englishOnlyModel,
code: "es",
expected: false,
},
{
name: "english-only does not support zh",
model: englishOnlyModel,
code: "zh",
expected: false,
},
{
name: "auto always supported on multilingual",
model: multilingualModel,
code: "",
expected: true,
},
{
name: "auto always supported on english-only",
model: englishOnlyModel,
code: "",
expected: true,
},
{
name: "empty SupportedLanguages still supports auto",
model: Model{ID: "empty", SupportedLanguages: []string{}},
code: "",
expected: true,
},
{
name: "empty SupportedLanguages does not support en",
model: Model{ID: "empty", SupportedLanguages: []string{}},
code: "en",
expected: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := tc.model.SupportsLanguage(tc.code); got != tc.expected {
t.Errorf("SupportsLanguage(%q) = %v, want %v", tc.code, got, tc.expected)
}
})
}
}
func TestModelType_Constants(t *testing.T) {
// verify ModelType constants exist and are distinct
if Transcription == LLM {
t.Error("Transcription and LLM should be different")
}
// verify they're the expected values
if Transcription != 0 {
t.Errorf("Transcription = %d, want 0", Transcription)
}
if LLM != 1 {
t.Errorf("LLM = %d, want 1", LLM)
}
}
func TestEndpointConfig_Fields(t *testing.T) {
endpoint := EndpointConfig{
BaseURL: "https://api.openai.com",
Path: "/v1/audio/transcriptions",
}
if endpoint.BaseURL != "https://api.openai.com" {
t.Errorf("BaseURL = %q, want 'https://api.openai.com'", endpoint.BaseURL)
}
if endpoint.Path != "/v1/audio/transcriptions" {
t.Errorf("Path = %q, want '/v1/audio/transcriptions'", endpoint.Path)
}
}
func TestLocalModelInfo_Fields(t *testing.T) {
info := LocalModelInfo{
Filename: "ggml-base.en.bin",
Size: "142MB",
DownloadURL: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin",
}
if info.Filename != "ggml-base.en.bin" {
t.Errorf("Filename = %q, want 'ggml-base.en.bin'", info.Filename)
}
if info.Size != "142MB" {
t.Errorf("Size = %q, want '142MB'", info.Size)
}
if info.DownloadURL != "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin" {
t.Errorf("DownloadURL = %q", info.DownloadURL)
}
}
func TestModel_AllFields(t *testing.T) {
// verify all Model struct fields can be set and read correctly
model := Model{
ID: "test-model",
Name: "Test Model",
Description: "A test model for verification",
Type: Transcription,
SupportsBatch: true,
SupportsStreaming: true,
Local: true,
AdapterType: "test-adapter",
StreamingAdapter: "test-streaming-adapter",
SupportedLanguages: []string{"en", "es"},
Endpoint: &EndpointConfig{
BaseURL: "https://api.test.com",
Path: "/v1/test",
},
StreamingEndpoint: &EndpointConfig{
BaseURL: "wss://api.test.com",
Path: "/v1/stream",
},
LocalInfo: &LocalModelInfo{
Filename: "test.bin",
Size: "100MB",
DownloadURL: "https://example.com/test.bin",
},
DocsURL: "https://example.com/docs/languages",
}
if model.ID != "test-model" {
t.Errorf("ID = %q, want 'test-model'", model.ID)
}
if model.Name != "Test Model" {
t.Errorf("Name = %q, want 'Test Model'", model.Name)
}
if model.Description != "A test model for verification" {
t.Errorf("Description = %q", model.Description)
}
if model.Type != Transcription {
t.Errorf("Type = %v, want Transcription", model.Type)
}
if !model.SupportsBatch {
t.Error("SupportsBatch should be true")
}
if !model.SupportsStreaming {
t.Error("SupportsStreaming should be true")
}
if !model.Local {
t.Error("Local should be true")
}
if model.AdapterType != "test-adapter" {
t.Errorf("AdapterType = %q, want 'test-adapter'", model.AdapterType)
}
if len(model.SupportedLanguages) != 2 {
t.Errorf("SupportedLanguages length = %d, want 2", len(model.SupportedLanguages))
}
if model.Endpoint == nil {
t.Error("Endpoint should not be nil")
}
if model.LocalInfo == nil {
t.Error("LocalInfo should not be nil")
}
if model.DocsURL != "https://example.com/docs/languages" {
t.Errorf("DocsURL = %q, want 'https://example.com/docs/languages'", model.DocsURL)
}
}
func TestAllTranscriptionModels_HaveDocsURL(t *testing.T) {
// verify all transcription models have DocsURL set
providers := []string{"openai", "groq", "mistral", "elevenlabs", "deepgram", "whisper-cpp"}
expectedDocsURLs := map[string]string{
"openai": "https://platform.openai.com/docs/guides/speech-to-text#supported-languages",
"groq": "https://console.groq.com/docs/speech-to-text#supported-languages",
"mistral": "https://docs.mistral.ai/capabilities/audio/",
"elevenlabs": "https://elevenlabs.io/speech-to-text",
"deepgram": "https://developers.deepgram.com/docs/language",
"whisper-cpp": "https://github.com/ggml-org/whisper.cpp#models",
}
for _, pName := range providers {
p := GetProvider(pName)
if p == nil {
t.Errorf("GetProvider(%q) returned nil", pName)
continue
}
expectedURL := expectedDocsURLs[pName]
for _, m := range p.Models() {
if m.Type != Transcription {
continue
}
if m.DocsURL == "" {
t.Errorf("%s/%s: DocsURL is empty", pName, m.ID)
} else if m.DocsURL != expectedURL {
t.Errorf("%s/%s: DocsURL = %q, want %q", pName, m.ID, m.DocsURL, expectedURL)
}
}
}
}
+72
View File
@@ -0,0 +1,72 @@
package provider
// Provider name constants for config and registry
const (
ProviderOpenAI = "openai"
ProviderGroq = "groq"
ProviderMistral = "mistral"
ProviderElevenLabs = "elevenlabs"
ProviderDeepgram = "deepgram"
ProviderWhisperCpp = "whisper-cpp"
)
// Config provider names (used in config file transcription.provider)
const (
ConfigProviderOpenAI = "openai"
ConfigProviderGroqTranscription = "groq-transcription"
ConfigProviderMistralTranscription = "mistral-transcription"
ConfigProviderElevenLabs = "elevenlabs"
ConfigProviderDeepgram = "deepgram"
ConfigProviderWhisperCpp = "whisper-cpp"
)
// Environment variable names for API keys
const (
EnvOpenAIKey = "OPENAI_API_KEY"
EnvGroqKey = "GROQ_API_KEY"
EnvMistralKey = "MISTRAL_API_KEY"
EnvElevenLabsKey = "ELEVENLABS_API_KEY"
EnvDeepgramKey = "DEEPGRAM_API_KEY"
)
// Adapter type constants for transcription backends
const (
AdapterOpenAI = "openai"
AdapterElevenLabs = "elevenlabs"
AdapterElevenLabsStream = "elevenlabs-streaming"
AdapterDeepgram = "deepgram"
AdapterWhisperCpp = "whisper-cpp"
AdapterOpenAIRealtime = "openai-realtime"
)
// BaseProviderName maps config provider names to registry provider names
// e.g. "groq-transcription" -> "groq", "mistral-transcription" -> "mistral"
func BaseProviderName(configProvider string) string {
switch configProvider {
case ConfigProviderGroqTranscription:
return ProviderGroq
case ConfigProviderMistralTranscription:
return ProviderMistral
default:
return configProvider
}
}
// EnvVarForProvider returns the environment variable name for a provider's API key
func EnvVarForProvider(provider string) string {
base := BaseProviderName(provider)
switch base {
case ProviderOpenAI:
return EnvOpenAIKey
case ProviderGroq:
return EnvGroqKey
case ProviderMistral:
return EnvMistralKey
case ProviderElevenLabs:
return EnvElevenLabsKey
case ProviderDeepgram:
return EnvDeepgramKey
default:
return ""
}
}
+122
View File
@@ -0,0 +1,122 @@
package provider
import "strings"
// OpenAIProvider implements Provider for OpenAI services
type OpenAIProvider struct{}
func (p *OpenAIProvider) Name() string {
return ProviderOpenAI
}
func (p *OpenAIProvider) RequiresAPIKey() bool {
return true
}
func (p *OpenAIProvider) ValidateAPIKey(key string) bool {
return strings.HasPrefix(key, "sk-")
}
func (p *OpenAIProvider) APIKeyURL() string {
return "https://platform.openai.com/api-keys"
}
func (p *OpenAIProvider) IsLocal() bool {
return false
}
func (p *OpenAIProvider) Models() []Model {
// https://platform.openai.com/docs/guides/speech-to-text#supported-languages
allLangs := openaiTranscriptionLanguages
docsURL := "https://platform.openai.com/docs/guides/speech-to-text#supported-languages"
return []Model{
// transcription models
{
ID: "whisper-1",
Name: "Whisper 1",
Description: "Reliable and cost-effective; good default for most use cases",
Type: Transcription,
SupportsBatch: true,
SupportsStreaming: false,
Local: false,
AdapterType: AdapterOpenAI,
SupportedLanguages: allLangs,
Endpoint: &EndpointConfig{BaseURL: "https://api.openai.com", Path: "/v1/audio/transcriptions"},
DocsURL: docsURL,
},
{
ID: "gpt-4o-transcribe",
Name: "GPT-4o Transcribe",
Description: "Top accuracy; slower and pricier but best quality",
Type: Transcription,
SupportsBatch: true,
SupportsStreaming: false,
Local: false,
AdapterType: AdapterOpenAI,
SupportedLanguages: allLangs,
Endpoint: &EndpointConfig{BaseURL: "https://api.openai.com", Path: "/v1/audio/transcriptions"},
DocsURL: docsURL,
},
{
ID: "gpt-4o-mini-transcribe",
Name: "GPT-4o Mini Transcribe",
Description: "Good balance of speed, cost, and quality",
Type: Transcription,
SupportsBatch: true,
SupportsStreaming: false,
Local: false,
AdapterType: AdapterOpenAI,
SupportedLanguages: allLangs,
Endpoint: &EndpointConfig{BaseURL: "https://api.openai.com", Path: "/v1/audio/transcriptions"},
DocsURL: docsURL,
},
{
ID: "gpt-4o-realtime-preview",
Name: "GPT-4o Realtime Preview",
Description: "Instant words as you speak; fastest but most expensive",
Type: Transcription,
SupportsBatch: false,
SupportsStreaming: true,
Local: false,
AdapterType: AdapterOpenAIRealtime,
SupportedLanguages: allLangs,
Endpoint: &EndpointConfig{BaseURL: "wss://api.openai.com", Path: "/v1/realtime"},
DocsURL: docsURL,
},
// LLM models
{
ID: "gpt-4o-mini",
Name: "GPT-4o Mini",
Description: "Fast and cheap; good default for text cleanup",
Type: LLM,
SupportsBatch: true,
SupportsStreaming: false,
Local: false,
AdapterType: AdapterOpenAI,
Endpoint: &EndpointConfig{BaseURL: "https://api.openai.com", Path: "/v1/chat/completions"},
},
{
ID: "gpt-4o",
Name: "GPT-4o",
Description: "Best quality cleanup; pricier but smarter rewrites",
Type: LLM,
SupportsBatch: true,
SupportsStreaming: false,
Local: false,
AdapterType: AdapterOpenAI,
Endpoint: &EndpointConfig{BaseURL: "https://api.openai.com", Path: "/v1/chat/completions"},
},
}
}
func (p *OpenAIProvider) DefaultModel(t ModelType) string {
switch t {
case Transcription:
return "whisper-1"
case LLM:
return "gpt-4o-mini"
}
return ""
}
+185
View File
@@ -0,0 +1,185 @@
package provider
import (
"errors"
"fmt"
"strings"
)
// Provider defines the interface for a transcription/LLM service provider
type Provider interface {
Name() string
RequiresAPIKey() bool
ValidateAPIKey(key string) bool
APIKeyURL() string
IsLocal() bool
Models() []Model
DefaultModel(t ModelType) string
}
// ProviderConfig holds configuration for a single provider
type ProviderConfig struct {
APIKey string `toml:"api_key"`
}
var registry = make(map[string]Provider)
func init() {
Register(&OpenAIProvider{})
Register(&GroqProvider{})
Register(&MistralProvider{})
Register(&ElevenLabsProvider{})
Register(&WhisperCppProvider{})
Register(&DeepgramProvider{})
}
// Register adds a provider to the registry
func Register(p Provider) {
registry[p.Name()] = p
}
// GetProvider returns a provider by name, or nil if not found
func GetProvider(name string) Provider {
return registry[name]
}
// ListProviders returns all registered provider names
func ListProviders() []string {
names := make([]string, 0, len(registry))
for name := range registry {
names = append(names, name)
}
return names
}
// ListProvidersWithTranscription returns providers that support transcription
func ListProvidersWithTranscription() []string {
var names []string
for name, p := range registry {
if hasModelsOfType(p, Transcription) {
names = append(names, name)
}
}
return names
}
// ListProvidersWithLLM returns providers that support LLM
func ListProvidersWithLLM() []string {
var names []string
for name, p := range registry {
if hasModelsOfType(p, LLM) {
names = append(names, name)
}
}
return names
}
// hasModelsOfType returns true if provider has any models of the given type
func hasModelsOfType(p Provider, t ModelType) bool {
for _, m := range p.Models() {
if m.Type == t {
return true
}
}
return false
}
// GetModel returns a model from a specific provider, or error if not found
func GetModel(providerName, modelID string) (*Model, error) {
p := GetProvider(providerName)
if p == nil {
return nil, fmt.Errorf("unknown provider: %s", providerName)
}
for _, m := range p.Models() {
if m.ID == modelID {
return &m, nil
}
}
return nil, fmt.Errorf("model %s not found in provider %s", modelID, providerName)
}
// ModelsOfType returns all models of the given type from a provider
func ModelsOfType(p Provider, t ModelType) []Model {
var result []Model
for _, m := range p.Models() {
if m.Type == t {
result = append(result, m)
}
}
return result
}
// FindModelByID searches all providers for a model with the given ID
func FindModelByID(modelID string) (*Model, Provider, error) {
for _, p := range registry {
for _, m := range p.Models() {
if m.ID == modelID {
return &m, p, nil
}
}
}
return nil, nil, fmt.Errorf("model %s not found in any provider", modelID)
}
// ModelsForLanguage returns models from a provider that support the given language
func ModelsForLanguage(p Provider, t ModelType, langCode string) []Model {
var result []Model
for _, m := range p.Models() {
if m.Type == t && m.SupportsLanguage(langCode) {
result = append(result, m)
}
}
return result
}
// ValidateModelLanguage checks if a model supports the given language.
// Returns error with list of supported languages if not supported.
// Returns nil if langCode is "" (auto) - auto is always supported.
func ValidateModelLanguage(providerName, modelID, langCode string) error {
if langCode == "" {
return nil // auto always supported
}
model, err := GetModel(providerName, modelID)
if err != nil {
return err
}
if model.SupportsLanguage(langCode) {
return nil
}
// truncate supported languages list for error message
supported := model.SupportedLanguages
suffix := ""
if len(supported) > 5 {
supported = supported[:5]
suffix = "..."
}
// build error with docs URL if available
docsHint := ""
if model.DocsURL != "" {
docsHint = fmt.Sprintf(" See %s for full list.", model.DocsURL)
}
langLabel := LanguageLabel(langCode)
if langLabel == "" {
langLabel = fmt.Sprintf("language '%s'", langCode)
}
return fmt.Errorf(
"model %s does not support %s.%s Supported: %s%s",
model.Name,
langLabel,
docsHint,
strings.Join(supported, ", "),
suffix,
)
}
var (
ErrProviderNotFound = errors.New("provider not found")
ErrModelNotFound = errors.New("model not found")
)
+405
View File
@@ -0,0 +1,405 @@
package provider
import (
"slices"
"strings"
"testing"
)
func TestProviderInterface(t *testing.T) {
providers := []struct {
name string
hasTranscription bool
hasLLM bool
isLocal bool
defaultTransModel string
defaultLLMModel string
}{
{"openai", true, true, false, "whisper-1", "gpt-4o-mini"},
{"groq", true, true, false, "whisper-large-v3-turbo", "llama-3.3-70b-versatile"},
{"mistral", true, false, false, "voxtral-mini-latest", ""},
{"elevenlabs", true, false, false, "scribe_v1", ""},
}
for _, tc := range providers {
t.Run(tc.name, func(t *testing.T) {
p := GetProvider(tc.name)
if p == nil {
t.Fatalf("GetProvider(%q) returned nil", tc.name)
}
if p.Name() != tc.name {
t.Errorf("Name() = %q, want %q", p.Name(), tc.name)
}
hasTranscription := len(ModelsOfType(p, Transcription)) > 0
if hasTranscription != tc.hasTranscription {
t.Errorf("hasTranscription = %v, want %v", hasTranscription, tc.hasTranscription)
}
hasLLM := len(ModelsOfType(p, LLM)) > 0
if hasLLM != tc.hasLLM {
t.Errorf("hasLLM = %v, want %v", hasLLM, tc.hasLLM)
}
if p.IsLocal() != tc.isLocal {
t.Errorf("IsLocal() = %v, want %v", p.IsLocal(), tc.isLocal)
}
if p.DefaultModel(Transcription) != tc.defaultTransModel {
t.Errorf("DefaultModel(Transcription) = %q, want %q", p.DefaultModel(Transcription), tc.defaultTransModel)
}
if p.DefaultModel(LLM) != tc.defaultLLMModel {
t.Errorf("DefaultModel(LLM) = %q, want %q", p.DefaultModel(LLM), tc.defaultLLMModel)
}
if !p.RequiresAPIKey() {
t.Error("RequiresAPIKey() should be true for all cloud providers")
}
if tc.hasTranscription && len(ModelsOfType(p, Transcription)) == 0 {
t.Error("should have transcription models")
}
if tc.hasLLM && len(ModelsOfType(p, LLM)) == 0 {
t.Error("should have LLM models")
}
})
}
}
func TestGetProviderNotFound(t *testing.T) {
p := GetProvider("nonexistent")
if p != nil {
t.Errorf("GetProvider(nonexistent) should return nil, got %v", p)
}
}
func TestListProviders(t *testing.T) {
providers := ListProviders()
expected := []string{"openai", "groq", "mistral", "elevenlabs"}
for _, name := range expected {
if !slices.Contains(providers, name) {
t.Errorf("ListProviders() missing %q", name)
}
}
}
func TestListProvidersWithTranscription(t *testing.T) {
providers := ListProvidersWithTranscription()
// All providers support transcription
expected := []string{"openai", "groq", "mistral", "elevenlabs"}
for _, name := range expected {
if !slices.Contains(providers, name) {
t.Errorf("ListProvidersWithTranscription() missing %q", name)
}
}
}
func TestListProvidersWithLLM(t *testing.T) {
providers := ListProvidersWithLLM()
expected := []string{"openai", "groq"}
for _, name := range expected {
if !slices.Contains(providers, name) {
t.Errorf("ListProvidersWithLLM() missing %q", name)
}
}
// Mistral and ElevenLabs should NOT be in the list
notExpected := []string{"mistral", "elevenlabs"}
for _, name := range notExpected {
if slices.Contains(providers, name) {
t.Errorf("ListProvidersWithLLM() should not include %q", name)
}
}
}
func TestValidateAPIKey(t *testing.T) {
tests := []struct {
provider string
key string
valid bool
}{
{"openai", "sk-abc123", true},
{"openai", "invalid", false},
{"openai", "", false},
{"groq", "gsk_abc123", true},
{"groq", "invalid", false},
{"groq", "", false},
{"mistral", "any-non-empty", true},
{"mistral", "", false},
{"elevenlabs", "any-non-empty", true},
{"elevenlabs", "", false},
}
for _, tc := range tests {
t.Run(tc.provider+"_"+tc.key, func(t *testing.T) {
p := GetProvider(tc.provider)
if p.ValidateAPIKey(tc.key) != tc.valid {
t.Errorf("ValidateAPIKey(%q) = %v, want %v", tc.key, !tc.valid, tc.valid)
}
})
}
}
func TestGetModel(t *testing.T) {
// valid provider and model
m, err := GetModel("openai", "whisper-1")
if err != nil {
t.Errorf("GetModel('openai', 'whisper-1') unexpected error: %v", err)
}
if m == nil {
t.Fatal("GetModel returned nil model")
}
if m.ID != "whisper-1" {
t.Errorf("GetModel returned model with ID %q, want 'whisper-1'", m.ID)
}
// unknown provider
_, err = GetModel("nonexistent", "whisper-1")
if err == nil {
t.Error("GetModel('nonexistent', ...) should return error")
}
// unknown model
_, err = GetModel("openai", "nonexistent")
if err == nil {
t.Error("GetModel('openai', 'nonexistent') should return error")
}
}
func TestModelsOfType(t *testing.T) {
p := GetProvider("openai")
trans := ModelsOfType(p, Transcription)
llm := ModelsOfType(p, LLM)
// OpenAI has 4 transcription models: whisper-1, gpt-4o-transcribe, gpt-4o-mini-transcribe, gpt-4o-realtime-preview
if len(trans) != 4 {
t.Errorf("ModelsOfType(Transcription) = %d, want 4", len(trans))
}
// OpenAI has 2 LLM models: gpt-4o-mini, gpt-4o
if len(llm) != 2 {
t.Errorf("ModelsOfType(LLM) = %d, want 2", len(llm))
}
}
func TestFindModelByID(t *testing.T) {
// find model that exists
m, p, err := FindModelByID("whisper-1")
if err != nil {
t.Errorf("FindModelByID('whisper-1') unexpected error: %v", err)
}
if m == nil || p == nil {
t.Fatal("FindModelByID returned nil")
}
if m.ID != "whisper-1" {
t.Errorf("FindModelByID returned model %q, want 'whisper-1'", m.ID)
}
if p.Name() != "openai" {
t.Errorf("FindModelByID returned provider %q, want 'openai'", p.Name())
}
// model not found
_, _, err = FindModelByID("nonexistent")
if err == nil {
t.Error("FindModelByID('nonexistent') should return error")
}
}
func TestModelsForLanguage(t *testing.T) {
groq := GetProvider("groq")
// en should include all models
enModels := ModelsForLanguage(groq, Transcription, "en")
if len(enModels) != 2 {
t.Errorf("ModelsForLanguage('en') = %d, want 2", len(enModels))
}
// es should include all models (both are multilingual)
esModels := ModelsForLanguage(groq, Transcription, "es")
if len(esModels) != 2 {
t.Errorf("ModelsForLanguage('es') = %d, want 2", len(esModels))
}
// auto ("") should include all models
autoModels := ModelsForLanguage(groq, Transcription, "")
if len(autoModels) != 2 {
t.Errorf("ModelsForLanguage('') = %d, want 2 (auto returns all)", len(autoModels))
}
}
func TestValidateModelLanguage(t *testing.T) {
// valid language for multilingual model
err := ValidateModelLanguage("groq", "whisper-large-v3", "es")
if err != nil {
t.Errorf("ValidateModelLanguage(whisper-large-v3, 'es') unexpected error: %v", err)
}
// valid language for another multilingual model
err = ValidateModelLanguage("groq", "whisper-large-v3-turbo", "de")
if err != nil {
t.Errorf("ValidateModelLanguage(whisper-large-v3-turbo, 'de') unexpected error: %v", err)
}
// auto always passes
err = ValidateModelLanguage("groq", "whisper-large-v3", "")
if err != nil {
t.Errorf("ValidateModelLanguage(whisper-large-v3, '') should pass (auto): %v", err)
}
// unknown provider
err = ValidateModelLanguage("nonexistent", "whisper-1", "en")
if err == nil {
t.Error("ValidateModelLanguage with unknown provider should return error")
}
// unknown model
err = ValidateModelLanguage("openai", "nonexistent", "en")
if err == nil {
t.Error("ValidateModelLanguage with unknown model should return error")
}
}
func TestValidateModelLanguage_ErrorFormat(t *testing.T) {
// verify error includes model name, not ID
// use whisper-cpp base.en model which is English-only
err := ValidateModelLanguage("whisper-cpp", "base.en", "es")
if err == nil {
t.Fatal("expected error for unsupported language")
}
errMsg := err.Error()
// should contain model name (from Model.Name)
if !strings.Contains(errMsg, "Base English") {
t.Errorf("error should contain model name, got: %s", errMsg)
}
// should contain docs URL
if !strings.Contains(errMsg, "https://github.com/ggml-org/whisper.cpp") {
t.Errorf("error should contain docs URL, got: %s", errMsg)
}
// should contain language label
if !strings.Contains(errMsg, "Spanish (es)") {
t.Errorf("error should contain language label, got: %s", errMsg)
}
// should contain supported languages (English-only has just 'en')
if !strings.Contains(errMsg, "en") {
t.Errorf("error should contain supported languages, got: %s", errMsg)
}
}
func TestOpenAIStreamingModels(t *testing.T) {
// gpt-4o-realtime-preview is streaming-only
m, err := GetModel("openai", "gpt-4o-realtime-preview")
if err != nil {
t.Fatalf("GetModel('openai', 'gpt-4o-realtime-preview') error: %v", err)
}
if m.SupportsBatch {
t.Error("gpt-4o-realtime-preview should have SupportsBatch=false")
}
if !m.SupportsStreaming {
t.Error("gpt-4o-realtime-preview should have SupportsStreaming=true")
}
if m.SupportsBothModes() {
t.Error("gpt-4o-realtime-preview should not support both modes")
}
if m.AdapterType != "openai-realtime" {
t.Errorf("gpt-4o-realtime-preview AdapterType=%q, want 'openai-realtime'", m.AdapterType)
}
if m.Endpoint == nil {
t.Fatal("gpt-4o-realtime-preview should have Endpoint set")
}
if m.Endpoint.BaseURL != "wss://api.openai.com" {
t.Errorf("gpt-4o-realtime-preview Endpoint.BaseURL=%q, want 'wss://api.openai.com'", m.Endpoint.BaseURL)
}
// default model should still be whisper-1
p := GetProvider("openai")
if p.DefaultModel(Transcription) != "whisper-1" {
t.Errorf("DefaultModel(Transcription) = %q, want 'whisper-1'", p.DefaultModel(Transcription))
}
}
func TestElevenLabsProvider(t *testing.T) {
p := GetProvider("elevenlabs")
if p == nil {
t.Fatal("GetProvider('elevenlabs') returned nil")
}
models := p.Models()
// ElevenLabsProvider.Models() returns 3 models
if len(models) != 3 {
t.Errorf("ElevenLabsProvider.Models() = %d models, want 3", len(models))
}
// Check batch + streaming models
scribeV1, err := GetModel("elevenlabs", "scribe_v1")
if err != nil {
t.Fatalf("GetModel('elevenlabs', 'scribe_v1') error: %v", err)
}
if !scribeV1.SupportsBatch {
t.Error("scribe_v1 should have SupportsBatch=true")
}
if scribeV1.SupportsStreaming {
t.Error("scribe_v1 should have SupportsStreaming=false")
}
if scribeV1.AdapterType != "elevenlabs" {
t.Errorf("scribe_v1 AdapterType=%q, want 'elevenlabs'", scribeV1.AdapterType)
}
if scribeV1.StreamingAdapter != "elevenlabs-streaming" {
t.Errorf("scribe_v1 StreamingAdapter=%q, want 'elevenlabs-streaming'", scribeV1.StreamingAdapter)
}
scribeV2, err := GetModel("elevenlabs", "scribe_v2")
if err != nil {
t.Fatalf("GetModel('elevenlabs', 'scribe_v2') error: %v", err)
}
if !scribeV2.SupportsBatch {
t.Error("scribe_v2 should have SupportsBatch=true")
}
if scribeV2.SupportsStreaming {
t.Error("scribe_v2 should have SupportsStreaming=false")
}
if scribeV2.AdapterType != "elevenlabs" {
t.Errorf("scribe_v2 AdapterType=%q, want 'elevenlabs'", scribeV2.AdapterType)
}
if scribeV2.StreamingAdapter != "elevenlabs-streaming" {
t.Errorf("scribe_v2 StreamingAdapter=%q, want 'elevenlabs-streaming'", scribeV2.StreamingAdapter)
}
scribeV2Realtime, err := GetModel("elevenlabs", "scribe_v2_realtime")
if err != nil {
t.Fatalf("GetModel('elevenlabs', 'scribe_v2_realtime') error: %v", err)
}
if scribeV2Realtime.SupportsBatch {
t.Error("scribe_v2_realtime should have SupportsBatch=false")
}
if !scribeV2Realtime.SupportsStreaming {
t.Error("scribe_v2_realtime should have SupportsStreaming=true")
}
if scribeV2Realtime.AdapterType != "elevenlabs" {
t.Errorf("scribe_v2_realtime AdapterType=%q, want 'elevenlabs'", scribeV2Realtime.AdapterType)
}
if scribeV2Realtime.StreamingAdapter != "elevenlabs-streaming" {
t.Errorf("scribe_v2_realtime StreamingAdapter=%q, want 'elevenlabs-streaming'", scribeV2Realtime.StreamingAdapter)
}
// All models should share the same supported language list
wantLangCount := len(elevenLabsTranscriptionLanguages)
for _, m := range models {
if len(m.SupportedLanguages) != wantLangCount {
t.Errorf("model %q has %d languages, want %d", m.ID, len(m.SupportedLanguages), wantLangCount)
}
}
}
+108
View File
@@ -0,0 +1,108 @@
package provider
import "github.com/leonardotrapani/hyprvoice/internal/models/whisper"
// WhisperCppProvider implements Provider for local whisper.cpp transcription
type WhisperCppProvider struct{}
func (p *WhisperCppProvider) Name() string {
return ProviderWhisperCpp
}
func (p *WhisperCppProvider) RequiresAPIKey() bool {
return false
}
func (p *WhisperCppProvider) ValidateAPIKey(key string) bool {
return true // no API key needed
}
func (p *WhisperCppProvider) APIKeyURL() string {
return ""
}
func (p *WhisperCppProvider) IsLocal() bool {
return true
}
func (p *WhisperCppProvider) Models() []Model {
// https://github.com/ggml-org/whisper.cpp#models
allLangs := whisperTranscriptionLanguages
// https://github.com/ggml-org/whisper.cpp#models
englishOnly := whisperEnglishOnlyLanguages
docsURL := "https://github.com/ggml-org/whisper.cpp#models"
whisperModels := whisper.ListModels()
result := make([]Model, 0, len(whisperModels))
for _, wm := range whisperModels {
var langs []string
if wm.Multilingual {
langs = allLangs
} else {
langs = englishOnly
}
result = append(result, Model{
ID: wm.ID,
Name: wm.Name,
Description: modelDescription(wm),
Type: Transcription,
SupportsBatch: true,
SupportsStreaming: false,
Local: true,
AdapterType: AdapterWhisperCpp,
SupportedLanguages: langs,
Endpoint: nil, // local CLI, no HTTP endpoint
LocalInfo: &LocalModelInfo{
Filename: wm.Filename,
Size: wm.Size,
DownloadURL: whisper.GetDownloadURL(wm.ID),
},
DocsURL: docsURL,
})
}
return result
}
func modelDescription(m whisper.ModelInfo) string {
switch m.ID {
case "tiny.en":
return "Free/offline; fastest but low accuracy, good for weak hardware"
case "base.en":
return "Free/offline; balanced speed and accuracy, recommended start"
case "small.en":
return "Free/offline; better accuracy, needs decent CPU"
case "medium.en":
return "Free/offline; best .en accuracy, needs good CPU/RAM"
case "tiny":
return "Free/offline multilingual; fastest but low accuracy"
case "base":
return "Free/offline multilingual; balanced, recommended start"
case "small":
return "Free/offline multilingual; better accuracy, needs decent CPU"
case "medium":
return "Free/offline multilingual; great accuracy, needs good CPU/RAM"
case "large-v1":
return "Free/offline; high accuracy, needs strong CPU/GPU"
case "large-v2":
return "Free/offline; high accuracy, needs strong CPU/GPU"
case "large-v3":
return "Free/offline; best accuracy available, needs strong hardware"
case "large-v3-turbo":
return "Free/offline; near-best accuracy with better speed"
}
if m.Multilingual {
return "Free/offline multilingual model"
}
return "Free/offline English model"
}
func (p *WhisperCppProvider) DefaultModel(t ModelType) string {
switch t {
case Transcription:
return "base.en"
}
return ""
}
+148
View File
@@ -0,0 +1,148 @@
package provider
import "testing"
func TestWhisperCppProvider_GetProvider(t *testing.T) {
p := GetProvider("whisper-cpp")
if p == nil {
t.Fatal("GetProvider('whisper-cpp') returned nil")
}
if p.Name() != "whisper-cpp" {
t.Errorf("expected name 'whisper-cpp', got '%s'", p.Name())
}
}
func TestWhisperCppProvider_Models(t *testing.T) {
p := &WhisperCppProvider{}
models := p.Models()
// verify we have 12 models
if len(models) != 12 {
t.Errorf("expected 12 models, got %d", len(models))
}
// verify all models have required fields
for _, m := range models {
if !m.Local {
t.Errorf("model %s: expected Local=true", m.ID)
}
if m.LocalInfo == nil {
t.Errorf("model %s: expected LocalInfo to be set", m.ID)
}
if m.AdapterType != "whisper-cpp" {
t.Errorf("model %s: expected AdapterType='whisper-cpp', got '%s'", m.ID, m.AdapterType)
}
if m.Type != Transcription {
t.Errorf("model %s: expected Type=Transcription", m.ID)
}
if m.Endpoint != nil {
t.Errorf("model %s: expected Endpoint=nil for local model", m.ID)
}
}
}
func TestWhisperCppProvider_EnglishOnlyModels(t *testing.T) {
p := &WhisperCppProvider{}
models := p.Models()
englishOnlyIDs := map[string]bool{
"tiny.en": true,
"base.en": true,
"small.en": true,
"medium.en": true,
}
for _, m := range models {
isEnglishOnly := englishOnlyIDs[m.ID]
if isEnglishOnly {
// english-only models should only support 'en'
if len(m.SupportedLanguages) != 1 || m.SupportedLanguages[0] != "en" {
t.Errorf("model %s: expected SupportedLanguages=['en'], got %v", m.ID, m.SupportedLanguages)
}
if m.SupportsLanguage("es") {
t.Errorf("model %s: SupportsLanguage('es') should be false", m.ID)
}
if !m.SupportsLanguage("en") {
t.Errorf("model %s: SupportsLanguage('en') should be true", m.ID)
}
if !m.SupportsLanguage("") {
t.Errorf("model %s: SupportsLanguage('') should be true (auto always supported)", m.ID)
}
}
}
}
func TestWhisperCppProvider_MultilingualModels(t *testing.T) {
p := &WhisperCppProvider{}
models := p.Models()
multilingualIDs := map[string]bool{
"tiny": true,
"base": true,
"small": true,
"medium": true,
"large-v1": true,
"large-v2": true,
"large-v3": true,
"large-v3-turbo": true,
}
for _, m := range models {
isMultilingual := multilingualIDs[m.ID]
if isMultilingual {
if len(m.SupportedLanguages) <= 1 {
t.Errorf("model %s: expected multiple languages, got %d", m.ID, len(m.SupportedLanguages))
}
if !m.SupportsLanguage("es") {
t.Errorf("model %s: SupportsLanguage('es') should be true", m.ID)
}
if !m.SupportsLanguage("en") {
t.Errorf("model %s: SupportsLanguage('en') should be true", m.ID)
}
}
}
}
func TestWhisperCppProvider_RequiresAPIKey(t *testing.T) {
p := &WhisperCppProvider{}
if p.RequiresAPIKey() {
t.Error("RequiresAPIKey() should return false")
}
}
func TestWhisperCppProvider_IsLocal(t *testing.T) {
p := &WhisperCppProvider{}
if !p.IsLocal() {
t.Error("IsLocal() should return true")
}
}
func TestWhisperCppProvider_DefaultModel(t *testing.T) {
p := &WhisperCppProvider{}
if p.DefaultModel(Transcription) != "base.en" {
t.Errorf("expected DefaultModel(Transcription)='base.en', got '%s'", p.DefaultModel(Transcription))
}
if p.DefaultModel(LLM) != "" {
t.Errorf("expected DefaultModel(LLM)='', got '%s'", p.DefaultModel(LLM))
}
}
func TestWhisperCppProvider_LocalInfo(t *testing.T) {
p := &WhisperCppProvider{}
models := p.Models()
for _, m := range models {
if m.LocalInfo.Filename == "" {
t.Errorf("model %s: LocalInfo.Filename should not be empty", m.ID)
}
if m.LocalInfo.Size == "" {
t.Errorf("model %s: LocalInfo.Size should not be empty", m.ID)
}
if m.LocalInfo.DownloadURL == "" {
t.Errorf("model %s: LocalInfo.DownloadURL should not be empty", m.ID)
}
if !m.NeedsDownload() {
t.Errorf("model %s: NeedsDownload() should be true for local model", m.ID)
}
}
}
+18 -11
View File
@@ -29,7 +29,14 @@ type Config struct {
Timeout time.Duration Timeout time.Duration
} }
type Recorder struct { // Recorder interface for audio recording
type Recorder interface {
Start(ctx context.Context) (<-chan AudioFrame, <-chan error, error)
Stop()
IsRecording() bool
}
type recorder struct {
config Config config Config
recording atomic.Bool recording atomic.Bool
@@ -40,15 +47,15 @@ type Recorder struct {
wg sync.WaitGroup wg sync.WaitGroup
} }
func NewRecorder(config Config) *Recorder { func NewRecorder(config Config) Recorder {
return &Recorder{config: config} return &recorder{config: config}
} }
func (r *Recorder) IsRecording() bool { func (r *recorder) IsRecording() bool {
return r.recording.Load() return r.recording.Load()
} }
func (r *Recorder) Start(ctx context.Context) (<-chan AudioFrame, <-chan error, error) { func (r *recorder) Start(ctx context.Context) (<-chan AudioFrame, <-chan error, error) {
if r.recording.Load() { if r.recording.Load() {
return nil, nil, fmt.Errorf("already recording") return nil, nil, fmt.Errorf("already recording")
} }
@@ -77,7 +84,7 @@ func (r *Recorder) Start(ctx context.Context) (<-chan AudioFrame, <-chan error,
return frameCh, errCh, nil return frameCh, errCh, nil
} }
func (r *Recorder) Stop() { func (r *recorder) Stop() {
if !r.recording.Load() { if !r.recording.Load() {
return return
} }
@@ -87,7 +94,7 @@ func (r *Recorder) Stop() {
r.wg.Wait() r.wg.Wait()
} }
func (r *Recorder) captureLoop(ctx context.Context, frameCh chan<- AudioFrame, errCh chan<- error) { func (r *recorder) captureLoop(ctx context.Context, frameCh chan<- AudioFrame, errCh chan<- error) {
defer func() { defer func() {
close(frameCh) close(frameCh)
close(errCh) close(errCh)
@@ -178,7 +185,7 @@ func (r *Recorder) captureLoop(ctx context.Context, frameCh chan<- AudioFrame, e
} }
} }
func (r *Recorder) requestCancel() { func (r *recorder) requestCancel() {
r.mu.Lock() r.mu.Lock()
cancel := r.cancel cancel := r.cancel
r.mu.Unlock() r.mu.Unlock()
@@ -187,7 +194,7 @@ func (r *Recorder) requestCancel() {
} }
} }
func (r *Recorder) emitErr(errCh chan<- error, err error) { func (r *recorder) emitErr(errCh chan<- error, err error) {
select { select {
case errCh <- err: case errCh <- err:
default: default:
@@ -195,7 +202,7 @@ func (r *Recorder) emitErr(errCh chan<- error, err error) {
log.Printf("Recording error: %v", err) log.Printf("Recording error: %v", err)
} }
func (r *Recorder) buildPwRecordArgs() []string { func (r *recorder) buildPwRecordArgs() []string {
args := []string{ args := []string{
"--format", r.config.Format, "--format", r.config.Format,
"--rate", strconv.Itoa(r.config.SampleRate), "--rate", strconv.Itoa(r.config.SampleRate),
@@ -222,7 +229,7 @@ func CheckPipeWireAvailable(ctx context.Context) error {
return nil return nil
} }
func (r *Recorder) validateConfig() error { func (r *recorder) validateConfig() error {
if r.config.SampleRate <= 0 { if r.config.SampleRate <= 0 {
return fmt.Errorf("invalid SampleRate: %d", r.config.SampleRate) return fmt.Errorf("invalid SampleRate: %d", r.config.SampleRate)
} }
+7 -87
View File
@@ -24,8 +24,9 @@ func TestNewRecorder(t *testing.T) {
return return
} }
if recorder.config.SampleRate != config.SampleRate { // verify recorder implements the interface
t.Errorf("SampleRate not set correctly: got %d, want %d", recorder.config.SampleRate, config.SampleRate) if !recorder.IsRecording() {
t.Logf("Recorder created successfully, not recording initially")
} }
} }
@@ -54,19 +55,6 @@ func TestRecorder_ValidateConfig(t *testing.T) {
config Config config Config
wantErr bool wantErr bool
}{ }{
{
name: "valid config",
config: Config{
SampleRate: 16000,
Channels: 1,
Format: "s16",
BufferSize: 8192,
Device: "",
ChannelBufferSize: 30,
Timeout: 5 * time.Minute,
},
wantErr: false,
},
{ {
name: "invalid sample rate", name: "invalid sample rate",
config: Config{ config: Config{
@@ -127,85 +115,17 @@ func TestRecorder_ValidateConfig(t *testing.T) {
}, },
wantErr: true, wantErr: true,
}, },
{
name: "invalid timeout",
config: Config{
SampleRate: 16000,
Channels: 1,
Format: "s16",
BufferSize: 8192,
ChannelBufferSize: 30,
Timeout: 0,
},
wantErr: false, // Timeout validation is not implemented in validateConfig
},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
recorder := NewRecorder(tt.config) recorder := NewRecorder(tt.config)
err := recorder.validateConfig() ctx := context.Background()
_, _, err := recorder.Start(ctx)
if (err != nil) != tt.wantErr { if (err != nil) != tt.wantErr {
t.Errorf("validateConfig() error = %v, wantErr %v", err, tt.wantErr) t.Errorf("Start() with invalid config error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestRecorder_BuildPwRecordArgs(t *testing.T) {
tests := []struct {
name string
config Config
expected []string
}{
{
name: "default config",
config: Config{
SampleRate: 16000,
Channels: 1,
Format: "s16",
Device: "",
},
expected: []string{
"--format", "s16",
"--rate", "16000",
"--channels", "1",
"-",
},
},
{
name: "with device",
config: Config{
SampleRate: 44100,
Channels: 2,
Format: "s32",
Device: "hw:0",
},
expected: []string{
"--format", "s32",
"--rate", "44100",
"--channels", "2",
"-",
"--target", "hw:0",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
recorder := NewRecorder(tt.config)
args := recorder.buildPwRecordArgs()
if len(args) != len(tt.expected) {
t.Errorf("buildPwRecordArgs() returned %d args, want %d", len(args), len(tt.expected))
return
}
for i, arg := range args {
if arg != tt.expected[i] {
t.Errorf("buildPwRecordArgs()[%d] = %q, want %q", i, arg, tt.expected[i])
}
} }
recorder.Stop()
}) })
} }
} }
+217 -3
View File
@@ -5,11 +5,16 @@ import (
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"sync"
"sync/atomic"
"testing" "testing"
"time" "time"
"github.com/leonardotrapani/hyprvoice/internal/config" "github.com/leonardotrapani/hyprvoice/internal/config"
"github.com/leonardotrapani/hyprvoice/internal/injection"
"github.com/leonardotrapani/hyprvoice/internal/llm"
"github.com/leonardotrapani/hyprvoice/internal/recording" "github.com/leonardotrapani/hyprvoice/internal/recording"
"github.com/leonardotrapani/hyprvoice/internal/transcriber"
) )
// TestConfig returns a valid configuration for testing // TestConfig returns a valid configuration for testing
@@ -26,10 +31,12 @@ func TestConfig() *config.Config {
}, },
Transcription: config.TranscriptionConfig{ Transcription: config.TranscriptionConfig{
Provider: "openai", Provider: "openai",
APIKey: "test-api-key",
Language: "", Language: "",
Model: "whisper-1", Model: "whisper-1",
}, },
Providers: map[string]config.ProviderConfig{
"openai": {APIKey: "test-api-key"},
},
Injection: config.InjectionConfig{ Injection: config.InjectionConfig{
Backends: []string{"ydotool", "wtype", "clipboard"}, Backends: []string{"ydotool", "wtype", "clipboard"},
YdotoolTimeout: 5 * time.Second, YdotoolTimeout: 5 * time.Second,
@@ -56,7 +63,6 @@ func TestConfigWithInvalidValues() *config.Config {
}, },
Transcription: config.TranscriptionConfig{ Transcription: config.TranscriptionConfig{
Provider: "", // Invalid Provider: "", // Invalid
APIKey: "", // Invalid
Model: "", // Invalid Model: "", // Invalid
}, },
Injection: config.InjectionConfig{ Injection: config.InjectionConfig{
@@ -122,7 +128,7 @@ func MockAudioFrame(data []byte) recording.AudioFrame {
} }
} }
// MockTranscriberAdapter implements transcriber.TranscriptionAdapter for testing // MockTranscriberAdapter implements transcriber.BatchAdapter for testing
type MockTranscriberAdapter struct { type MockTranscriberAdapter struct {
TranscribeFunc func(ctx context.Context, audioData []byte) (string, error) TranscribeFunc func(ctx context.Context, audioData []byte) (string, error)
} }
@@ -180,3 +186,211 @@ func CaptureOutput(t *testing.T, fn func()) string {
out, _ := io.ReadAll(r) out, _ := io.ReadAll(r)
return string(out) return string(out)
} }
// MockRecorder implements recording.Recorder for testing
type MockRecorder struct {
Frames []recording.AudioFrame
StartError error
mu sync.Mutex
recording atomic.Bool
stopCh chan struct{}
}
func NewMockRecorder() *MockRecorder {
return &MockRecorder{
Frames: []recording.AudioFrame{MockAudioFrame(nil)},
}
}
func (m *MockRecorder) Start(ctx context.Context) (<-chan recording.AudioFrame, <-chan error, error) {
if m.StartError != nil {
return nil, nil, m.StartError
}
m.mu.Lock()
m.stopCh = make(chan struct{})
m.mu.Unlock()
m.recording.Store(true)
frameCh := make(chan recording.AudioFrame, len(m.Frames)+1)
errCh := make(chan error, 1)
go func() {
defer close(frameCh)
defer close(errCh)
for _, frame := range m.Frames {
select {
case <-ctx.Done():
return
case <-m.stopCh:
return
case frameCh <- frame:
}
}
// keep channel open until stopped
select {
case <-ctx.Done():
case <-m.stopCh:
}
}()
return frameCh, errCh, nil
}
func (m *MockRecorder) Stop() {
if !m.recording.Load() {
return
}
m.recording.Store(false)
m.mu.Lock()
if m.stopCh != nil {
close(m.stopCh)
m.stopCh = nil
}
m.mu.Unlock()
}
func (m *MockRecorder) IsRecording() bool {
return m.recording.Load()
}
// MockTranscriber implements transcriber.Transcriber for testing
type MockTranscriber struct {
Transcription string
StartError error
StopError error
GetError error
mu sync.Mutex
started bool
}
func NewMockTranscriber(transcription string) *MockTranscriber {
return &MockTranscriber{Transcription: transcription}
}
func (m *MockTranscriber) Start(ctx context.Context, frameCh <-chan recording.AudioFrame) (<-chan error, error) {
if m.StartError != nil {
return nil, m.StartError
}
m.mu.Lock()
m.started = true
m.mu.Unlock()
errCh := make(chan error, 1)
// drain frames in background
go func() {
defer close(errCh)
for range frameCh {
}
}()
return errCh, nil
}
func (m *MockTranscriber) Stop(ctx context.Context) error {
m.mu.Lock()
m.started = false
m.mu.Unlock()
return m.StopError
}
func (m *MockTranscriber) GetFinalTranscription() (string, error) {
if m.GetError != nil {
return "", m.GetError
}
return m.Transcription, nil
}
// MockInjector implements injection.Injector for testing
type MockInjector struct {
InjectedTexts []string
InjectError error
mu sync.Mutex
}
func NewMockInjector() *MockInjector {
return &MockInjector{}
}
func (m *MockInjector) Inject(ctx context.Context, text string) error {
if m.InjectError != nil {
return m.InjectError
}
m.mu.Lock()
m.InjectedTexts = append(m.InjectedTexts, text)
m.mu.Unlock()
return nil
}
func (m *MockInjector) GetInjectedTexts() []string {
m.mu.Lock()
defer m.mu.Unlock()
result := make([]string, len(m.InjectedTexts))
copy(result, m.InjectedTexts)
return result
}
// MockLLMAdapter implements llm.Adapter for testing
type MockLLMAdapter struct {
ProcessedText string
ProcessError error
mu sync.Mutex
ProcessCalled bool
InputText string
}
func NewMockLLMAdapter(processedText string) *MockLLMAdapter {
return &MockLLMAdapter{ProcessedText: processedText}
}
func (m *MockLLMAdapter) Process(ctx context.Context, text string) (string, error) {
m.mu.Lock()
m.ProcessCalled = true
m.InputText = text
m.mu.Unlock()
if m.ProcessError != nil {
return "", m.ProcessError
}
return m.ProcessedText, nil
}
// Factory helpers for pipeline testing
// MockRecorderFactory returns a factory that creates the given mock recorder
func MockRecorderFactory(mock *MockRecorder) func(cfg recording.Config) recording.Recorder {
return func(cfg recording.Config) recording.Recorder {
return mock
}
}
// MockTranscriberFactory returns a factory that creates the given mock transcriber
func MockTranscriberFactory(mock *MockTranscriber) func(cfg transcriber.Config) (transcriber.Transcriber, error) {
return func(cfg transcriber.Config) (transcriber.Transcriber, error) {
return mock, nil
}
}
// MockInjectorFactory returns a factory that creates the given mock injector
func MockInjectorFactory(mock *MockInjector) func(cfg injection.Config) injection.Injector {
return func(cfg injection.Config) injection.Injector {
return mock
}
}
// MockLLMAdapterFactory returns a factory that creates the given mock LLM adapter
func MockLLMAdapterFactory(mock *MockLLMAdapter) func(cfg llm.Config) (llm.Adapter, error) {
return func(cfg llm.Config) (llm.Adapter, error) {
return mock, nil
}
}
+497
View File
@@ -0,0 +1,497 @@
package transcriber
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/leonardotrapani/hyprvoice/internal/provider"
)
// DeepgramAdapter implements StreamingAdapter for Deepgram real-time transcription
type DeepgramAdapter struct {
endpoint *provider.EndpointConfig
apiKey string
model string
language string
keywords []string
conn *websocket.Conn
resultsCh chan TranscriptionResult
mu sync.Mutex
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
started bool
// reconnection config
maxRetries int
retryDelays []time.Duration
// finalization signaling
finalizeDone chan struct{}
finalizing bool // true when Finalize() has been called
}
// deepgramCloseStream message to signal end of audio
type deepgramCloseStream struct {
Type string `json:"type"`
}
// Deepgram WebSocket response types (incoming)
type deepgramWSResponse struct {
Type string `json:"type"`
Channel *deepgramChannel `json:"channel,omitempty"`
Metadata *deepgramMetadata `json:"metadata,omitempty"`
Error *deepgramError `json:"error,omitempty"`
ChannelIdx []int `json:"channel_index,omitempty"`
Duration float64 `json:"duration,omitempty"`
Start float64 `json:"start,omitempty"`
IsFinal bool `json:"is_final,omitempty"`
SpeechFinal bool `json:"speech_final,omitempty"`
}
type deepgramChannel struct {
Alternatives []deepgramAlternative `json:"alternatives,omitempty"`
}
type deepgramAlternative struct {
Transcript string `json:"transcript"`
Confidence float64 `json:"confidence"`
}
type deepgramMetadata struct {
RequestID string `json:"request_id"`
ModelInfo struct {
Name string `json:"name"`
Version string `json:"version"`
} `json:"model_info"`
}
type deepgramError struct {
Type string `json:"type"`
Message string `json:"message"`
Description string `json:"description,omitempty"`
}
// NewDeepgramAdapter creates a new streaming adapter for Deepgram
// endpoint: the WebSocket endpoint config (e.g., wss://api.deepgram.com, /v1/listen)
// apiKey: Deepgram API key
// model: model ID (e.g., "nova-3")
// lang: provider language code
func NewDeepgramAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string, keywords []string) *DeepgramAdapter {
return &DeepgramAdapter{
endpoint: endpoint,
apiKey: apiKey,
model: model,
language: lang,
keywords: keywords,
resultsCh: make(chan TranscriptionResult, 100),
maxRetries: 3,
retryDelays: defaultRetryDelays,
finalizeDone: make(chan struct{}, 1),
}
}
// Start initiates the WebSocket connection to Deepgram
func (a *DeepgramAdapter) Start(ctx context.Context, lang string) error {
a.mu.Lock()
defer a.mu.Unlock()
if a.started {
return fmt.Errorf("adapter already started")
}
// use lang param if provided, otherwise use constructor lang
if lang != "" {
a.language = lang
}
// create cancelable context
a.ctx, a.cancel = context.WithCancel(ctx)
// connect to WebSocket
if err := a.connectLocked(); err != nil {
return err
}
a.started = true
// start reader goroutine
a.wg.Add(1)
go a.readLoop()
log.Printf("deepgram: connected, model=%s, language=%s", a.model, a.language)
return nil
}
// connectLocked establishes WebSocket connection. Must be called with mu held.
func (a *DeepgramAdapter) connectLocked() error {
wsURL, err := a.buildURL()
if err != nil {
return fmt.Errorf("build websocket url: %w", err)
}
headers := http.Header{}
headers.Set("Authorization", "Token "+a.apiKey)
log.Printf("deepgram: connecting to %s", wsURL)
conn, resp, err := websocket.DefaultDialer.DialContext(a.ctx, wsURL, headers)
if err != nil {
if resp != nil {
log.Printf("deepgram: dial failed with status %d", resp.StatusCode)
}
return fmt.Errorf("websocket dial: %w", err)
}
a.conn = conn
return nil
}
// reconnect attempts to re-establish the WebSocket connection with exponential backoff.
// Returns true if reconnection succeeded.
func (a *DeepgramAdapter) reconnect() bool {
for attempt := 0; attempt < a.maxRetries; attempt++ {
// check if context cancelled
select {
case <-a.ctx.Done():
return false
default:
}
// wait before retry (skip wait on first attempt)
if attempt > 0 {
delay := a.retryDelays[attempt-1]
if attempt-1 >= len(a.retryDelays) {
delay = a.retryDelays[len(a.retryDelays)-1]
}
log.Printf("deepgram: reconnect attempt %d/%d after %v", attempt+1, a.maxRetries, delay)
select {
case <-a.ctx.Done():
return false
case <-time.After(delay):
}
} else {
log.Printf("deepgram: reconnect attempt %d/%d", attempt+1, a.maxRetries)
}
a.mu.Lock()
// close old connection if exists
if a.conn != nil {
a.conn.Close()
a.conn = nil
}
err := a.connectLocked()
a.mu.Unlock()
if err == nil {
log.Printf("deepgram: reconnected successfully")
// notify caller of brief interruption
select {
case a.resultsCh <- TranscriptionResult{Error: fmt.Errorf("connection interrupted, reconnected"), IsFinal: false}:
default:
}
return true
}
log.Printf("deepgram: reconnect failed: %v", err)
}
return false
}
// buildURL constructs the WebSocket URL with query parameters
func (a *DeepgramAdapter) buildURL() (string, error) {
// parse base URL and path
baseURL := a.endpoint.BaseURL + a.endpoint.Path
u, err := url.Parse(baseURL)
if err != nil {
return "", fmt.Errorf("parse base url: %w", err)
}
// add query parameters
q := u.Query()
q.Set("model", a.model)
q.Set("encoding", "linear16") // 16-bit linear PCM
q.Set("sample_rate", "16000") // 16kHz
q.Set("channels", "1") // mono
// enable interim results
q.Set("interim_results", "true")
// enable smart formatting for better output
q.Set("smart_format", "true")
// enable punctuation
q.Set("punctuate", "true")
// add language if specified
lang := normalizeDeepgramLanguage(a.language)
if lang != "" {
q.Set("language", lang)
}
// nova-3 uses "keyterm" (singular), others use "keywords" (plural)
if len(a.keywords) > 0 && !strings.HasPrefix(a.model, "nova-3") && !strings.HasPrefix(a.model, "flux") {
q.Set("keywords", strings.Join(a.keywords, ","))
}
u.RawQuery = q.Encode()
return u.String(), nil
}
// readLoop reads messages from the WebSocket and sends results to the channel
func (a *DeepgramAdapter) readLoop() {
defer a.wg.Done()
defer close(a.resultsCh)
for {
select {
case <-a.ctx.Done():
return
default:
}
a.mu.Lock()
conn := a.conn
a.mu.Unlock()
if conn == nil {
// no connection, try to reconnect
if !a.reconnect() {
a.resultsCh <- TranscriptionResult{Error: fmt.Errorf("connection lost, reconnection failed after %d attempts", a.maxRetries)}
return
}
continue
}
_, message, err := conn.ReadMessage()
if err != nil {
// check if context was cancelled (normal shutdown)
select {
case <-a.ctx.Done():
return
default:
}
// check if we're finalizing - normal close after finalize is expected
a.mu.Lock()
finalizing := a.finalizing
a.mu.Unlock()
if finalizing {
// expected close after finalization, signal done and exit gracefully
select {
case a.finalizeDone <- struct{}{}:
default:
}
return
}
// attempt reconnection
log.Printf("deepgram: read error: %v, attempting reconnection", err)
if !a.reconnect() {
a.resultsCh <- TranscriptionResult{Error: fmt.Errorf("websocket read: %w, reconnection failed", err)}
return
}
continue
}
// parse message
var resp deepgramWSResponse
if err := json.Unmarshal(message, &resp); err != nil {
log.Printf("deepgram: parse error: %v", err)
continue
}
// handle different message types
switch resp.Type {
case "Metadata":
if resp.Metadata != nil {
log.Printf("deepgram: session started, request_id=%s, model=%s",
resp.Metadata.RequestID, resp.Metadata.ModelInfo.Name)
}
case "Results":
// transcription result
if resp.Channel != nil && len(resp.Channel.Alternatives) > 0 {
transcript := resp.Channel.Alternatives[0].Transcript
if transcript != "" {
isFinal := resp.IsFinal || resp.SpeechFinal
if isFinal {
log.Printf("deepgram: final: %q", transcript)
// signal finalization (non-blocking)
select {
case a.finalizeDone <- struct{}{}:
default:
}
}
a.resultsCh <- TranscriptionResult{Text: transcript, IsFinal: isFinal}
}
}
case "Error":
if resp.Error != nil {
errMsg := resp.Error.Message
if resp.Error.Description != "" {
errMsg = fmt.Sprintf("%s: %s", errMsg, resp.Error.Description)
}
log.Printf("deepgram: error: %s", errMsg)
a.resultsCh <- TranscriptionResult{Error: fmt.Errorf("deepgram: %s", errMsg)}
}
case "UtteranceEnd":
log.Printf("deepgram: utterance end detected")
case "SpeechStarted":
log.Printf("deepgram: speech started")
default:
log.Printf("deepgram: unknown message type: %s", resp.Type)
}
}
}
// SendChunk sends audio data to the WebSocket
// Deepgram expects raw binary audio data, not base64 encoded
func (a *DeepgramAdapter) SendChunk(audio []byte) error {
a.mu.Lock()
if !a.started {
a.mu.Unlock()
return fmt.Errorf("adapter not started")
}
conn := a.conn
a.mu.Unlock()
// check context
select {
case <-a.ctx.Done():
return a.ctx.Err()
default:
}
if conn == nil {
return fmt.Errorf("no connection")
}
// send raw binary audio (not base64)
a.mu.Lock()
err := a.conn.WriteMessage(websocket.BinaryMessage, audio)
a.mu.Unlock()
if err != nil {
// attempt reconnection
log.Printf("deepgram: write error: %v, attempting reconnection", err)
if a.reconnect() {
// retry the chunk after reconnection
a.mu.Lock()
err = a.conn.WriteMessage(websocket.BinaryMessage, audio)
a.mu.Unlock()
if err == nil {
return nil
}
}
return fmt.Errorf("websocket write: %w", err)
}
return nil
}
// Results returns the channel for receiving transcription results
func (a *DeepgramAdapter) Results() <-chan TranscriptionResult {
return a.resultsCh
}
// Finalize sends a CloseStream message to signal end of audio and waits for final results
func (a *DeepgramAdapter) Finalize(ctx context.Context) error {
a.mu.Lock()
if !a.started {
a.mu.Unlock()
return nil
}
conn := a.conn
a.mu.Unlock()
if conn == nil {
return nil
}
// drain any previous finalize signals
select {
case <-a.finalizeDone:
default:
}
// mark as finalizing to prevent reconnection attempts on normal close
a.mu.Lock()
a.finalizing = true
a.mu.Unlock()
// send CloseStream message
msg := deepgramCloseStream{Type: "CloseStream"}
a.mu.Lock()
err := a.conn.WriteJSON(msg)
a.mu.Unlock()
if err != nil {
log.Printf("deepgram: finalize write error: %v", err)
return fmt.Errorf("finalize write: %w", err)
}
log.Printf("deepgram: sent CloseStream, waiting for final transcript")
// wait for final result or timeout
select {
case <-a.finalizeDone:
log.Printf("deepgram: finalize complete")
return nil
case <-ctx.Done():
log.Printf("deepgram: finalize timeout")
return ctx.Err()
case <-a.ctx.Done():
return a.ctx.Err()
}
}
// Close gracefully closes the WebSocket connection
func (a *DeepgramAdapter) Close() error {
a.mu.Lock()
if !a.started {
a.mu.Unlock()
return nil
}
// mark as finalizing to prevent reconnection attempts
a.finalizing = true
// cancel context first to signal reader to stop
if a.cancel != nil {
a.cancel()
}
// get conn ref while holding lock
conn := a.conn
a.started = false
a.mu.Unlock()
// close websocket outside of lock (readLoop may be blocked on read)
if conn != nil {
// send close frame (best effort)
_ = conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
conn.Close()
}
// wait for reader to finish
a.wg.Wait()
log.Printf("deepgram: closed")
return nil
}
@@ -0,0 +1,143 @@
package transcriber
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/leonardotrapani/hyprvoice/internal/provider"
)
// DeepgramBatchAdapter implements BatchAdapter for Deepgram pre-recorded transcription
type DeepgramBatchAdapter struct {
endpoint *provider.EndpointConfig
apiKey string
model string
language string
keywords []string
}
// deepgramBatchResponse is the response from the pre-recorded API
type deepgramBatchResponse struct {
Results *deepgramBatchResults `json:"results,omitempty"`
Error *deepgramError `json:"error,omitempty"`
}
type deepgramBatchResults struct {
Channels []deepgramBatchChannel `json:"channels,omitempty"`
}
type deepgramBatchChannel struct {
Alternatives []deepgramAlternative `json:"alternatives,omitempty"`
}
// NewDeepgramBatchAdapter creates a new batch adapter for Deepgram
func NewDeepgramBatchAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string, keywords []string) *DeepgramBatchAdapter {
return &DeepgramBatchAdapter{
endpoint: endpoint,
apiKey: apiKey,
model: model,
language: lang,
keywords: keywords,
}
}
// Transcribe sends audio data to Deepgram's pre-recorded API
func (a *DeepgramBatchAdapter) Transcribe(ctx context.Context, audioData []byte) (string, error) {
if len(audioData) == 0 {
return "", nil
}
// convert raw PCM to WAV format
wavData, err := convertToWAV(audioData)
if err != nil {
return "", fmt.Errorf("convert to WAV: %w", err)
}
// build URL with query parameters
apiURL, err := a.buildURL()
if err != nil {
return "", fmt.Errorf("build url: %w", err)
}
// create request with WAV data as body
req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(wavData))
if err != nil {
return "", fmt.Errorf("create request: %w", err)
}
// set headers
req.Header.Set("Authorization", "Token "+a.apiKey)
req.Header.Set("Content-Type", "audio/wav")
// send request
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("http request: %w", err)
}
defer resp.Body.Close()
// read response
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("deepgram api error (status %d): %s", resp.StatusCode, string(body))
}
// parse response
var result deepgramBatchResponse
if err := json.Unmarshal(body, &result); err != nil {
return "", fmt.Errorf("parse response: %w", err)
}
if result.Error != nil {
return "", fmt.Errorf("deepgram error: %s", result.Error.Message)
}
// extract transcript
if result.Results == nil || len(result.Results.Channels) == 0 {
return "", nil
}
if len(result.Results.Channels[0].Alternatives) == 0 {
return "", nil
}
return result.Results.Channels[0].Alternatives[0].Transcript, nil
}
// buildURL constructs the API URL with query parameters
func (a *DeepgramBatchAdapter) buildURL() (string, error) {
baseURL := a.endpoint.BaseURL + a.endpoint.Path
u, err := url.Parse(baseURL)
if err != nil {
return "", fmt.Errorf("parse base url: %w", err)
}
q := u.Query()
q.Set("model", a.model)
q.Set("smart_format", "true")
q.Set("punctuate", "true")
// add language if specified
lang := normalizeDeepgramLanguage(a.language)
if lang != "" {
q.Set("language", lang)
}
// nova-3 uses "keyterm" (singular), others use "keywords" (plural)
if len(a.keywords) > 0 && !strings.HasPrefix(a.model, "nova-3") && !strings.HasPrefix(a.model, "flux") {
q.Set("keywords", strings.Join(a.keywords, ","))
}
u.RawQuery = q.Encode()
return u.String(), nil
}
@@ -0,0 +1,434 @@
package transcriber
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/gorilla/websocket"
"github.com/leonardotrapani/hyprvoice/internal/provider"
)
func TestDeepgramAdapter_ImplementsStreamingAdapter(t *testing.T) {
var _ StreamingAdapter = (*DeepgramAdapter)(nil)
}
func TestDeepgramAdapter_Creation(t *testing.T) {
endpoint := &provider.EndpointConfig{
BaseURL: "wss://api.deepgram.com",
Path: "/v1/listen",
}
adapter := NewDeepgramAdapter(endpoint, "test-api-key", "nova-3", "en", nil)
if adapter.apiKey != "test-api-key" {
t.Errorf("apiKey = %q, want %q", adapter.apiKey, "test-api-key")
}
if adapter.model != "nova-3" {
t.Errorf("model = %q, want %q", adapter.model, "nova-3")
}
if adapter.language != "en" {
t.Errorf("language = %q, want %q", adapter.language, "en")
}
if adapter.maxRetries != 3 {
t.Errorf("maxRetries = %d, want %d", adapter.maxRetries, 3)
}
}
func TestDeepgramAdapter_BuildURL(t *testing.T) {
tests := []struct {
name string
model string
language string
wantURL []string // URL must contain all these substrings
}{
{
name: "english",
model: "nova-3",
language: "en",
wantURL: []string{"model=nova-3", "language=en-US", "encoding=linear16", "sample_rate=16000"},
},
{
name: "spanish",
model: "nova-2",
language: "es",
wantURL: []string{"model=nova-2", "language=es", "encoding=linear16"},
},
{
name: "auto-detect",
model: "nova-3",
language: "",
wantURL: []string{"model=nova-3", "encoding=linear16"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
endpoint := &provider.EndpointConfig{
BaseURL: "wss://api.deepgram.com",
Path: "/v1/listen",
}
adapter := NewDeepgramAdapter(endpoint, "test-key", tt.model, tt.language, nil)
url, err := adapter.buildURL()
if err != nil {
t.Fatalf("buildURL() error = %v", err)
}
for _, want := range tt.wantURL {
if !strings.Contains(url, want) {
t.Errorf("buildURL() = %q, want to contain %q", url, want)
}
}
})
}
}
func TestDeepgramAdapter_SendChunkNotStarted(t *testing.T) {
endpoint := &provider.EndpointConfig{
BaseURL: "wss://api.deepgram.com",
Path: "/v1/listen",
}
adapter := NewDeepgramAdapter(endpoint, "test-key", "nova-3", "en", nil)
err := adapter.SendChunk([]byte("audio data"))
if err == nil {
t.Error("SendChunk() should return error when adapter not started")
}
if !strings.Contains(err.Error(), "not started") {
t.Errorf("error should mention 'not started', got: %v", err)
}
}
func TestDeepgramAdapter_CloseNotStarted(t *testing.T) {
endpoint := &provider.EndpointConfig{
BaseURL: "wss://api.deepgram.com",
Path: "/v1/listen",
}
adapter := NewDeepgramAdapter(endpoint, "test-key", "nova-3", "en", nil)
// closing not-started adapter should not error
err := adapter.Close()
if err != nil {
t.Errorf("Close() error = %v, want nil", err)
}
}
// mockDeepgramServer creates a mock WebSocket server for testing
func mockDeepgramServer(t *testing.T, handler func(*websocket.Conn)) *httptest.Server {
upgrader := websocket.Upgrader{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// verify auth header
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Token ") {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
t.Logf("upgrade error: %v", err)
return
}
defer conn.Close()
handler(conn)
}))
return server
}
func TestDeepgramAdapter_StartAndClose(t *testing.T) {
server := mockDeepgramServer(t, func(conn *websocket.Conn) {
// send metadata response
metadata := deepgramWSResponse{
Type: "Metadata",
Metadata: &deepgramMetadata{
RequestID: "test-123",
},
}
metadata.Metadata.ModelInfo.Name = "nova-3"
if err := conn.WriteJSON(metadata); err != nil {
t.Logf("write metadata error: %v", err)
return
}
// wait for close
for {
_, _, err := conn.ReadMessage()
if err != nil {
break
}
}
})
defer server.Close()
// convert http URL to ws URL
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
endpoint := &provider.EndpointConfig{
BaseURL: wsURL,
Path: "",
}
adapter := NewDeepgramAdapter(endpoint, "test-api-key", "nova-3", "en", nil)
ctx := context.Background()
if err := adapter.Start(ctx, ""); err != nil {
t.Fatalf("Start() error = %v", err)
}
// verify can't start twice
if err := adapter.Start(ctx, ""); err == nil {
t.Error("Start() should return error when already started")
}
// close
if err := adapter.Close(); err != nil {
t.Errorf("Close() error = %v", err)
}
}
func TestDeepgramAdapter_ReceivesResults(t *testing.T) {
var wg sync.WaitGroup
wg.Add(1)
server := mockDeepgramServer(t, func(conn *websocket.Conn) {
defer wg.Done()
// send metadata
metadata := deepgramWSResponse{Type: "Metadata", Metadata: &deepgramMetadata{RequestID: "test-123"}}
_ = conn.WriteJSON(metadata)
// send interim result
interim := deepgramWSResponse{
Type: "Results",
IsFinal: false,
Channel: &deepgramChannel{
Alternatives: []deepgramAlternative{{Transcript: "hello", Confidence: 0.95}},
},
}
_ = conn.WriteJSON(interim)
// send final result
final := deepgramWSResponse{
Type: "Results",
IsFinal: true,
Channel: &deepgramChannel{
Alternatives: []deepgramAlternative{{Transcript: "hello world", Confidence: 0.98}},
},
}
_ = conn.WriteJSON(final)
// wait briefly then close
time.Sleep(50 * time.Millisecond)
})
defer server.Close()
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
endpoint := &provider.EndpointConfig{BaseURL: wsURL, Path: ""}
adapter := NewDeepgramAdapter(endpoint, "test-api-key", "nova-3", "en", nil)
ctx := context.Background()
if err := adapter.Start(ctx, ""); err != nil {
t.Fatalf("Start() error = %v", err)
}
// collect results
var results []TranscriptionResult
timeout := time.After(2 * time.Second)
loop:
for {
select {
case result, ok := <-adapter.Results():
if !ok {
break loop
}
results = append(results, result)
if result.IsFinal {
break loop
}
case <-timeout:
t.Fatal("timeout waiting for results")
}
}
adapter.Close()
wg.Wait()
// verify results
if len(results) < 2 {
t.Fatalf("expected at least 2 results, got %d", len(results))
}
// check interim
if results[0].Text != "hello" || results[0].IsFinal {
t.Errorf("interim result = %+v, want Text='hello', IsFinal=false", results[0])
}
// check final
found := false
for _, r := range results {
if r.Text == "hello world" && r.IsFinal {
found = true
break
}
}
if !found {
t.Errorf("did not find expected final result 'hello world'")
}
}
func TestDeepgramAdapter_SendsRawBinaryAudio(t *testing.T) {
receivedAudio := make(chan []byte, 1)
server := mockDeepgramServer(t, func(conn *websocket.Conn) {
// send metadata first
metadata := deepgramWSResponse{Type: "Metadata", Metadata: &deepgramMetadata{RequestID: "test-123"}}
_ = conn.WriteJSON(metadata)
// read audio chunk
msgType, data, err := conn.ReadMessage()
if err != nil {
return
}
if msgType != websocket.BinaryMessage {
t.Errorf("expected binary message, got %d", msgType)
}
receivedAudio <- data
// keep reading until close
for {
_, _, err := conn.ReadMessage()
if err != nil {
break
}
}
})
defer server.Close()
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
endpoint := &provider.EndpointConfig{BaseURL: wsURL, Path: ""}
adapter := NewDeepgramAdapter(endpoint, "test-api-key", "nova-3", "en", nil)
ctx := context.Background()
if err := adapter.Start(ctx, ""); err != nil {
t.Fatalf("Start() error = %v", err)
}
// send audio chunk
testAudio := []byte{0x01, 0x02, 0x03, 0x04}
if err := adapter.SendChunk(testAudio); err != nil {
t.Errorf("SendChunk() error = %v", err)
}
// verify audio was received
select {
case audio := <-receivedAudio:
if string(audio) != string(testAudio) {
t.Errorf("received audio = %v, want %v", audio, testAudio)
}
case <-time.After(time.Second):
t.Error("timeout waiting for audio")
}
adapter.Close()
}
func TestDeepgramAdapter_HandlesError(t *testing.T) {
server := mockDeepgramServer(t, func(conn *websocket.Conn) {
// send error
errResp := deepgramWSResponse{
Type: "Error",
Error: &deepgramError{
Type: "AuthError",
Message: "Invalid API key",
},
}
data, _ := json.Marshal(errResp)
_ = conn.WriteMessage(websocket.TextMessage, data)
time.Sleep(50 * time.Millisecond)
})
defer server.Close()
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
endpoint := &provider.EndpointConfig{BaseURL: wsURL, Path: ""}
adapter := NewDeepgramAdapter(endpoint, "test-api-key", "nova-3", "en", nil)
ctx := context.Background()
if err := adapter.Start(ctx, ""); err != nil {
t.Fatalf("Start() error = %v", err)
}
// wait for error result
select {
case result := <-adapter.Results():
if result.Error == nil {
t.Error("expected error result")
}
if !strings.Contains(result.Error.Error(), "Invalid API key") {
t.Errorf("error = %v, want to contain 'Invalid API key'", result.Error)
}
case <-time.After(time.Second):
t.Error("timeout waiting for error")
}
adapter.Close()
}
func TestDeepgramAdapter_ContextCancellation(t *testing.T) {
server := mockDeepgramServer(t, func(conn *websocket.Conn) {
// send metadata first so connection is established
metadata := deepgramWSResponse{Type: "Metadata", Metadata: &deepgramMetadata{RequestID: "test-123"}}
_ = conn.WriteJSON(metadata)
// just keep connection open until closed
for {
_, _, err := conn.ReadMessage()
if err != nil {
break
}
}
})
defer server.Close()
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
endpoint := &provider.EndpointConfig{BaseURL: wsURL, Path: ""}
adapter := NewDeepgramAdapter(endpoint, "test-api-key", "nova-3", "en", nil)
ctx, cancel := context.WithCancel(context.Background())
if err := adapter.Start(ctx, ""); err != nil {
t.Fatalf("Start() error = %v", err)
}
// cancel context - this should trigger Close() to be called or at least stop SendChunk
cancel()
// SendChunk should return error after context cancelled
err := adapter.SendChunk([]byte("test"))
if err == nil {
// it's ok if first chunk after cancel succeeds - the context cancel is async
// but subsequent operations should fail
}
// Close should work even after context cancelled
if err := adapter.Close(); err != nil {
t.Errorf("Close() error = %v", err)
}
// results channel should be closed after Close()
select {
case _, ok := <-adapter.Results():
if ok {
// drain any remaining
for range adapter.Results() {
}
}
case <-time.After(2 * time.Second):
t.Error("timeout waiting for results channel to close")
}
}
+36 -13
View File
@@ -10,12 +10,18 @@ import (
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"time" "time"
"github.com/leonardotrapani/hyprvoice/internal/provider"
) )
// ElevenLabsAdapter implements TranscriptionAdapter for ElevenLabs Scribe API // ElevenLabsAdapter implements BatchAdapter for ElevenLabs Scribe API
type ElevenLabsAdapter struct { type ElevenLabsAdapter struct {
client *http.Client client *http.Client
config Config endpoint *provider.EndpointConfig
apiKey string
model string
language string
keywords []string
} }
// ElevenLabsResponse represents the API response // ElevenLabsResponse represents the API response
@@ -23,11 +29,19 @@ type ElevenLabsResponse struct {
Text string `json:"text"` Text string `json:"text"`
} }
// NewElevenLabsAdapter creates a new ElevenLabs adapter // NewElevenLabsAdapter creates an adapter for ElevenLabs Scribe API
func NewElevenLabsAdapter(config Config) *ElevenLabsAdapter { // endpoint: the endpoint config (BaseURL + Path)
// apiKey: ElevenLabs API key
// model: model ID (e.g., "scribe_v1")
// lang: provider language code
func NewElevenLabsAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string, keywords []string) *ElevenLabsAdapter {
return &ElevenLabsAdapter{ return &ElevenLabsAdapter{
client: &http.Client{Timeout: 30 * time.Second}, client: &http.Client{Timeout: 30 * time.Second},
config: config, endpoint: endpoint,
apiKey: apiKey,
model: model,
language: lang,
keywords: keywords,
} }
} }
@@ -57,30 +71,39 @@ func (a *ElevenLabsAdapter) Transcribe(ctx context.Context, audioData []byte) (s
} }
// Add model_id // Add model_id
if err := writer.WriteField("model_id", a.config.Model); err != nil { if err := writer.WriteField("model_id", a.model); err != nil {
return "", fmt.Errorf("write model_id: %w", err) return "", fmt.Errorf("write model_id: %w", err)
} }
// Add language_code if specified // Add language_code if specified
if a.config.Language != "" { if a.language != "" {
if err := writer.WriteField("language_code", a.config.Language); err != nil { if err := writer.WriteField("language_code", a.language); err != nil {
return "", fmt.Errorf("write language_code: %w", err) return "", fmt.Errorf("write language_code: %w", err)
} }
} }
// keyterms only supported on scribe_v2, not scribe_v1
if a.model != "scribe_v1" {
for _, keyword := range a.keywords {
if err := writer.WriteField("keyterms", keyword); err != nil {
return "", fmt.Errorf("write keyterms: %w", err)
}
}
}
if err := writer.Close(); err != nil { if err := writer.Close(); err != nil {
return "", fmt.Errorf("close writer: %w", err) return "", fmt.Errorf("close writer: %w", err)
} }
// Create HTTP request // Create HTTP request using endpoint config
url := "https://api.elevenlabs.io/v1/speech-to-text" url := a.endpoint.BaseURL + a.endpoint.Path
req, err := http.NewRequestWithContext(ctx, "POST", url, &body) req, err := http.NewRequestWithContext(ctx, "POST", url, &body)
if err != nil { if err != nil {
return "", fmt.Errorf("create request: %w", err) return "", fmt.Errorf("create request: %w", err)
} }
req.Header.Set("Content-Type", writer.FormDataContentType()) req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("xi-api-key", a.config.APIKey) req.Header.Set("xi-api-key", a.apiKey)
start := time.Now() start := time.Now()
resp, err := a.client.Do(req) resp, err := a.client.Do(req)
@@ -0,0 +1,536 @@
package transcriber
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/leonardotrapani/hyprvoice/internal/provider"
)
// default retry delays for reconnection (exponential backoff: 1s, 2s, 4s)
var defaultRetryDelays = []time.Duration{1 * time.Second, 2 * time.Second, 4 * time.Second}
// ElevenLabsStreamingAdapter implements StreamingAdapter for ElevenLabs real-time transcription
type ElevenLabsStreamingAdapter struct {
endpoint *provider.EndpointConfig
apiKey string
model string
language string
keywords []string
conn *websocket.Conn
resultsCh chan TranscriptionResult
mu sync.Mutex
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
started bool
// reconnection config
maxRetries int
retryDelays []time.Duration
// finalization signaling
commitDone chan struct{}
contextSent bool
}
// ElevenLabs WebSocket message types (outgoing)
type elevenLabsInputAudioChunk struct {
MessageType string `json:"message_type"`
AudioBase64 string `json:"audio_base_64"`
Commit bool `json:"commit"`
SampleRate int `json:"sample_rate"`
PreviousText string `json:"previous_text,omitempty"`
}
// ElevenLabs WebSocket response types (incoming)
type elevenLabsWSMessage struct {
MessageType string `json:"message_type"`
Text string `json:"text,omitempty"`
Error string `json:"error,omitempty"`
SessionID string `json:"session_id,omitempty"`
LanguageCode string `json:"language_code,omitempty"`
}
// NewElevenLabsStreamingAdapter creates a new streaming adapter for ElevenLabs
// endpoint: the WebSocket endpoint config (e.g., wss://api.elevenlabs.io, /v1/speech-to-text/realtime)
// apiKey: ElevenLabs API key
// model: model ID (e.g., "scribe_v1")
// lang: provider language code
func NewElevenLabsStreamingAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string, keywords []string) *ElevenLabsStreamingAdapter {
return &ElevenLabsStreamingAdapter{
endpoint: endpoint,
apiKey: apiKey,
model: model,
language: lang,
keywords: keywords,
resultsCh: make(chan TranscriptionResult, 100),
maxRetries: 3,
retryDelays: defaultRetryDelays,
commitDone: make(chan struct{}, 1),
}
}
// Start initiates the WebSocket connection to ElevenLabs
func (a *ElevenLabsStreamingAdapter) Start(ctx context.Context, lang string) error {
a.mu.Lock()
defer a.mu.Unlock()
if a.started {
return fmt.Errorf("adapter already started")
}
// use lang param if provided, otherwise use constructor lang
if lang != "" {
a.language = lang
}
// create cancelable context
a.ctx, a.cancel = context.WithCancel(ctx)
// connect to WebSocket
if err := a.connectLocked(); err != nil {
return err
}
a.started = true
// start reader goroutine
a.wg.Add(1)
go a.readLoop()
log.Printf("elevenlabs-streaming: connected, model=%s, language=%s", a.model, a.language)
return nil
}
// connectLocked establishes WebSocket connection. Must be called with mu held.
func (a *ElevenLabsStreamingAdapter) connectLocked() error {
wsURL, err := a.buildURL()
if err != nil {
return fmt.Errorf("build websocket url: %w", err)
}
headers := http.Header{}
headers.Set("xi-api-key", a.apiKey)
log.Printf("elevenlabs-streaming: connecting to %s", wsURL)
conn, resp, err := websocket.DefaultDialer.DialContext(a.ctx, wsURL, headers)
if err != nil {
if resp != nil {
log.Printf("elevenlabs-streaming: dial failed with status %d", resp.StatusCode)
}
return fmt.Errorf("websocket dial: %w", err)
}
a.conn = conn
a.contextSent = false
return nil
}
// reconnect attempts to re-establish the WebSocket connection with exponential backoff.
// Returns true if reconnection succeeded.
func (a *ElevenLabsStreamingAdapter) reconnect() bool {
for attempt := 0; attempt < a.maxRetries; attempt++ {
// check if context cancelled
select {
case <-a.ctx.Done():
return false
default:
}
// wait before retry (skip wait on first attempt)
if attempt > 0 {
delay := a.retryDelays[attempt-1]
if attempt-1 >= len(a.retryDelays) {
delay = a.retryDelays[len(a.retryDelays)-1]
}
log.Printf("elevenlabs-streaming: reconnect attempt %d/%d after %v", attempt+1, a.maxRetries, delay)
select {
case <-a.ctx.Done():
return false
case <-time.After(delay):
}
} else {
log.Printf("elevenlabs-streaming: reconnect attempt %d/%d", attempt+1, a.maxRetries)
}
a.mu.Lock()
// close old connection if exists
if a.conn != nil {
a.conn.Close()
a.conn = nil
}
err := a.connectLocked()
a.mu.Unlock()
if err == nil {
log.Printf("elevenlabs-streaming: reconnected successfully")
// notify caller of brief interruption
select {
case a.resultsCh <- TranscriptionResult{Error: fmt.Errorf("connection interrupted, reconnected"), IsFinal: false}:
default:
}
return true
}
log.Printf("elevenlabs-streaming: reconnect failed: %v", err)
}
return false
}
// buildURL constructs the WebSocket URL with query parameters
func (a *ElevenLabsStreamingAdapter) buildURL() (string, error) {
// parse base URL and path
baseURL := a.endpoint.BaseURL + a.endpoint.Path
u, err := url.Parse(baseURL)
if err != nil {
return "", fmt.Errorf("parse base url: %w", err)
}
// add query parameters
q := u.Query()
q.Set("model_id", a.model)
q.Set("audio_format", "pcm_16000") // we use 16kHz PCM
// add language if specified
if a.language != "" {
q.Set("language_code", a.language)
}
// use VAD for automatic commit (easier for real-time use)
q.Set("commit_strategy", "vad")
u.RawQuery = q.Encode()
return u.String(), nil
}
// readLoop reads messages from the WebSocket and sends results to the channel
func (a *ElevenLabsStreamingAdapter) readLoop() {
defer a.wg.Done()
defer close(a.resultsCh)
for {
select {
case <-a.ctx.Done():
return
default:
}
a.mu.Lock()
conn := a.conn
a.mu.Unlock()
if conn == nil {
// no connection, try to reconnect
if !a.reconnect() {
a.resultsCh <- TranscriptionResult{Error: fmt.Errorf("connection lost, reconnection failed after %d attempts", a.maxRetries)}
return
}
continue
}
_, message, err := conn.ReadMessage()
if err != nil {
if a.handleFatalClose(err) {
return
}
// check if context was cancelled (normal shutdown)
select {
case <-a.ctx.Done():
return
default:
}
// attempt reconnection
log.Printf("elevenlabs-streaming: read error: %v, attempting reconnection", err)
if !a.reconnect() {
a.resultsCh <- TranscriptionResult{Error: fmt.Errorf("websocket read: %w, reconnection failed", err)}
return
}
continue
}
// parse message
var msg elevenLabsWSMessage
if err := json.Unmarshal(message, &msg); err != nil {
log.Printf("elevenlabs-streaming: parse error: %v", err)
continue
}
// handle different message types
switch msg.MessageType {
case "session_started":
log.Printf("elevenlabs-streaming: session started, id=%s", msg.SessionID)
case "partial_transcript":
// interim result
if msg.Text != "" {
a.resultsCh <- TranscriptionResult{Text: msg.Text, IsFinal: false}
}
case "committed_transcript", "committed_transcript_with_timestamps":
// final result
log.Printf("elevenlabs-streaming: committed: %q", msg.Text)
if msg.Text != "" {
a.resultsCh <- TranscriptionResult{Text: msg.Text, IsFinal: true}
}
// signal finalization is done (non-blocking)
select {
case a.commitDone <- struct{}{}:
default:
}
case "error", "auth_error", "quota_exceeded", "rate_limited",
"queue_overflow", "resource_exhausted", "session_time_limit_exceeded",
"input_error", "chunk_size_exceeded", "insufficient_audio_activity",
"transcriber_error", "commit_throttled", "unaccepted_terms", "invalid_request":
// error message
errMsg := msg.Error
if errMsg == "" {
errMsg = msg.MessageType
}
log.Printf("elevenlabs-streaming: error: %s", errMsg)
err := fmt.Errorf("elevenlabs: %s", errMsg)
if isElevenLabsFatalMessageType(msg.MessageType) {
a.handleFatalError(err)
return
}
a.emitResultError(err)
default:
log.Printf("elevenlabs-streaming: unknown message type: %s payload=%s", msg.MessageType, strings.TrimSpace(string(message)))
}
}
}
func (a *ElevenLabsStreamingAdapter) emitResultError(err error) {
select {
case a.resultsCh <- TranscriptionResult{Error: err}:
default:
}
}
func (a *ElevenLabsStreamingAdapter) handleFatalError(err error) {
fatalErr := NewFatalTranscriptionError(err)
log.Printf("elevenlabs-streaming: fatal error: %v", err)
a.emitResultError(fatalErr)
a.closeConn()
if a.cancel != nil {
a.cancel()
}
}
func (a *ElevenLabsStreamingAdapter) handleFatalClose(err error) bool {
var closeErr *websocket.CloseError
if !errors.As(err, &closeErr) {
return false
}
if !isElevenLabsFatalCloseCode(closeErr.Code) {
return false
}
reason := strings.TrimSpace(closeErr.Text)
if reason == "" {
reason = "no reason provided"
}
a.handleFatalError(fmt.Errorf("elevenlabs websocket closed (%d): %s", closeErr.Code, reason))
return true
}
func (a *ElevenLabsStreamingAdapter) closeConn() {
a.mu.Lock()
conn := a.conn
a.conn = nil
a.mu.Unlock()
if conn != nil {
_ = conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
_ = conn.Close()
}
}
func isElevenLabsFatalCloseCode(code int) bool {
switch code {
case websocket.ClosePolicyViolation,
websocket.CloseUnsupportedData,
websocket.CloseInvalidFramePayloadData,
websocket.CloseMessageTooBig,
websocket.CloseProtocolError:
return true
default:
return false
}
}
func isElevenLabsFatalMessageType(messageType string) bool {
switch messageType {
case "auth_error", "unaccepted_terms", "invalid_request", "input_error", "chunk_size_exceeded":
return true
default:
return false
}
}
// SendChunk sends audio data to the WebSocket
func (a *ElevenLabsStreamingAdapter) SendChunk(audio []byte) error {
a.mu.Lock()
if !a.started {
a.mu.Unlock()
return fmt.Errorf("adapter not started")
}
conn := a.conn
a.mu.Unlock()
// check context
select {
case <-a.ctx.Done():
return a.ctx.Err()
default:
}
if conn == nil {
return fmt.Errorf("no connection")
}
// encode audio as base64
audioB64 := base64.StdEncoding.EncodeToString(audio)
// create message
msg := elevenLabsInputAudioChunk{
MessageType: "input_audio_chunk",
AudioBase64: audioB64,
Commit: false, // let VAD handle commits
SampleRate: 16000,
}
a.mu.Lock()
if !a.contextSent && len(a.keywords) > 0 {
msg.PreviousText = strings.Join(a.keywords, ", ")
a.contextSent = true
}
a.mu.Unlock()
// send as JSON
a.mu.Lock()
err := a.conn.WriteJSON(msg)
a.mu.Unlock()
if err != nil {
// attempt reconnection
log.Printf("elevenlabs-streaming: write error: %v, attempting reconnection", err)
if a.reconnect() {
// retry the chunk after reconnection
a.mu.Lock()
err = a.conn.WriteJSON(msg)
a.mu.Unlock()
if err == nil {
return nil
}
}
return fmt.Errorf("websocket write: %w", err)
}
return nil
}
// Results returns the channel for receiving transcription results
func (a *ElevenLabsStreamingAdapter) Results() <-chan TranscriptionResult {
return a.resultsCh
}
// Finalize sends a commit message to force ElevenLabs to commit any pending audio
// and waits for the committed_transcript response
func (a *ElevenLabsStreamingAdapter) Finalize(ctx context.Context) error {
a.mu.Lock()
if !a.started {
a.mu.Unlock()
return nil
}
conn := a.conn
a.mu.Unlock()
if conn == nil {
return nil
}
// drain any previous commit signals
select {
case <-a.commitDone:
default:
}
// send empty audio chunk with commit=true to force finalization
msg := elevenLabsInputAudioChunk{
MessageType: "input_audio_chunk",
AudioBase64: "",
Commit: true,
SampleRate: 16000,
}
a.mu.Lock()
err := a.conn.WriteJSON(msg)
a.mu.Unlock()
if err != nil {
log.Printf("elevenlabs-streaming: finalize write error: %v", err)
return fmt.Errorf("finalize write: %w", err)
}
log.Printf("elevenlabs-streaming: sent commit, waiting for final transcript")
// wait for committed_transcript or timeout
select {
case <-a.commitDone:
log.Printf("elevenlabs-streaming: finalize complete")
return nil
case <-ctx.Done():
log.Printf("elevenlabs-streaming: finalize timeout")
return ctx.Err()
case <-a.ctx.Done():
return a.ctx.Err()
}
}
// Close gracefully closes the WebSocket connection
func (a *ElevenLabsStreamingAdapter) Close() error {
a.mu.Lock()
if !a.started {
a.mu.Unlock()
return nil
}
// cancel context first to signal reader to stop
if a.cancel != nil {
a.cancel()
}
// get conn ref while holding lock
conn := a.conn
a.started = false
a.mu.Unlock()
// close websocket outside of lock (readLoop may be blocked on read)
if conn != nil {
// send close frame (best effort)
_ = conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
conn.Close()
}
// wait for reader to finish
a.wg.Wait()
log.Printf("elevenlabs-streaming: closed")
return nil
}
@@ -0,0 +1,754 @@
package transcriber
import (
"context"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/gorilla/websocket"
"github.com/leonardotrapani/hyprvoice/internal/provider"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
// mockElevenLabsServer creates a test WebSocket server that simulates ElevenLabs
func mockElevenLabsServer(t *testing.T, handler func(*websocket.Conn)) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// check API key header
apiKey := r.Header.Get("xi-api-key")
if apiKey == "" {
http.Error(w, "missing api key", http.StatusUnauthorized)
return
}
// upgrade to websocket
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
t.Logf("upgrade error: %v", err)
return
}
defer conn.Close()
handler(conn)
}))
}
func TestElevenLabsStreamingAdapter_ImplementsInterface(t *testing.T) {
var _ StreamingAdapter = (*ElevenLabsStreamingAdapter)(nil)
}
func TestElevenLabsStreamingAdapter_Start(t *testing.T) {
server := mockElevenLabsServer(t, func(conn *websocket.Conn) {
// send session started
msg := elevenLabsWSMessage{
MessageType: "session_started",
SessionID: "test-session-123",
}
conn.WriteJSON(msg)
// keep connection open
for {
_, _, err := conn.ReadMessage()
if err != nil {
return
}
}
})
defer server.Close()
// convert http://... to ws://...
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
adapter := NewElevenLabsStreamingAdapter(
&provider.EndpointConfig{BaseURL: wsURL, Path: ""},
"test-api-key",
"scribe_v1",
"en",
nil,
)
ctx := context.Background()
err := adapter.Start(ctx, "")
if err != nil {
t.Fatalf("Start() error: %v", err)
}
// give time for session_started to be received
time.Sleep(50 * time.Millisecond)
err = adapter.Close()
if err != nil {
t.Errorf("Close() error: %v", err)
}
}
func TestElevenLabsStreamingAdapter_SendChunk(t *testing.T) {
receivedChunks := make(chan []byte, 10)
server := mockElevenLabsServer(t, func(conn *websocket.Conn) {
// send session started
conn.WriteJSON(elevenLabsWSMessage{
MessageType: "session_started",
SessionID: "test-session",
})
// read incoming messages
for {
_, message, err := conn.ReadMessage()
if err != nil {
return
}
var msg elevenLabsInputAudioChunk
if err := json.Unmarshal(message, &msg); err != nil {
continue
}
if msg.MessageType == "input_audio_chunk" {
decoded, _ := base64.StdEncoding.DecodeString(msg.AudioBase64)
receivedChunks <- decoded
}
}
})
defer server.Close()
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
adapter := NewElevenLabsStreamingAdapter(
&provider.EndpointConfig{BaseURL: wsURL, Path: ""},
"test-api-key",
"scribe_v1",
"en",
nil,
)
ctx := context.Background()
if err := adapter.Start(ctx, ""); err != nil {
t.Fatalf("Start() error: %v", err)
}
defer adapter.Close()
// send audio chunk
testAudio := []byte{0x01, 0x02, 0x03, 0x04}
if err := adapter.SendChunk(testAudio); err != nil {
t.Fatalf("SendChunk() error: %v", err)
}
// verify received
select {
case received := <-receivedChunks:
if string(received) != string(testAudio) {
t.Errorf("received audio mismatch: got %v, want %v", received, testAudio)
}
case <-time.After(time.Second):
t.Error("timeout waiting for audio chunk")
}
}
func TestElevenLabsStreamingAdapter_Results(t *testing.T) {
server := mockElevenLabsServer(t, func(conn *websocket.Conn) {
// send session started
conn.WriteJSON(elevenLabsWSMessage{
MessageType: "session_started",
SessionID: "test-session",
})
// send partial transcript
conn.WriteJSON(elevenLabsWSMessage{
MessageType: "partial_transcript",
Text: "hello",
})
// send committed transcript
conn.WriteJSON(elevenLabsWSMessage{
MessageType: "committed_transcript",
Text: "hello world",
})
// keep connection open
for {
_, _, err := conn.ReadMessage()
if err != nil {
return
}
}
})
defer server.Close()
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
adapter := NewElevenLabsStreamingAdapter(
&provider.EndpointConfig{BaseURL: wsURL, Path: ""},
"test-api-key",
"scribe_v1",
"en",
nil,
)
ctx := context.Background()
if err := adapter.Start(ctx, ""); err != nil {
t.Fatalf("Start() error: %v", err)
}
defer adapter.Close()
results := adapter.Results()
// check partial result
select {
case result := <-results:
if result.Error != nil {
t.Fatalf("unexpected error: %v", result.Error)
}
if result.Text != "hello" {
t.Errorf("partial text: got %q, want %q", result.Text, "hello")
}
if result.IsFinal {
t.Error("partial result should not be final")
}
case <-time.After(time.Second):
t.Fatal("timeout waiting for partial result")
}
// check final result
select {
case result := <-results:
if result.Error != nil {
t.Fatalf("unexpected error: %v", result.Error)
}
if result.Text != "hello world" {
t.Errorf("final text: got %q, want %q", result.Text, "hello world")
}
if !result.IsFinal {
t.Error("committed result should be final")
}
case <-time.After(time.Second):
t.Fatal("timeout waiting for final result")
}
}
func TestElevenLabsStreamingAdapter_ErrorMessages(t *testing.T) {
server := mockElevenLabsServer(t, func(conn *websocket.Conn) {
// send session started
conn.WriteJSON(elevenLabsWSMessage{
MessageType: "session_started",
SessionID: "test-session",
})
// send error
conn.WriteJSON(elevenLabsWSMessage{
MessageType: "error",
Error: "test error message",
})
// keep connection open
for {
_, _, err := conn.ReadMessage()
if err != nil {
return
}
}
})
defer server.Close()
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
adapter := NewElevenLabsStreamingAdapter(
&provider.EndpointConfig{BaseURL: wsURL, Path: ""},
"test-api-key",
"scribe_v1",
"en",
nil,
)
ctx := context.Background()
if err := adapter.Start(ctx, ""); err != nil {
t.Fatalf("Start() error: %v", err)
}
defer adapter.Close()
results := adapter.Results()
// check error result
select {
case result := <-results:
if result.Error == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(result.Error.Error(), "test error message") {
t.Errorf("error message: got %q, want to contain %q", result.Error.Error(), "test error message")
}
case <-time.After(time.Second):
t.Fatal("timeout waiting for error result")
}
}
func TestElevenLabsStreamingAdapter_LanguageConversion(t *testing.T) {
var receivedURL string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedURL = r.URL.String()
// check API key header
if r.Header.Get("xi-api-key") == "" {
http.Error(w, "missing api key", http.StatusUnauthorized)
return
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
conn.WriteJSON(elevenLabsWSMessage{
MessageType: "session_started",
SessionID: "test-session",
})
for {
_, _, err := conn.ReadMessage()
if err != nil {
return
}
}
}))
defer server.Close()
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
adapter := NewElevenLabsStreamingAdapter(
&provider.EndpointConfig{BaseURL: wsURL, Path: "/v1/speech-to-text/realtime"},
"test-api-key",
"scribe_v1",
"es", // Spanish
nil,
)
ctx := context.Background()
if err := adapter.Start(ctx, ""); err != nil {
t.Fatalf("Start() error: %v", err)
}
adapter.Close()
// verify language_code was set
if !strings.Contains(receivedURL, "language_code=es") {
t.Errorf("URL should contain language_code=es, got: %s", receivedURL)
}
// verify model_id was set
if !strings.Contains(receivedURL, "model_id=scribe_v1") {
t.Errorf("URL should contain model_id=scribe_v1, got: %s", receivedURL)
}
// verify audio_format was set
if !strings.Contains(receivedURL, "audio_format=pcm_16000") {
t.Errorf("URL should contain audio_format=pcm_16000, got: %s", receivedURL)
}
}
func TestElevenLabsStreamingAdapter_Close(t *testing.T) {
server := mockElevenLabsServer(t, func(conn *websocket.Conn) {
conn.WriteJSON(elevenLabsWSMessage{
MessageType: "session_started",
SessionID: "test-session",
})
for {
_, _, err := conn.ReadMessage()
if err != nil {
return
}
}
})
defer server.Close()
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
adapter := NewElevenLabsStreamingAdapter(
&provider.EndpointConfig{BaseURL: wsURL, Path: ""},
"test-api-key",
"scribe_v1",
"en",
nil,
)
ctx := context.Background()
if err := adapter.Start(ctx, ""); err != nil {
t.Fatalf("Start() error: %v", err)
}
// close should not block
done := make(chan struct{})
go func() {
adapter.Close()
close(done)
}()
select {
case <-done:
// ok
case <-time.After(2 * time.Second):
t.Fatal("Close() blocked for too long")
}
// results channel should be closed
_, ok := <-adapter.Results()
if ok {
// there might be buffered results, drain them
for range adapter.Results() {
}
}
}
func TestElevenLabsStreamingAdapter_NotStarted(t *testing.T) {
adapter := NewElevenLabsStreamingAdapter(
&provider.EndpointConfig{BaseURL: "wss://api.elevenlabs.io", Path: "/v1/speech-to-text/realtime"},
"test-api-key",
"scribe_v1",
"en",
nil,
)
// SendChunk should fail when not started
err := adapter.SendChunk([]byte{0x01, 0x02})
if err == nil {
t.Error("SendChunk() should fail when adapter not started")
}
if !strings.Contains(err.Error(), "not started") {
t.Errorf("error should mention 'not started', got: %v", err)
}
// Close should not fail when not started
err = adapter.Close()
if err != nil {
t.Errorf("Close() should not fail when not started: %v", err)
}
}
func TestElevenLabsStreamingAdapter_ReconnectOnReadError(t *testing.T) {
var connectionCount int
var serverConn *websocket.Conn
var mu sync.Mutex
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("xi-api-key") == "" {
http.Error(w, "missing api key", http.StatusUnauthorized)
return
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
mu.Lock()
serverConn = conn
connectionCount++
mu.Unlock()
// send session started
conn.WriteJSON(elevenLabsWSMessage{
MessageType: "session_started",
SessionID: "test-session",
})
// keep connection open
for {
_, _, err := conn.ReadMessage()
if err != nil {
return
}
}
}))
defer server.Close()
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
adapter := NewElevenLabsStreamingAdapter(
&provider.EndpointConfig{BaseURL: wsURL, Path: ""},
"test-api-key",
"scribe_v1",
"en",
nil,
)
// use very short delays for testing
adapter.retryDelays = []time.Duration{10 * time.Millisecond, 20 * time.Millisecond, 40 * time.Millisecond}
ctx := context.Background()
if err := adapter.Start(ctx, ""); err != nil {
t.Fatalf("Start() error: %v", err)
}
defer adapter.Close()
// verify initial connection
time.Sleep(20 * time.Millisecond)
mu.Lock()
count := connectionCount
conn := serverConn
mu.Unlock()
if count != 1 {
t.Errorf("expected 1 connection, got %d", count)
}
// close server connection to trigger read error
if conn != nil {
conn.Close()
}
// wait for reconnection
time.Sleep(100 * time.Millisecond)
// should have reconnected
mu.Lock()
count = connectionCount
mu.Unlock()
if count < 2 {
t.Errorf("expected reconnection, connection count: %d", count)
}
}
func TestElevenLabsStreamingAdapter_ReconnectNotifiesClient(t *testing.T) {
var serverConn *websocket.Conn
connectionMu := sync.Mutex{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("xi-api-key") == "" {
http.Error(w, "missing api key", http.StatusUnauthorized)
return
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
connectionMu.Lock()
serverConn = conn
connectionMu.Unlock()
conn.WriteJSON(elevenLabsWSMessage{
MessageType: "session_started",
SessionID: "test-session",
})
for {
_, _, err := conn.ReadMessage()
if err != nil {
return
}
}
}))
defer server.Close()
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
adapter := NewElevenLabsStreamingAdapter(
&provider.EndpointConfig{BaseURL: wsURL, Path: ""},
"test-api-key",
"scribe_v1",
"en",
nil,
)
adapter.retryDelays = []time.Duration{10 * time.Millisecond, 20 * time.Millisecond, 40 * time.Millisecond}
ctx := context.Background()
if err := adapter.Start(ctx, ""); err != nil {
t.Fatalf("Start() error: %v", err)
}
defer adapter.Close()
results := adapter.Results()
// close server connection to trigger reconnect
time.Sleep(50 * time.Millisecond)
connectionMu.Lock()
if serverConn != nil {
serverConn.Close()
}
connectionMu.Unlock()
// should receive notification about reconnection
gotReconnectNotification := false
timeout := time.After(500 * time.Millisecond)
for {
select {
case result, ok := <-results:
if !ok {
t.Fatal("results channel closed unexpectedly")
}
if result.Error != nil && strings.Contains(result.Error.Error(), "reconnected") {
gotReconnectNotification = true
}
if gotReconnectNotification {
return
}
case <-timeout:
if !gotReconnectNotification {
t.Error("expected reconnection notification")
}
return
}
}
}
func TestElevenLabsStreamingAdapter_MaxRetriesExhausted(t *testing.T) {
var connectionCount int
var mu sync.Mutex
// server that allows first connection but rejects subsequent ones
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("xi-api-key") == "" {
http.Error(w, "missing api key", http.StatusUnauthorized)
return
}
mu.Lock()
connectionCount++
count := connectionCount
mu.Unlock()
if count == 1 {
// accept first connection, then close it
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
conn.WriteJSON(elevenLabsWSMessage{
MessageType: "session_started",
SessionID: "test-session",
})
time.Sleep(10 * time.Millisecond)
conn.Close()
} else {
// reject subsequent connections
http.Error(w, "server unavailable", http.StatusServiceUnavailable)
}
}))
defer server.Close()
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
adapter := NewElevenLabsStreamingAdapter(
&provider.EndpointConfig{BaseURL: wsURL, Path: ""},
"test-api-key",
"scribe_v1",
"en",
nil,
)
adapter.retryDelays = []time.Duration{5 * time.Millisecond, 10 * time.Millisecond, 15 * time.Millisecond}
adapter.maxRetries = 2
ctx := context.Background()
if err := adapter.Start(ctx, ""); err != nil {
t.Fatalf("Start() error: %v", err)
}
defer adapter.Close()
results := adapter.Results()
// wait for final error after retries exhausted
var finalError error
timeout := time.After(500 * time.Millisecond)
loop:
for {
select {
case result, ok := <-results:
if !ok {
break loop
}
if result.Error != nil {
finalError = result.Error
}
case <-timeout:
break loop
}
}
if finalError == nil {
t.Error("expected final error after max retries")
} else if !strings.Contains(finalError.Error(), "reconnection failed") {
t.Errorf("expected 'reconnection failed' in error, got: %v", finalError)
}
}
func TestElevenLabsStreamingAdapter_ReconnectExponentialBackoff(t *testing.T) {
connectionTimes := []time.Time{}
connectionMu := sync.Mutex{}
// server that closes connections after session_started
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("xi-api-key") == "" {
http.Error(w, "missing api key", http.StatusUnauthorized)
return
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
connectionMu.Lock()
connectionTimes = append(connectionTimes, time.Now())
connectionMu.Unlock()
conn.WriteJSON(elevenLabsWSMessage{
MessageType: "session_started",
SessionID: "test-session",
})
// close after short delay to trigger reconnect
time.Sleep(10 * time.Millisecond)
conn.Close()
}))
defer server.Close()
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
adapter := NewElevenLabsStreamingAdapter(
&provider.EndpointConfig{BaseURL: wsURL, Path: ""},
"test-api-key",
"scribe_v1",
"en",
nil,
)
// use measurable delays
adapter.retryDelays = []time.Duration{50 * time.Millisecond, 100 * time.Millisecond, 200 * time.Millisecond}
adapter.maxRetries = 3
ctx := context.Background()
if err := adapter.Start(ctx, ""); err != nil {
t.Fatalf("Start() error: %v", err)
}
// wait for retries
time.Sleep(500 * time.Millisecond)
adapter.Close()
connectionMu.Lock()
times := connectionTimes
connectionMu.Unlock()
if len(times) < 2 {
t.Fatalf("expected at least 2 connection attempts, got %d", len(times))
}
// verify delays are increasing (exponential backoff)
for i := 1; i < len(times)-1; i++ {
delay1 := times[i].Sub(times[i-1])
delay2 := times[i+1].Sub(times[i])
// delay2 should be greater than or equal to delay1 (with some tolerance for timing)
if delay2 < delay1-20*time.Millisecond {
t.Logf("delay %d: %v, delay %d: %v", i, delay1, i+1, delay2)
}
}
}
+31 -24
View File
@@ -3,39 +3,50 @@ package transcriber
import ( import (
"context" "context"
"testing" "testing"
"github.com/leonardotrapani/hyprvoice/internal/provider"
) )
func TestNewElevenLabsAdapter(t *testing.T) { func TestNewElevenLabsAdapter(t *testing.T) {
config := Config{ endpoint := &provider.EndpointConfig{
Provider: "elevenlabs", BaseURL: "https://api.elevenlabs.io",
APIKey: "test-api-key", Path: "/v1/speech-to-text",
Language: "en",
Model: "scribe_v1",
} }
adapter := NewElevenLabsAdapter(config) adapter := NewElevenLabsAdapter(endpoint, "test-api-key", "scribe_v1", "en", nil)
if adapter == nil { if adapter == nil {
t.Fatalf("NewElevenLabsAdapter() returned nil") t.Fatalf("NewElevenLabsAdapter() returned nil")
} }
if adapter.config.APIKey != "test-api-key" { if adapter.apiKey != "test-api-key" {
t.Errorf("APIKey not set correctly, got: %s", adapter.config.APIKey) t.Errorf("APIKey not set correctly, got: %s", adapter.apiKey)
} }
if adapter.config.Model != "scribe_v1" { if adapter.model != "scribe_v1" {
t.Errorf("Model not set correctly, got: %s", adapter.config.Model) t.Errorf("Model not set correctly, got: %s", adapter.model)
}
if adapter.language != "en" {
t.Errorf("Language not set correctly, got: %s", adapter.language)
}
if adapter.endpoint.BaseURL != "https://api.elevenlabs.io" {
t.Errorf("Endpoint BaseURL not set correctly, got: %s", adapter.endpoint.BaseURL)
}
if adapter.endpoint.Path != "/v1/speech-to-text" {
t.Errorf("Endpoint Path not set correctly, got: %s", adapter.endpoint.Path)
} }
} }
func TestElevenLabsAdapter_Transcribe_EmptyAudio(t *testing.T) { func TestElevenLabsAdapter_Transcribe_EmptyAudio(t *testing.T) {
config := Config{ endpoint := &provider.EndpointConfig{
Provider: "elevenlabs", BaseURL: "https://api.elevenlabs.io",
APIKey: "test-key", Path: "/v1/speech-to-text",
Model: "scribe_v1",
} }
adapter := NewElevenLabsAdapter(config) adapter := NewElevenLabsAdapter(endpoint, "test-key", "scribe_v1", "", nil)
ctx := context.Background() ctx := context.Background()
result, err := adapter.Transcribe(ctx, []byte{}) result, err := adapter.Transcribe(ctx, []byte{})
@@ -50,22 +61,18 @@ func TestElevenLabsAdapter_Transcribe_EmptyAudio(t *testing.T) {
} }
func TestElevenLabsAdapter_Transcribe_ValidAudio(t *testing.T) { func TestElevenLabsAdapter_Transcribe_ValidAudio(t *testing.T) {
// This test will require mocking the HTTP client endpoint := &provider.EndpointConfig{
// For now, we test the structure exists BaseURL: "https://api.elevenlabs.io",
config := Config{ Path: "/v1/speech-to-text",
Provider: "elevenlabs",
APIKey: "test-key",
Language: "en",
Model: "scribe_v1",
} }
adapter := NewElevenLabsAdapter(config) adapter := NewElevenLabsAdapter(endpoint, "test-key", "scribe_v1", "en", nil)
if adapter == nil { if adapter == nil {
t.Fatal("NewElevenLabsAdapter() returned nil") t.Fatal("NewElevenLabsAdapter() returned nil")
} }
// Test that adapter has a client // test that adapter has a client
if adapter.client == nil { if adapter.client == nil {
t.Error("adapter.client is nil") t.Error("adapter.client is nil")
} }
@@ -1,60 +0,0 @@
package transcriber
import (
"bytes"
"context"
"fmt"
"log"
"time"
"github.com/sashabaranov/go-openai"
)
// GroqTranscriptionAdapter implements TranscriptionAdapter for Groq Whisper API
type GroqTranscriptionAdapter struct {
client *openai.Client
config Config
}
func NewGroqTranscriptionAdapter(config Config) *GroqTranscriptionAdapter {
clientConfig := openai.DefaultConfig(config.APIKey)
clientConfig.BaseURL = "https://api.groq.com/openai/v1"
client := openai.NewClientWithConfig(clientConfig)
return &GroqTranscriptionAdapter{
client: client,
config: config,
}
}
func (a *GroqTranscriptionAdapter) Transcribe(ctx context.Context, audioData []byte) (string, error) {
if len(audioData) == 0 {
return "", nil
}
// Convert raw PCM to WAV format
wavData, err := convertToWAV(audioData)
if err != nil {
return "", fmt.Errorf("convert to WAV: %w", err)
}
// Create transcription request
req := openai.AudioRequest{
Model: a.config.Model,
Reader: bytes.NewReader(wavData),
FilePath: "audio.wav",
Language: a.config.Language,
}
start := time.Now()
resp, err := a.client.CreateTranscription(ctx, req)
duration := time.Since(start)
if err != nil {
log.Printf("groq-transcription-adapter: API call failed after %v: %v", duration, err)
return "", fmt.Errorf("groq transcription: %w", err)
}
log.Printf("groq-transcription-adapter: transcribed %d bytes in %v: %q", len(audioData), duration, resp.Text)
return resp.Text, nil
}
@@ -1,63 +0,0 @@
package transcriber
import (
"bytes"
"context"
"fmt"
"log"
"time"
"github.com/sashabaranov/go-openai"
)
// GroqTranslationAdapter implements TranscriptionAdapter for Groq Translation API
// Translates audio to English text. The Language field in config hints at the source language.
type GroqTranslationAdapter struct {
client *openai.Client
config Config
}
func NewGroqTranslationAdapter(config Config) *GroqTranslationAdapter {
clientConfig := openai.DefaultConfig(config.APIKey)
clientConfig.BaseURL = "https://api.groq.com/openai/v1"
client := openai.NewClientWithConfig(clientConfig)
return &GroqTranslationAdapter{
client: client,
config: config,
}
}
func (a *GroqTranslationAdapter) Transcribe(ctx context.Context, audioData []byte) (string, error) {
if len(audioData) == 0 {
return "", nil
}
// Convert raw PCM to WAV format
wavData, err := convertToWAV(audioData)
if err != nil {
return "", fmt.Errorf("convert to WAV: %w", err)
}
// Create translation request
// Note: Translation always outputs English, regardless of target language
// The Language field in the request hints at the source audio language for better accuracy
req := openai.AudioRequest{
Model: a.config.Model,
Reader: bytes.NewReader(wavData),
FilePath: "audio.wav",
Language: a.config.Language, // Source language hint
}
start := time.Now()
resp, err := a.client.CreateTranslation(ctx, req)
duration := time.Since(start)
if err != nil {
log.Printf("groq-translation-adapter: API call failed after %v: %v", duration, err)
return "", fmt.Errorf("groq translation: %w", err)
}
log.Printf("groq-translation-adapter: translated %d bytes in %v: %q", len(audioData), duration, resp.Text)
return resp.Text, nil
}
-60
View File
@@ -1,60 +0,0 @@
package transcriber
import (
"bytes"
"context"
"fmt"
"log"
"time"
"github.com/sashabaranov/go-openai"
)
// MistralAdapter implements TranscriptionAdapter for Mistral Voxtral API
type MistralAdapter struct {
client *openai.Client
config Config
}
func NewMistralAdapter(config Config) *MistralAdapter {
clientConfig := openai.DefaultConfig(config.APIKey)
clientConfig.BaseURL = "https://api.mistral.ai/v1"
client := openai.NewClientWithConfig(clientConfig)
return &MistralAdapter{
client: client,
config: config,
}
}
func (a *MistralAdapter) Transcribe(ctx context.Context, audioData []byte) (string, error) {
if len(audioData) == 0 {
return "", nil
}
// Convert raw PCM to WAV format
wavData, err := convertToWAV(audioData)
if err != nil {
return "", fmt.Errorf("convert to WAV: %w", err)
}
// Create transcription request
req := openai.AudioRequest{
Model: a.config.Model,
Reader: bytes.NewReader(wavData),
FilePath: "audio.wav",
Language: a.config.Language,
}
start := time.Now()
resp, err := a.client.CreateTranscription(ctx, req)
duration := time.Since(start)
if err != nil {
log.Printf("mistral-adapter: API call failed after %v: %v", duration, err)
return "", fmt.Errorf("mistral transcription: %w", err)
}
log.Printf("mistral-adapter: transcribed %d bytes in %v: %q", len(audioData), duration, resp.Text)
return resp.Text, nil
}
+44 -12
View File
@@ -5,22 +5,49 @@ import (
"context" "context"
"fmt" "fmt"
"log" "log"
"strings"
"time" "time"
"github.com/leonardotrapani/hyprvoice/internal/provider"
"github.com/sashabaranov/go-openai" "github.com/sashabaranov/go-openai"
) )
// OpenAIAdapter implements TranscriptionAdapter for OpenAI Whisper API // OpenAIAdapter implements BatchAdapter for any OpenAI-compatible API
// Works with OpenAI, Groq, Mistral, and any other OpenAI-compatible endpoint
type OpenAIAdapter struct { type OpenAIAdapter struct {
client *openai.Client client *openai.Client
config Config model string
language string
keywords []string
providerName string
} }
func NewOpenAIAdapter(config Config) *OpenAIAdapter { // NewOpenAIAdapter creates an adapter for OpenAI-compatible transcription APIs
client := openai.NewClient(config.APIKey) // endpoint: the BaseURL for the API (e.g., "https://api.openai.com", "https://api.groq.com/openai")
// apiKey: the API key for authentication
// model: model ID to use
// lang: provider language code
// keywords: optional spelling hints
// providerName: used for logging and language format conversion
func NewOpenAIAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string, keywords []string, providerName string) *OpenAIAdapter {
var client *openai.Client
if endpoint != nil && endpoint.BaseURL != "" {
// use custom endpoint
clientConfig := openai.DefaultConfig(apiKey)
clientConfig.BaseURL = endpoint.BaseURL + "/v1"
client = openai.NewClientWithConfig(clientConfig)
} else {
// default to OpenAI
client = openai.NewClient(apiKey)
}
return &OpenAIAdapter{ return &OpenAIAdapter{
client: client, client: client,
config: config, model: model,
language: lang,
keywords: keywords,
providerName: providerName,
} }
} }
@@ -37,10 +64,15 @@ func (a *OpenAIAdapter) Transcribe(ctx context.Context, audioData []byte) (strin
// Create transcription request // Create transcription request
req := openai.AudioRequest{ req := openai.AudioRequest{
Model: a.config.Model, Model: a.model,
Reader: bytes.NewReader(wavData), Reader: bytes.NewReader(wavData),
FilePath: "audio.wav", FilePath: "audio.wav",
Language: a.config.Language, Language: a.language,
}
// Add keywords as initial_prompt to help with spelling hints
if len(a.keywords) > 0 {
req.Prompt = strings.Join(a.keywords, ", ")
} }
start := time.Now() start := time.Now()
@@ -48,10 +80,10 @@ func (a *OpenAIAdapter) Transcribe(ctx context.Context, audioData []byte) (strin
duration := time.Since(start) duration := time.Since(start)
if err != nil { if err != nil {
log.Printf("openai-adapter: API call failed after %v: %v", duration, err) log.Printf("%s-adapter: API call failed after %v: %v", a.providerName, duration, err)
return "", fmt.Errorf("openai transcription: %w", err) return "", fmt.Errorf("%s transcription: %w", a.providerName, err)
} }
log.Printf("openai-adapter: transcribed %d bytes in %v: %q", len(audioData), duration, resp.Text) log.Printf("%s-adapter: transcribed %d bytes in %v: %q", a.providerName, len(audioData), duration, resp.Text)
return resp.Text, nil return resp.Text, nil
} }
@@ -0,0 +1,602 @@
package transcriber
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/leonardotrapani/hyprvoice/internal/provider"
)
// OpenAIRealtimeAdapter implements StreamingAdapter for OpenAI Realtime API transcription
type OpenAIRealtimeAdapter struct {
endpoint *provider.EndpointConfig
apiKey string
model string
language string
keywords []string
conn *websocket.Conn
resultsCh chan TranscriptionResult
mu sync.Mutex
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
started bool
// reconnection config
maxRetries int
retryDelays []time.Duration
// track current item for transcription
currentItemID string
// finalization signaling
transcriptionDone chan struct{}
}
// OpenAI Realtime WebSocket message types (outgoing)
type openaiRealtimeSessionUpdate struct {
Type string `json:"type"`
Session openaiRealtimeSessionConfig `json:"session"`
}
type openaiRealtimeSessionConfig struct {
Modalities []string `json:"modalities,omitempty"`
InputAudioFormat string `json:"input_audio_format,omitempty"`
InputAudioTranscription *openaiRealtimeTranscription `json:"input_audio_transcription,omitempty"`
TurnDetection *openaiRealtimeTurnDetection `json:"turn_detection,omitempty"`
}
type openaiRealtimeTranscription struct {
Model string `json:"model,omitempty"`
Language string `json:"language,omitempty"`
Prompt string `json:"prompt,omitempty"`
}
type openaiRealtimeTurnDetection struct {
Type string `json:"type"`
Threshold float64 `json:"threshold,omitempty"`
PrefixPaddingMs int `json:"prefix_padding_ms,omitempty"`
SilenceDurationMs int `json:"silence_duration_ms,omitempty"`
CreateResponse bool `json:"create_response,omitempty"`
}
type openaiRealtimeInputAudioAppend struct {
Type string `json:"type"`
Audio string `json:"audio"`
}
type openaiRealtimeInputAudioCommit struct {
Type string `json:"type"`
}
// OpenAI Realtime WebSocket response types (incoming)
type openaiRealtimeServerEvent struct {
Type string `json:"type"`
EventID string `json:"event_id,omitempty"`
Session *openaiRealtimeSessionInfo `json:"session,omitempty"`
Error *openaiRealtimeError `json:"error,omitempty"`
ItemID string `json:"item_id,omitempty"`
ContentIndex int `json:"content_index,omitempty"`
Transcript string `json:"transcript,omitempty"`
Delta string `json:"delta,omitempty"`
}
type openaiRealtimeSessionInfo struct {
ID string `json:"id"`
Model string `json:"model"`
}
type openaiRealtimeError struct {
Type string `json:"type"`
Code string `json:"code,omitempty"`
Message string `json:"message"`
Param string `json:"param,omitempty"`
}
// NewOpenAIRealtimeAdapter creates a new streaming adapter for OpenAI Realtime API
// endpoint: the WebSocket endpoint config (e.g., wss://api.openai.com, /v1/realtime)
// apiKey: OpenAI API key
// model: model ID (e.g., "gpt-4o-realtime-preview")
// lang: canonical language code (will be used for transcription config)
func NewOpenAIRealtimeAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang string, keywords []string) *OpenAIRealtimeAdapter {
return &OpenAIRealtimeAdapter{
endpoint: endpoint,
apiKey: apiKey,
model: model,
language: lang,
keywords: keywords,
resultsCh: make(chan TranscriptionResult, 100),
maxRetries: 3,
retryDelays: defaultRetryDelays,
transcriptionDone: make(chan struct{}, 1),
}
}
// Start initiates the WebSocket connection to OpenAI Realtime API
func (a *OpenAIRealtimeAdapter) Start(ctx context.Context, lang string) error {
a.mu.Lock()
defer a.mu.Unlock()
if a.started {
return fmt.Errorf("adapter already started")
}
// use lang param if provided, otherwise use constructor lang
if lang != "" {
a.language = lang
}
// create cancelable context
a.ctx, a.cancel = context.WithCancel(ctx)
// connect to WebSocket
if err := a.connectLocked(); err != nil {
return err
}
a.started = true
// start reader goroutine
a.wg.Add(1)
go a.readLoop()
log.Printf("openai-realtime: connected, model=%s, language=%s", a.model, a.language)
return nil
}
// connectLocked establishes WebSocket connection and configures session. Must be called with mu held.
func (a *OpenAIRealtimeAdapter) connectLocked() error {
wsURL, err := a.buildURL()
if err != nil {
return fmt.Errorf("build websocket url: %w", err)
}
headers := http.Header{}
headers.Set("Authorization", "Bearer "+a.apiKey)
headers.Set("OpenAI-Beta", "realtime=v1")
log.Printf("openai-realtime: connecting to %s", wsURL)
conn, resp, err := websocket.DefaultDialer.DialContext(a.ctx, wsURL, headers)
if err != nil {
if resp != nil {
log.Printf("openai-realtime: dial failed with status %d", resp.StatusCode)
}
return fmt.Errorf("websocket dial: %w", err)
}
a.conn = conn
// configure session for transcription-only mode
if err := a.configureSession(); err != nil {
conn.Close()
a.conn = nil
return fmt.Errorf("configure session: %w", err)
}
return nil
}
// configureSession sends session.update to configure transcription mode
func (a *OpenAIRealtimeAdapter) configureSession() error {
// configure for transcription-only mode
// use server VAD to automatically detect speech and commit audio
sessionUpdate := openaiRealtimeSessionUpdate{
Type: "session.update",
Session: openaiRealtimeSessionConfig{
Modalities: []string{"text"}, // text only, no audio output
InputAudioFormat: "pcm16", // we send 16-bit PCM
InputAudioTranscription: &openaiRealtimeTranscription{
Model: "gpt-4o-transcribe", // use gpt-4o for input transcription
},
TurnDetection: &openaiRealtimeTurnDetection{
Type: "server_vad",
Threshold: 0.5,
PrefixPaddingMs: 300,
SilenceDurationMs: 500,
CreateResponse: false, // we don't want responses, just transcription
},
},
}
// add language if specified
if a.language != "" {
sessionUpdate.Session.InputAudioTranscription.Language = a.language
}
if len(a.keywords) > 0 {
sessionUpdate.Session.InputAudioTranscription.Prompt = strings.Join(a.keywords, ", ")
}
return a.conn.WriteJSON(sessionUpdate)
}
// reconnect attempts to re-establish the WebSocket connection with exponential backoff.
// Returns true if reconnection succeeded.
func (a *OpenAIRealtimeAdapter) reconnect() bool {
for attempt := 0; attempt < a.maxRetries; attempt++ {
// check if context cancelled
select {
case <-a.ctx.Done():
return false
default:
}
// wait before retry (skip wait on first attempt)
if attempt > 0 {
delay := a.retryDelays[attempt-1]
if attempt-1 >= len(a.retryDelays) {
delay = a.retryDelays[len(a.retryDelays)-1]
}
log.Printf("openai-realtime: reconnect attempt %d/%d after %v", attempt+1, a.maxRetries, delay)
select {
case <-a.ctx.Done():
return false
case <-time.After(delay):
}
} else {
log.Printf("openai-realtime: reconnect attempt %d/%d", attempt+1, a.maxRetries)
}
a.mu.Lock()
// close old connection if exists
if a.conn != nil {
a.conn.Close()
a.conn = nil
}
err := a.connectLocked()
a.mu.Unlock()
if err == nil {
log.Printf("openai-realtime: reconnected successfully")
// notify caller of brief interruption
select {
case a.resultsCh <- TranscriptionResult{Error: fmt.Errorf("connection interrupted, reconnected"), IsFinal: false}:
default:
}
return true
}
log.Printf("openai-realtime: reconnect failed: %v", err)
}
return false
}
// buildURL constructs the WebSocket URL with query parameters
func (a *OpenAIRealtimeAdapter) buildURL() (string, error) {
// parse base URL and path
baseURL := a.endpoint.BaseURL + a.endpoint.Path
u, err := url.Parse(baseURL)
if err != nil {
return "", fmt.Errorf("parse base url: %w", err)
}
// add model as query parameter
q := u.Query()
q.Set("model", a.model)
u.RawQuery = q.Encode()
return u.String(), nil
}
// readLoop reads messages from the WebSocket and sends results to the channel
func (a *OpenAIRealtimeAdapter) readLoop() {
defer a.wg.Done()
defer close(a.resultsCh)
for {
select {
case <-a.ctx.Done():
return
default:
}
a.mu.Lock()
conn := a.conn
a.mu.Unlock()
if conn == nil {
// no connection, try to reconnect
if !a.reconnect() {
a.resultsCh <- TranscriptionResult{Error: fmt.Errorf("connection lost, reconnection failed after %d attempts", a.maxRetries)}
return
}
continue
}
_, message, err := conn.ReadMessage()
if err != nil {
// check if context was cancelled (normal shutdown)
select {
case <-a.ctx.Done():
return
default:
}
// attempt reconnection
log.Printf("openai-realtime: read error: %v, attempting reconnection", err)
if !a.reconnect() {
a.resultsCh <- TranscriptionResult{Error: fmt.Errorf("websocket read: %w, reconnection failed", err)}
return
}
continue
}
// parse message
var event openaiRealtimeServerEvent
if err := json.Unmarshal(message, &event); err != nil {
log.Printf("openai-realtime: parse error: %v", err)
continue
}
// handle different event types
a.handleEvent(event)
}
}
// handleEvent processes incoming server events
func (a *OpenAIRealtimeAdapter) handleEvent(event openaiRealtimeServerEvent) {
switch event.Type {
case "session.created":
if event.Session != nil {
log.Printf("openai-realtime: session created, id=%s, model=%s", event.Session.ID, event.Session.Model)
}
case "session.updated":
log.Printf("openai-realtime: session updated")
case "error":
if event.Error != nil {
errMsg := event.Error.Message
if event.Error.Code != "" {
errMsg = fmt.Sprintf("%s: %s", event.Error.Code, errMsg)
}
log.Printf("openai-realtime: error: %s", errMsg)
a.resultsCh <- TranscriptionResult{Error: fmt.Errorf("openai: %s", errMsg)}
}
case "input_audio_buffer.speech_started":
log.Printf("openai-realtime: speech started")
case "input_audio_buffer.speech_stopped":
log.Printf("openai-realtime: speech stopped, item_id=%s", event.ItemID)
a.currentItemID = event.ItemID
case "input_audio_buffer.committed":
log.Printf("openai-realtime: audio committed, item_id=%s", event.ItemID)
a.currentItemID = event.ItemID
case "conversation.item.input_audio_transcription.delta":
// partial transcription result
if event.Delta != "" {
a.resultsCh <- TranscriptionResult{Text: event.Delta, IsFinal: false}
}
case "conversation.item.input_audio_transcription.completed":
// final transcription result
log.Printf("openai-realtime: transcription completed: %q", event.Transcript)
if event.Transcript != "" {
a.resultsCh <- TranscriptionResult{Text: event.Transcript, IsFinal: true}
}
// signal finalization (non-blocking)
select {
case a.transcriptionDone <- struct{}{}:
default:
}
case "conversation.item.input_audio_transcription.failed":
log.Printf("openai-realtime: transcription failed for item %s", event.ItemID)
if event.Error != nil {
a.resultsCh <- TranscriptionResult{Error: fmt.Errorf("transcription failed: %s", event.Error.Message)}
}
case "conversation.item.created", "conversation.item.added":
log.Printf("openai-realtime: conversation item created/added")
case "rate_limits.updated":
// ignore rate limit updates
default:
log.Printf("openai-realtime: unhandled event type: %s", event.Type)
}
}
// SendChunk sends audio data to the WebSocket
// OpenAI Realtime API expects base64-encoded PCM16 audio at 24kHz
// We receive 16kHz audio, so we need to resample
func (a *OpenAIRealtimeAdapter) SendChunk(audio []byte) error {
a.mu.Lock()
if !a.started {
a.mu.Unlock()
return fmt.Errorf("adapter not started")
}
conn := a.conn
a.mu.Unlock()
// check context
select {
case <-a.ctx.Done():
return a.ctx.Err()
default:
}
if conn == nil {
return fmt.Errorf("no connection")
}
// resample from 16kHz to 24kHz (OpenAI expects 24kHz)
resampled := resample16to24(audio)
// encode audio as base64
audioB64 := base64.StdEncoding.EncodeToString(resampled)
// create message
msg := openaiRealtimeInputAudioAppend{
Type: "input_audio_buffer.append",
Audio: audioB64,
}
// send as JSON
a.mu.Lock()
err := a.conn.WriteJSON(msg)
a.mu.Unlock()
if err != nil {
// attempt reconnection
log.Printf("openai-realtime: write error: %v, attempting reconnection", err)
if a.reconnect() {
// retry the chunk after reconnection
a.mu.Lock()
err = a.conn.WriteJSON(msg)
a.mu.Unlock()
if err == nil {
return nil
}
}
return fmt.Errorf("websocket write: %w", err)
}
return nil
}
// resample16to24 converts 16kHz PCM16 audio to 24kHz using linear interpolation
// Input: 16-bit PCM samples at 16kHz
// Output: 16-bit PCM samples at 24kHz
func resample16to24(input []byte) []byte {
if len(input) < 2 {
return input
}
// input has 16kHz samples (2 bytes each)
// output needs 24kHz samples (ratio 24/16 = 1.5)
numInputSamples := len(input) / 2
numOutputSamples := (numInputSamples * 3) / 2
output := make([]byte, numOutputSamples*2)
for i := 0; i < numOutputSamples; i++ {
// calculate position in input
srcPos := float64(i) * 16.0 / 24.0
srcIdx := int(srcPos)
frac := srcPos - float64(srcIdx)
// get source samples
var sample1, sample2 int16
if srcIdx*2+1 < len(input) {
sample1 = int16(input[srcIdx*2]) | (int16(input[srcIdx*2+1]) << 8)
}
if (srcIdx+1)*2+1 < len(input) {
sample2 = int16(input[(srcIdx+1)*2]) | (int16(input[(srcIdx+1)*2+1]) << 8)
} else {
sample2 = sample1
}
// linear interpolation
outSample := int16(float64(sample1)*(1-frac) + float64(sample2)*frac)
// write output sample (little-endian)
output[i*2] = byte(outSample)
output[i*2+1] = byte(outSample >> 8)
}
return output
}
// Results returns the channel for receiving transcription results
func (a *OpenAIRealtimeAdapter) Results() <-chan TranscriptionResult {
return a.resultsCh
}
// Finalize sends a commit message to force OpenAI to process any pending audio
// and waits for the transcription.completed response
func (a *OpenAIRealtimeAdapter) Finalize(ctx context.Context) error {
a.mu.Lock()
if !a.started {
a.mu.Unlock()
return nil
}
conn := a.conn
a.mu.Unlock()
if conn == nil {
return nil
}
// drain any previous transcription signals
select {
case <-a.transcriptionDone:
default:
}
// send input_audio_buffer.commit to force processing of pending audio
msg := openaiRealtimeInputAudioCommit{
Type: "input_audio_buffer.commit",
}
a.mu.Lock()
err := a.conn.WriteJSON(msg)
a.mu.Unlock()
if err != nil {
log.Printf("openai-realtime: finalize write error: %v", err)
return fmt.Errorf("finalize write: %w", err)
}
log.Printf("openai-realtime: sent commit, waiting for final transcription")
// wait for transcription.completed or timeout
select {
case <-a.transcriptionDone:
log.Printf("openai-realtime: finalize complete")
return nil
case <-ctx.Done():
log.Printf("openai-realtime: finalize timeout")
return ctx.Err()
case <-a.ctx.Done():
return a.ctx.Err()
}
}
// Close gracefully closes the WebSocket connection
func (a *OpenAIRealtimeAdapter) Close() error {
a.mu.Lock()
if !a.started {
a.mu.Unlock()
return nil
}
// cancel context first to signal reader to stop
if a.cancel != nil {
a.cancel()
}
// get conn ref while holding lock
conn := a.conn
a.started = false
a.mu.Unlock()
// close websocket outside of lock (readLoop may be blocked on read)
if conn != nil {
// send close frame (best effort)
_ = conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
conn.Close()
}
// wait for reader to finish
a.wg.Wait()
log.Printf("openai-realtime: closed")
return nil
}
@@ -0,0 +1,544 @@
package transcriber
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/gorilla/websocket"
"github.com/leonardotrapani/hyprvoice/internal/provider"
)
// mockOpenAIRealtimeServer creates a mock WebSocket server for OpenAI Realtime API
func mockOpenAIRealtimeServer(t *testing.T, handler func(*websocket.Conn)) *httptest.Server {
upgrader := websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// verify auth header
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
t.Errorf("expected Bearer auth header, got: %s", auth)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// verify model in query
model := r.URL.Query().Get("model")
if model == "" {
t.Error("expected model query parameter")
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
t.Errorf("upgrade failed: %v", err)
return
}
defer conn.Close()
handler(conn)
}))
}
func TestOpenAIRealtimeAdapter_ImplementsInterface(t *testing.T) {
var _ StreamingAdapter = (*OpenAIRealtimeAdapter)(nil)
}
func TestOpenAIRealtimeAdapter_Start(t *testing.T) {
var mu sync.Mutex
sessionCreated := false
sessionUpdated := false
server := mockOpenAIRealtimeServer(t, func(conn *websocket.Conn) {
// send session.created event
sessionCreatedEvent := map[string]interface{}{
"type": "session.created",
"event_id": "event_123",
"session": map[string]interface{}{
"id": "sess_123",
"model": "gpt-4o-realtime-preview",
},
}
if err := conn.WriteJSON(sessionCreatedEvent); err != nil {
t.Errorf("write session.created: %v", err)
}
mu.Lock()
sessionCreated = true
mu.Unlock()
// read session.update from client
_, msg, err := conn.ReadMessage()
if err != nil {
return
}
var update map[string]interface{}
if err := json.Unmarshal(msg, &update); err != nil {
t.Errorf("unmarshal session.update: %v", err)
return
}
if update["type"] != "session.update" {
t.Errorf("expected session.update, got %s", update["type"])
}
mu.Lock()
sessionUpdated = true
mu.Unlock()
// send session.updated response
sessionUpdatedEvent := map[string]interface{}{
"type": "session.updated",
"event_id": "event_124",
}
if err := conn.WriteJSON(sessionUpdatedEvent); err != nil {
t.Errorf("write session.updated: %v", err)
}
// keep connection open until client closes
for {
_, _, err := conn.ReadMessage()
if err != nil {
return
}
}
})
defer server.Close()
// extract host for endpoint
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
endpoint := &provider.EndpointConfig{
BaseURL: wsURL,
Path: "",
}
adapter := NewOpenAIRealtimeAdapter(endpoint, "sk-test-key", "gpt-4o-realtime-preview", "en", nil)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
err := adapter.Start(ctx, "")
if err != nil {
t.Fatalf("Start failed: %v", err)
}
defer adapter.Close()
// give time for events to process
time.Sleep(100 * time.Millisecond)
mu.Lock()
created := sessionCreated
updated := sessionUpdated
mu.Unlock()
if !created {
t.Error("session.created was not sent")
}
if !updated {
t.Error("session.update was not received by server")
}
}
func TestOpenAIRealtimeAdapter_SendChunk(t *testing.T) {
var mu sync.Mutex
receivedAudio := false
server := mockOpenAIRealtimeServer(t, func(conn *websocket.Conn) {
// send session.created
sessionCreatedEvent := map[string]interface{}{
"type": "session.created",
"session": map[string]interface{}{
"id": "sess_123",
},
}
conn.WriteJSON(sessionCreatedEvent)
// read session.update
conn.ReadMessage()
// send session.updated
conn.WriteJSON(map[string]interface{}{"type": "session.updated"})
// read audio chunk
_, msg, err := conn.ReadMessage()
if err != nil {
return
}
var audioMsg map[string]interface{}
if err := json.Unmarshal(msg, &audioMsg); err != nil {
t.Errorf("unmarshal audio: %v", err)
return
}
if audioMsg["type"] == "input_audio_buffer.append" {
audio, ok := audioMsg["audio"].(string)
if ok && len(audio) > 0 {
mu.Lock()
receivedAudio = true
mu.Unlock()
}
}
// keep connection open
for {
_, _, err := conn.ReadMessage()
if err != nil {
return
}
}
})
defer server.Close()
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
endpoint := &provider.EndpointConfig{BaseURL: wsURL, Path: ""}
adapter := NewOpenAIRealtimeAdapter(endpoint, "sk-test", "gpt-4o-realtime-preview", "", nil)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := adapter.Start(ctx, ""); err != nil {
t.Fatalf("Start failed: %v", err)
}
defer adapter.Close()
// give time for connection setup
time.Sleep(100 * time.Millisecond)
// send audio chunk (16kHz PCM16)
audio := make([]byte, 320) // 10ms of 16kHz audio
for i := range audio {
audio[i] = byte(i % 256)
}
if err := adapter.SendChunk(audio); err != nil {
t.Fatalf("SendChunk failed: %v", err)
}
// give time for message to be sent
time.Sleep(100 * time.Millisecond)
mu.Lock()
received := receivedAudio
mu.Unlock()
if !received {
t.Error("server did not receive audio chunk")
}
}
func TestOpenAIRealtimeAdapter_TranscriptionResults(t *testing.T) {
server := mockOpenAIRealtimeServer(t, func(conn *websocket.Conn) {
// send session.created
conn.WriteJSON(map[string]interface{}{"type": "session.created", "session": map[string]interface{}{"id": "sess_123"}})
// read session.update
conn.ReadMessage()
// send session.updated
conn.WriteJSON(map[string]interface{}{"type": "session.updated"})
// simulate transcription events
time.Sleep(50 * time.Millisecond)
// speech started
conn.WriteJSON(map[string]interface{}{
"type": "input_audio_buffer.speech_started",
})
// partial transcription
conn.WriteJSON(map[string]interface{}{
"type": "conversation.item.input_audio_transcription.delta",
"delta": "Hello",
})
// more partial
conn.WriteJSON(map[string]interface{}{
"type": "conversation.item.input_audio_transcription.delta",
"delta": " world",
})
// final transcription
conn.WriteJSON(map[string]interface{}{
"type": "conversation.item.input_audio_transcription.completed",
"transcript": "Hello world",
})
// keep connection open
for {
_, _, err := conn.ReadMessage()
if err != nil {
return
}
}
})
defer server.Close()
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
endpoint := &provider.EndpointConfig{BaseURL: wsURL, Path: ""}
adapter := NewOpenAIRealtimeAdapter(endpoint, "sk-test", "gpt-4o-realtime-preview", "en", nil)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := adapter.Start(ctx, ""); err != nil {
t.Fatalf("Start failed: %v", err)
}
defer adapter.Close()
results := adapter.Results()
// collect results
var partials []string
var finals []string
timeout := time.After(2 * time.Second)
for {
select {
case result, ok := <-results:
if !ok {
goto done
}
if result.Error != nil {
continue
}
if result.IsFinal {
finals = append(finals, result.Text)
} else {
partials = append(partials, result.Text)
}
if len(finals) > 0 {
goto done
}
case <-timeout:
goto done
}
}
done:
if len(partials) != 2 {
t.Errorf("expected 2 partial results, got %d: %v", len(partials), partials)
}
if len(finals) != 1 {
t.Errorf("expected 1 final result, got %d: %v", len(finals), finals)
}
if len(finals) > 0 && finals[0] != "Hello world" {
t.Errorf("expected final 'Hello world', got %q", finals[0])
}
}
func TestOpenAIRealtimeAdapter_ErrorHandling(t *testing.T) {
server := mockOpenAIRealtimeServer(t, func(conn *websocket.Conn) {
// send session.created
conn.WriteJSON(map[string]interface{}{"type": "session.created", "session": map[string]interface{}{"id": "sess_123"}})
// read session.update
conn.ReadMessage()
// send session.updated
conn.WriteJSON(map[string]interface{}{"type": "session.updated"})
// send error event
time.Sleep(50 * time.Millisecond)
conn.WriteJSON(map[string]interface{}{
"type": "error",
"error": map[string]interface{}{
"type": "invalid_request_error",
"code": "invalid_audio",
"message": "Audio format is invalid",
},
})
// keep connection open
for {
_, _, err := conn.ReadMessage()
if err != nil {
return
}
}
})
defer server.Close()
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
endpoint := &provider.EndpointConfig{BaseURL: wsURL, Path: ""}
adapter := NewOpenAIRealtimeAdapter(endpoint, "sk-test", "gpt-4o-realtime-preview", "", nil)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := adapter.Start(ctx, ""); err != nil {
t.Fatalf("Start failed: %v", err)
}
defer adapter.Close()
results := adapter.Results()
// wait for error
select {
case result := <-results:
if result.Error == nil {
t.Error("expected error result")
}
if !strings.Contains(result.Error.Error(), "invalid_audio") {
t.Errorf("expected error containing 'invalid_audio', got: %v", result.Error)
}
case <-time.After(2 * time.Second):
t.Error("timeout waiting for error result")
}
}
func TestOpenAIRealtimeAdapter_Reconnection(t *testing.T) {
connectCount := 0
var mu sync.Mutex
server := mockOpenAIRealtimeServer(t, func(conn *websocket.Conn) {
mu.Lock()
connectCount++
count := connectCount
mu.Unlock()
// send session.created
conn.WriteJSON(map[string]interface{}{"type": "session.created", "session": map[string]interface{}{"id": "sess_" + string(rune('0'+count))}})
// read session.update
conn.ReadMessage()
// send session.updated
conn.WriteJSON(map[string]interface{}{"type": "session.updated"})
// first connection: close immediately to trigger reconnect
if count == 1 {
time.Sleep(50 * time.Millisecond)
conn.Close()
return
}
// second connection: stay open
for {
_, _, err := conn.ReadMessage()
if err != nil {
return
}
}
})
defer server.Close()
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
endpoint := &provider.EndpointConfig{BaseURL: wsURL, Path: ""}
adapter := NewOpenAIRealtimeAdapter(endpoint, "sk-test", "gpt-4o-realtime-preview", "", nil)
adapter.retryDelays = []time.Duration{10 * time.Millisecond, 20 * time.Millisecond, 40 * time.Millisecond}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := adapter.Start(ctx, ""); err != nil {
t.Fatalf("Start failed: %v", err)
}
defer adapter.Close()
// wait for reconnection
time.Sleep(500 * time.Millisecond)
mu.Lock()
finalCount := connectCount
mu.Unlock()
if finalCount < 2 {
t.Errorf("expected at least 2 connections (reconnection), got %d", finalCount)
}
}
func TestOpenAIRealtimeAdapter_Close(t *testing.T) {
server := mockOpenAIRealtimeServer(t, func(conn *websocket.Conn) {
conn.WriteJSON(map[string]interface{}{"type": "session.created", "session": map[string]interface{}{"id": "sess_123"}})
conn.ReadMessage()
conn.WriteJSON(map[string]interface{}{"type": "session.updated"})
for {
_, _, err := conn.ReadMessage()
if err != nil {
return
}
}
})
defer server.Close()
wsURL := "ws" + strings.TrimPrefix(server.URL, "http")
endpoint := &provider.EndpointConfig{BaseURL: wsURL, Path: ""}
adapter := NewOpenAIRealtimeAdapter(endpoint, "sk-test", "gpt-4o-realtime-preview", "", nil)
ctx := context.Background()
if err := adapter.Start(ctx, ""); err != nil {
t.Fatalf("Start failed: %v", err)
}
// close should not block or panic
err := adapter.Close()
if err != nil {
t.Errorf("Close returned error: %v", err)
}
// results channel should be closed
select {
case _, ok := <-adapter.Results():
if ok {
// drain any remaining results
for range adapter.Results() {
}
}
case <-time.After(time.Second):
t.Error("results channel not closed after Close()")
}
}
func TestResample16to24(t *testing.T) {
// test with simple audio data
input := make([]byte, 32) // 16 samples at 16kHz
for i := 0; i < 16; i++ {
// write sample value (little-endian)
sample := int16(i * 1000)
input[i*2] = byte(sample)
input[i*2+1] = byte(sample >> 8)
}
output := resample16to24(input)
// 16 samples at 16kHz = 24 samples at 24kHz (ratio 1.5)
expectedSamples := 24
if len(output) != expectedSamples*2 {
t.Errorf("expected %d bytes, got %d", expectedSamples*2, len(output))
}
// output should have reasonable values (interpolated)
for i := 0; i < expectedSamples; i++ {
sample := int16(output[i*2]) | (int16(output[i*2+1]) << 8)
if sample < -32768 || sample > 32767 {
t.Errorf("sample %d out of range: %d", i, sample)
}
}
}
func TestResample16to24_EmptyInput(t *testing.T) {
output := resample16to24([]byte{})
if len(output) != 0 {
t.Errorf("expected empty output for empty input, got %d bytes", len(output))
}
}
func TestResample16to24_SingleSample(t *testing.T) {
input := []byte{0x00, 0x10} // single sample
output := resample16to24(input)
// with only 1 sample, output should be minimal
if len(output) == 0 {
t.Error("expected non-empty output for single sample")
}
}
+108
View File
@@ -0,0 +1,108 @@
package transcriber
import (
"bytes"
"context"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// WhisperCppAdapter implements BatchAdapter for local whisper-cpp transcription
type WhisperCppAdapter struct {
modelPath string
language string
threads int
}
// NewWhisperCppAdapter creates a new whisper-cpp adapter
// modelPath: full path to the model file (e.g., ~/.local/share/hyprvoice/models/whisper/ggml-base.en.bin)
// lang: whisper-cpp language code
// threads: number of CPU threads (0 for auto)
func NewWhisperCppAdapter(modelPath, lang string, threads int) *WhisperCppAdapter {
return &WhisperCppAdapter{
modelPath: modelPath,
language: lang,
threads: threads,
}
}
func (a *WhisperCppAdapter) Transcribe(ctx context.Context, audioData []byte) (string, error) {
if len(audioData) == 0 {
return "", nil
}
// check model file exists
if _, err := os.Stat(a.modelPath); os.IsNotExist(err) {
return "", fmt.Errorf("model file not found: %s", a.modelPath)
}
// check whisper-cli exists
whisperPath, err := exec.LookPath("whisper-cli")
if err != nil {
return "", fmt.Errorf("whisper-cli not found: install whisper.cpp first")
}
// convert raw PCM to WAV
wavData, err := convertToWAV(audioData)
if err != nil {
return "", fmt.Errorf("convert to WAV: %w", err)
}
// write to temp file
tmpDir := os.TempDir()
tmpFile := filepath.Join(tmpDir, fmt.Sprintf("hyprvoice-%d.wav", time.Now().UnixNano()))
if err := os.WriteFile(tmpFile, wavData, 0600); err != nil {
return "", fmt.Errorf("write temp file: %w", err)
}
defer os.Remove(tmpFile)
// use whisper-cpp auto if unspecified
lang := a.language
if lang == "" {
lang = "auto"
}
// build command args
args := []string{
"-m", a.modelPath,
"-l", lang,
"-nt", // no timestamps
"-np", // no progress
"-f", tmpFile,
}
// add threads if specified
if a.threads > 0 {
args = append(args, "-t", fmt.Sprintf("%d", a.threads))
}
// execute whisper-cli
cmd := exec.CommandContext(ctx, whisperPath, args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
start := time.Now()
err = cmd.Run()
duration := time.Since(start)
if err != nil {
// check if context was cancelled
if ctx.Err() != nil {
return "", ctx.Err()
}
log.Printf("whisper-cpp: command failed after %v: %v\nstderr: %s", duration, err, stderr.String())
return "", fmt.Errorf("whisper-cli failed: %w", err)
}
// parse output - whisper-cli outputs transcription text directly (with -nt flag)
text := strings.TrimSpace(stdout.String())
log.Printf("whisper-cpp: transcribed %d bytes in %v: %q", len(audioData), duration, text)
return text, nil
}
@@ -0,0 +1,149 @@
package transcriber
import (
"context"
"os"
"os/exec"
"path/filepath"
"testing"
)
func TestWhisperCppAdapter_ImplementsBatchAdapter(t *testing.T) {
// compile-time check that WhisperCppAdapter implements BatchAdapter
var _ BatchAdapter = (*WhisperCppAdapter)(nil)
}
func TestWhisperCppAdapter_EmptyAudio(t *testing.T) {
adapter := NewWhisperCppAdapter("/nonexistent/model.bin", "en", 4)
text, err := adapter.Transcribe(context.Background(), []byte{})
if err != nil {
t.Errorf("expected no error for empty audio, got: %v", err)
}
if text != "" {
t.Errorf("expected empty text for empty audio, got: %q", text)
}
}
func TestWhisperCppAdapter_MissingModel(t *testing.T) {
adapter := NewWhisperCppAdapter("/nonexistent/path/model.bin", "en", 4)
// create minimal valid PCM data (just zeros)
audioData := make([]byte, 32000) // 1 second at 16kHz 16-bit
_, err := adapter.Transcribe(context.Background(), audioData)
if err == nil {
t.Error("expected error for missing model file")
}
if err != nil && !contains(err.Error(), "model file not found") {
t.Errorf("expected 'model file not found' error, got: %v", err)
}
}
func TestWhisperCppAdapter_MissingCli(t *testing.T) {
if _, err := exec.LookPath("whisper-cli"); err == nil {
t.Skip("whisper-cli is installed")
}
tmpDir := t.TempDir()
modelPath := filepath.Join(tmpDir, "model.bin")
if err := os.WriteFile(modelPath, []byte("fake"), 0600); err != nil {
t.Fatalf("failed to create model: %v", err)
}
adapter := NewWhisperCppAdapter(modelPath, "en", 4)
audioData := make([]byte, 32000)
_, err := adapter.Transcribe(context.Background(), audioData)
if err == nil {
t.Error("expected error for missing whisper-cli")
}
if err != nil && !contains(err.Error(), "whisper-cli not found") {
t.Errorf("expected 'whisper-cli not found' error, got: %v", err)
}
}
func TestWhisperCppAdapter_LanguageConversion(t *testing.T) {
// verify adapter stores language for later conversion
adapter := NewWhisperCppAdapter("/fake/model.bin", "", 4)
if adapter.language != "" {
t.Errorf("expected empty language for auto, got: %q", adapter.language)
}
adapter = NewWhisperCppAdapter("/fake/model.bin", "en", 4)
if adapter.language != "en" {
t.Errorf("expected 'en' language, got: %q", adapter.language)
}
}
func TestWhisperCppAdapter_ThreadsConfig(t *testing.T) {
adapter := NewWhisperCppAdapter("/fake/model.bin", "en", 0)
if adapter.threads != 0 {
t.Errorf("expected threads=0 (auto), got: %d", adapter.threads)
}
adapter = NewWhisperCppAdapter("/fake/model.bin", "en", 8)
if adapter.threads != 8 {
t.Errorf("expected threads=8, got: %d", adapter.threads)
}
}
func TestWhisperCppAdapter_TempFileCleanup(t *testing.T) {
// this test requires whisper-cli and a model to be installed
// skip if not available
modelPath := os.Getenv("WHISPER_TEST_MODEL")
if modelPath == "" {
t.Skip("WHISPER_TEST_MODEL not set, skipping temp file cleanup test")
}
adapter := NewWhisperCppAdapter(modelPath, "en", 4)
// create minimal audio data
audioData := make([]byte, 32000)
// run transcription
_, _ = adapter.Transcribe(context.Background(), audioData)
// check that temp file was cleaned up
// (we can't easily verify this without modifying the adapter to expose temp path)
// this is more of a visual/log verification
}
func TestWhisperCppAdapter_ContextCancellation(t *testing.T) {
// skip if whisper-cli not installed
if _, err := os.Stat("/usr/local/bin/whisper-cli"); os.IsNotExist(err) {
t.Skip("whisper-cli not installed")
}
// create a fake model file for this test
tmpDir := t.TempDir()
fakeModel := filepath.Join(tmpDir, "fake.bin")
if err := os.WriteFile(fakeModel, []byte("fake"), 0600); err != nil {
t.Fatalf("failed to create fake model: %v", err)
}
adapter := NewWhisperCppAdapter(fakeModel, "en", 4)
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel immediately
// create minimal audio data
audioData := make([]byte, 32000)
_, err := adapter.Transcribe(ctx, audioData)
if err == nil {
t.Error("expected error for cancelled context")
}
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr))
}
func containsHelper(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
+15
View File
@@ -0,0 +1,15 @@
package transcriber
import "strings"
func normalizeDeepgramLanguage(code string) string {
if code == "" {
return ""
}
if strings.EqualFold(code, "en") || strings.EqualFold(code, "en-us") || strings.EqualFold(code, "en_us") {
return "en-US"
}
return code
}
+34
View File
@@ -0,0 +1,34 @@
package transcriber
import "errors"
// FatalTranscriptionError marks an error as non-recoverable for the current session.
type FatalTranscriptionError struct {
Err error
}
func (e *FatalTranscriptionError) Error() string {
if e == nil || e.Err == nil {
return "fatal transcription error"
}
return e.Err.Error()
}
func (e *FatalTranscriptionError) Unwrap() error {
if e == nil {
return nil
}
return e.Err
}
func NewFatalTranscriptionError(err error) error {
if err == nil {
return nil
}
return &FatalTranscriptionError{Err: err}
}
func IsFatalTranscriptionError(err error) bool {
var fatal *FatalTranscriptionError
return errors.As(err, &fatal)
}
+2 -2
View File
@@ -11,7 +11,7 @@ import (
// SimpleTranscriber collects all audio and transcribes when stopped // SimpleTranscriber collects all audio and transcribes when stopped
type SimpleTranscriber struct { type SimpleTranscriber struct {
adapter TranscriptionAdapter adapter BatchAdapter
config Config config Config
// Audio collection // Audio collection
@@ -27,7 +27,7 @@ type SimpleTranscriber struct {
transcriptionText string transcriptionText string
} }
func NewSimpleTranscriber(config Config, adapter TranscriptionAdapter) *SimpleTranscriber { func NewSimpleTranscriber(config Config, adapter BatchAdapter) *SimpleTranscriber {
return &SimpleTranscriber{ return &SimpleTranscriber{
adapter: adapter, adapter: adapter,
config: config, config: config,
+30
View File
@@ -0,0 +1,30 @@
package transcriber
import "context"
// TranscriptionResult represents a single transcription result from a streaming adapter
type TranscriptionResult struct {
Text string // the transcription text (partial or final)
IsFinal bool // true if this is a final result, false for interim results
Error error // non-nil if an error occurred
}
// StreamingAdapter interface for streaming transcription backends (send audio in real-time)
type StreamingAdapter interface {
// Start initiates the streaming connection with the given language setting
Start(ctx context.Context, language string) error
// SendChunk sends a chunk of audio data to the transcription service
SendChunk(audio []byte) error
// Results returns a channel that receives transcription results (partial and final)
Results() <-chan TranscriptionResult
// Finalize signals end of audio input and waits for final transcription results.
// This should be called before Close to ensure all pending audio is committed.
// The ctx controls the timeout for waiting on final results.
Finalize(ctx context.Context) error
// Close gracefully closes the streaming connection
Close() error
}
@@ -0,0 +1,223 @@
package transcriber
import (
"context"
"errors"
"log"
"strings"
"sync"
"time"
"github.com/leonardotrapani/hyprvoice/internal/recording"
)
// StreamingTranscriber wraps a StreamingAdapter and implements the Transcriber interface.
// It streams audio chunks to the adapter in real-time and accumulates transcription results.
type StreamingTranscriber struct {
adapter StreamingAdapter
language string
// accumulated final text
finalText strings.Builder
mu sync.Mutex
fatalErr error
// coordination
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
}
func NewStreamingTranscriber(adapter StreamingAdapter, language string) *StreamingTranscriber {
return &StreamingTranscriber{
adapter: adapter,
language: language,
}
}
func (t *StreamingTranscriber) Start(ctx context.Context, frameCh <-chan recording.AudioFrame) (<-chan error, error) {
t.ctx, t.cancel = context.WithCancel(ctx)
if err := t.adapter.Start(t.ctx, t.language); err != nil {
t.cancel()
return nil, err
}
errCh := make(chan error, 2)
// goroutine 1: read audio frames and send to adapter
t.wg.Add(1)
go t.sendAudio(frameCh, errCh)
// goroutine 2: read results from adapter and accumulate
t.wg.Add(1)
go t.receiveResults(errCh)
return errCh, nil
}
func (t *StreamingTranscriber) sendAudio(frameCh <-chan recording.AudioFrame, errCh chan<- error) {
defer t.wg.Done()
for {
select {
case <-t.ctx.Done():
return
case frame, ok := <-frameCh:
if !ok {
return
}
if err := t.adapter.SendChunk(frame.Data); err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
if t.ctx.Err() == nil && t.cancel != nil {
t.cancel()
}
return
}
if IsFatalTranscriptionError(err) {
if t.setFatalErr(err) {
select {
case errCh <- err:
default:
}
}
if t.cancel != nil {
t.cancel()
}
return
}
select {
case errCh <- err:
default:
}
// don't treat send errors as fatal - adapter may handle reconnection
log.Printf("streaming transcriber: send error: %v", err)
}
}
}
}
func (t *StreamingTranscriber) receiveResults(errCh chan<- error) {
defer t.wg.Done()
resultsCh := t.adapter.Results()
for {
select {
case <-t.ctx.Done():
// context cancelled, drain any remaining results before exiting
t.drainRemainingResults(resultsCh)
return
case result, ok := <-resultsCh:
if !ok {
return
}
t.processResult(result, errCh)
}
}
}
func (t *StreamingTranscriber) processResult(result TranscriptionResult, errCh chan<- error) {
if result.Error != nil {
if IsFatalTranscriptionError(result.Error) {
if t.setFatalErr(result.Error) {
select {
case errCh <- result.Error:
default:
}
}
log.Printf("streaming transcriber: result error: %v", result.Error)
if t.cancel != nil {
t.cancel()
}
return
}
select {
case errCh <- result.Error:
default:
}
log.Printf("streaming transcriber: result error: %v", result.Error)
return
}
if result.IsFinal && result.Text != "" {
t.mu.Lock()
if t.finalText.Len() > 0 {
t.finalText.WriteString(" ")
}
t.finalText.WriteString(result.Text)
t.mu.Unlock()
}
}
func (t *StreamingTranscriber) drainRemainingResults(resultsCh <-chan TranscriptionResult) {
// give a short window to collect any final results already in the channel
timeout := time.After(100 * time.Millisecond)
for {
select {
case result, ok := <-resultsCh:
if !ok {
return
}
if result.IsFinal && result.Text != "" {
t.mu.Lock()
if t.finalText.Len() > 0 {
t.finalText.WriteString(" ")
}
t.finalText.WriteString(result.Text)
t.mu.Unlock()
}
case <-timeout:
return
}
}
}
func (t *StreamingTranscriber) Stop(ctx context.Context) error {
// finalize adapter first to commit pending audio and wait for final results
// this must happen before canceling context so receiveResults can collect them
if err := t.adapter.Finalize(ctx); err != nil {
log.Printf("streaming transcriber: finalize error (continuing): %v", err)
}
// now cancel context to stop goroutines
if t.cancel != nil {
t.cancel()
}
// wait for goroutines to finish
t.wg.Wait()
// close the adapter
closeErr := t.adapter.Close()
if fatalErr := t.getFatalErr(); fatalErr != nil {
return fatalErr
}
return closeErr
}
func (t *StreamingTranscriber) GetFinalTranscription() (string, error) {
t.mu.Lock()
defer t.mu.Unlock()
if t.fatalErr != nil {
return "", t.fatalErr
}
return t.finalText.String(), nil
}
func (t *StreamingTranscriber) setFatalErr(err error) bool {
if err == nil {
return false
}
t.mu.Lock()
defer t.mu.Unlock()
if t.fatalErr != nil {
return false
}
t.fatalErr = err
return true
}
func (t *StreamingTranscriber) getFatalErr() error {
t.mu.Lock()
defer t.mu.Unlock()
return t.fatalErr
}
+112 -46
View File
@@ -4,6 +4,11 @@ import (
"context" "context"
"fmt" "fmt"
"golang.org/x/text/cases"
"golang.org/x/text/language"
"github.com/leonardotrapani/hyprvoice/internal/models/whisper"
"github.com/leonardotrapani/hyprvoice/internal/provider"
"github.com/leonardotrapani/hyprvoice/internal/recording" "github.com/leonardotrapani/hyprvoice/internal/recording"
) )
@@ -14,61 +19,122 @@ type Transcriber interface {
GetFinalTranscription() (string, error) GetFinalTranscription() (string, error)
} }
// Adapter interface for different transcription backends // BatchAdapter interface for batch transcription backends (collect all audio, transcribe at end)
type TranscriptionAdapter interface { type BatchAdapter interface {
Transcribe(ctx context.Context, audioData []byte) (string, error) Transcribe(ctx context.Context, audioData []byte) (string, error)
} }
// Configuration for the transcriber // Configuration for the transcriber
type Config struct { type Config struct {
Provider string Provider string
APIKey string APIKey string
Language string Language string
Model string Model string
Keywords []string
Threads int // CPU threads for local transcription (0 = auto)
Streaming bool // use streaming mode if model supports it
} }
// NewTranscriber creates a new simple transcriber // NewTranscriber creates a new transcriber based on model metadata
func NewTranscriber(config Config) (Transcriber, error) { func NewTranscriber(config Config) (Transcriber, error) {
// Create the appropriate adapter if config.Provider == "" {
var adapter TranscriptionAdapter return nil, fmt.Errorf("provider is required")
switch config.Provider {
case "openai":
if config.APIKey == "" {
return nil, fmt.Errorf("OpenAI API key required")
}
adapter = NewOpenAIAdapter(config)
case "groq-transcription":
if config.APIKey == "" {
return nil, fmt.Errorf("Groq API key required")
}
adapter = NewGroqTranscriptionAdapter(config)
case "groq-translation":
if config.APIKey == "" {
return nil, fmt.Errorf("Groq API key required")
}
adapter = NewGroqTranslationAdapter(config)
case "mistral-transcription":
if config.APIKey == "" {
return nil, fmt.Errorf("Mistral API key required")
}
adapter = NewMistralAdapter(config)
case "elevenlabs":
if config.APIKey == "" {
return nil, fmt.Errorf("ElevenLabs API key required")
}
adapter = NewElevenLabsAdapter(config)
default:
return nil, fmt.Errorf("unsupported provider: %s", config.Provider)
} }
// Create simple transcriber that collects all audio // map config provider name to registry provider name
transcriber := NewSimpleTranscriber(config, adapter) registryProvider := provider.BaseProviderName(config.Provider)
return transcriber, nil // lookup provider
p := provider.GetProvider(registryProvider)
if p == nil {
return nil, fmt.Errorf("unknown provider: %s", config.Provider)
}
// check API key requirement
if p.RequiresAPIKey() && config.APIKey == "" {
return nil, fmt.Errorf("%s API key required", cases.Title(language.English).String(registryProvider))
}
// lookup model from provider
model, err := provider.GetModel(registryProvider, config.Model)
if err != nil {
// if model not found, try to use default model
if config.Model == "" {
defaultModel := p.DefaultModel(provider.Transcription)
if defaultModel != "" {
model, err = provider.GetModel(registryProvider, defaultModel)
}
}
if err != nil || model == nil {
return nil, fmt.Errorf("model not found: %s (provider: %s)", config.Model, config.Provider)
}
}
// check model type
if model.Type != provider.Transcription {
return nil, fmt.Errorf("model %s is not a transcription model", config.Model)
}
if config.Language != "" && !model.SupportsLanguage(config.Language) {
return nil, fmt.Errorf("model %s does not support language %s", model.ID, config.Language)
}
// validate streaming/batch mode compatibility
if config.Streaming && !model.SupportsStreaming {
return nil, fmt.Errorf("model %s does not support streaming mode", model.ID)
}
if !config.Streaming && !model.SupportsBatch {
return nil, fmt.Errorf("model %s requires streaming mode (set streaming = true in config)", model.ID)
}
useStreaming := config.Streaming
// streaming mode: use StreamingTranscriber
if useStreaming {
// pick the right adapter type for streaming
adapterType := model.AdapterType
if model.StreamingAdapter != "" {
adapterType = model.StreamingAdapter
}
// pick the right endpoint for streaming
endpoint := model.Endpoint
if model.StreamingEndpoint != nil {
endpoint = model.StreamingEndpoint
}
var streamingAdapter StreamingAdapter
switch adapterType {
case provider.AdapterElevenLabsStream:
streamingAdapter = NewElevenLabsStreamingAdapter(endpoint, config.APIKey, model.ID, config.Language, config.Keywords)
case provider.AdapterDeepgram:
streamingAdapter = NewDeepgramAdapter(endpoint, config.APIKey, model.ID, config.Language, config.Keywords)
case provider.AdapterOpenAIRealtime:
streamingAdapter = NewOpenAIRealtimeAdapter(endpoint, config.APIKey, model.ID, config.Language, config.Keywords)
default:
return nil, fmt.Errorf("unsupported streaming adapter type: %s", adapterType)
}
return NewStreamingTranscriber(streamingAdapter, config.Language), nil
}
// batch mode: use SimpleTranscriber
var adapter BatchAdapter
switch model.AdapterType {
case provider.AdapterOpenAI:
adapter = NewOpenAIAdapter(model.Endpoint, config.APIKey, model.ID, config.Language, config.Keywords, registryProvider)
case provider.AdapterElevenLabs:
adapter = NewElevenLabsAdapter(model.Endpoint, config.APIKey, model.ID, config.Language, config.Keywords)
case provider.AdapterDeepgram:
adapter = NewDeepgramBatchAdapter(model.Endpoint, config.APIKey, model.ID, config.Language, config.Keywords)
case provider.AdapterWhisperCpp:
modelPath := whisper.GetModelPath(config.Model)
if modelPath == "" {
return nil, fmt.Errorf("unknown whisper model: %s", config.Model)
}
adapter = NewWhisperCppAdapter(modelPath, config.Language, config.Threads)
default:
return nil, fmt.Errorf("unsupported adapter type: %s", model.AdapterType)
}
return NewSimpleTranscriber(config, adapter), nil
} }
+586 -35
View File
@@ -3,9 +3,11 @@ package transcriber
import ( import (
"context" "context"
"fmt" "fmt"
"strings"
"testing" "testing"
"time" "time"
"github.com/leonardotrapani/hyprvoice/internal/provider"
"github.com/leonardotrapani/hyprvoice/internal/recording" "github.com/leonardotrapani/hyprvoice/internal/recording"
) )
@@ -55,26 +57,6 @@ func TestNewTranscriber(t *testing.T) {
}, },
wantErr: true, wantErr: true,
}, },
{
name: "valid groq-translation config",
config: Config{
Provider: "groq-translation",
APIKey: "gsk-test-key",
Language: "es",
Model: "whisper-large-v3-turbo",
},
wantErr: false,
},
{
name: "groq-translation config without api key",
config: Config{
Provider: "groq-translation",
APIKey: "",
Language: "es",
Model: "whisper-large-v3-turbo",
},
wantErr: true,
},
{ {
name: "valid mistral-transcription config", name: "valid mistral-transcription config",
config: Config{ config: Config{
@@ -100,7 +82,7 @@ func TestNewTranscriber(t *testing.T) {
config: Config{ config: Config{
Provider: "elevenlabs", Provider: "elevenlabs",
APIKey: "test-key", APIKey: "test-key",
Language: "en", Language: "eng", // ElevenLabs uses ISO 639-3
Model: "scribe_v1", Model: "scribe_v1",
}, },
wantErr: false, wantErr: false,
@@ -110,7 +92,7 @@ func TestNewTranscriber(t *testing.T) {
config: Config{ config: Config{
Provider: "elevenlabs", Provider: "elevenlabs",
APIKey: "test-key", APIKey: "test-key",
Language: "pt", Language: "por", // ElevenLabs uses ISO 639-3
Model: "scribe_v2", Model: "scribe_v2",
}, },
wantErr: false, wantErr: false,
@@ -120,7 +102,7 @@ func TestNewTranscriber(t *testing.T) {
config: Config{ config: Config{
Provider: "elevenlabs", Provider: "elevenlabs",
APIKey: "", APIKey: "",
Language: "en", Language: "eng",
Model: "scribe_v1", Model: "scribe_v1",
}, },
wantErr: true, wantErr: true,
@@ -144,14 +126,97 @@ func TestNewTranscriber(t *testing.T) {
wantErr: true, wantErr: true,
}, },
{ {
name: "empty model", name: "empty model uses default",
config: Config{ config: Config{
Provider: "openai", Provider: "openai",
APIKey: "test-key", APIKey: "test-key",
Language: "en", Language: "en",
Model: "", Model: "",
}, },
wantErr: false, // Model validation is not implemented in NewTranscriber wantErr: false, // uses default model when empty
},
{
name: "elevenlabs streaming model creates StreamingTranscriber",
config: Config{
Provider: "elevenlabs",
APIKey: "test-key",
Language: "eng", // ElevenLabs uses ISO 639-3
Model: "scribe_v2_realtime",
Streaming: true,
},
wantErr: false,
},
{
name: "elevenlabs batch model with streaming enabled fails",
config: Config{
Provider: "elevenlabs",
APIKey: "test-key",
Language: "eng", // ElevenLabs uses ISO 639-3
Model: "scribe_v2",
Streaming: true,
},
wantErr: true,
},
{
name: "deepgram streaming model creates StreamingTranscriber",
config: Config{
Provider: "deepgram",
APIKey: "test-key",
Language: "en",
Model: "nova-3",
Streaming: true,
},
wantErr: false,
},
{
name: "openai streaming model creates StreamingTranscriber",
config: Config{
Provider: "openai",
APIKey: "test-key",
Language: "en",
Model: "gpt-4o-realtime-preview",
Streaming: true,
},
wantErr: false,
},
{
name: "unknown model returns error",
config: Config{
Provider: "openai",
APIKey: "test-key",
Language: "en",
Model: "nonexistent-model",
},
wantErr: true,
},
{
name: "valid whisper-cpp config creates adapter",
config: Config{
Provider: "whisper-cpp",
Language: "en",
Model: "base.en",
Threads: 4,
},
wantErr: false, // creates adapter even if model file doesn't exist (runtime check)
},
{
name: "whisper-cpp without api key is valid",
config: Config{
Provider: "whisper-cpp",
APIKey: "", // no api key required
Language: "en",
Model: "tiny.en",
},
wantErr: false,
},
{
name: "whisper-cpp with unknown model returns error",
config: Config{
Provider: "whisper-cpp",
Language: "en",
Model: "nonexistent-whisper-model",
},
wantErr: true,
}, },
} }
@@ -195,12 +260,12 @@ func TestConfig(t *testing.T) {
} }
} }
// MockTranscriptionAdapter implements TranscriptionAdapter for testing // MockBatchAdapter implements BatchAdapter for testing
type MockTranscriptionAdapter struct { type MockBatchAdapter struct {
TranscribeFunc func(ctx context.Context, audioData []byte) (string, error) TranscribeFunc func(ctx context.Context, audioData []byte) (string, error)
} }
func (m *MockTranscriptionAdapter) Transcribe(ctx context.Context, audioData []byte) (string, error) { func (m *MockBatchAdapter) Transcribe(ctx context.Context, audioData []byte) (string, error) {
if m.TranscribeFunc != nil { if m.TranscribeFunc != nil {
return m.TranscribeFunc(ctx, audioData) return m.TranscribeFunc(ctx, audioData)
} }
@@ -215,7 +280,7 @@ func TestSimpleTranscriber_Start(t *testing.T) {
Model: "whisper-1", Model: "whisper-1",
} }
adapter := &MockTranscriptionAdapter{} adapter := &MockBatchAdapter{}
transcriber := NewSimpleTranscriber(config, adapter) transcriber := NewSimpleTranscriber(config, adapter)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
@@ -255,7 +320,7 @@ func TestSimpleTranscriber_Stop(t *testing.T) {
Model: "whisper-1", Model: "whisper-1",
} }
adapter := &MockTranscriptionAdapter{} adapter := &MockBatchAdapter{}
transcriber := NewSimpleTranscriber(config, adapter) transcriber := NewSimpleTranscriber(config, adapter)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
@@ -299,7 +364,7 @@ func TestSimpleTranscriber_GetFinalTranscription(t *testing.T) {
Model: "whisper-1", Model: "whisper-1",
} }
adapter := &MockTranscriptionAdapter{ adapter := &MockBatchAdapter{
TranscribeFunc: func(ctx context.Context, audioData []byte) (string, error) { TranscribeFunc: func(ctx context.Context, audioData []byte) (string, error) {
return "test transcription", nil return "test transcription", nil
}, },
@@ -327,7 +392,7 @@ func TestSimpleTranscriber_CollectAudio(t *testing.T) {
Model: "whisper-1", Model: "whisper-1",
} }
adapter := &MockTranscriptionAdapter{} adapter := &MockBatchAdapter{}
transcriber := NewSimpleTranscriber(config, adapter) transcriber := NewSimpleTranscriber(config, adapter)
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
@@ -411,7 +476,7 @@ func TestSimpleTranscriber_TranscribeAll(t *testing.T) {
Model: "whisper-1", Model: "whisper-1",
} }
adapter := &MockTranscriptionAdapter{ adapter := &MockBatchAdapter{
TranscribeFunc: func(ctx context.Context, audioData []byte) (string, error) { TranscribeFunc: func(ctx context.Context, audioData []byte) (string, error) {
return tt.mockResult, tt.mockError return tt.mockResult, tt.mockError
}, },
@@ -452,7 +517,7 @@ func TestNewSimpleTranscriber(t *testing.T) {
Model: "whisper-1", Model: "whisper-1",
} }
adapter := &MockTranscriptionAdapter{} adapter := &MockBatchAdapter{}
transcriber := NewSimpleTranscriber(config, adapter) transcriber := NewSimpleTranscriber(config, adapter)
if transcriber == nil { if transcriber == nil {
@@ -478,7 +543,7 @@ func TestNewSimpleTranscriber(t *testing.T) {
} }
func TestTranscriptionAdapter(t *testing.T) { func TestTranscriptionAdapter(t *testing.T) {
adapter := &MockTranscriptionAdapter{ adapter := &MockBatchAdapter{
TranscribeFunc: func(ctx context.Context, audioData []byte) (string, error) { TranscribeFunc: func(ctx context.Context, audioData []byte) (string, error) {
return "test result", nil return "test result", nil
}, },
@@ -497,3 +562,489 @@ func TestTranscriptionAdapter(t *testing.T) {
t.Errorf("Transcribe() = %q, want %q", result, "test result") t.Errorf("Transcribe() = %q, want %q", result, "test result")
} }
} }
func TestOpenAIAdapter_Creation(t *testing.T) {
tests := []struct {
name string
endpoint *provider.EndpointConfig
apiKey string
model string
language string
keywords []string
providerName string
}{
{
name: "openai with nil endpoint uses default",
endpoint: nil,
apiKey: "sk-test-key",
model: "whisper-1",
language: "en",
keywords: []string{"hello", "world"},
providerName: "openai",
},
{
name: "openai with explicit endpoint",
endpoint: &provider.EndpointConfig{BaseURL: "https://api.openai.com", Path: "/v1/audio/transcriptions"},
apiKey: "sk-test-key",
model: "whisper-1",
language: "es",
keywords: nil,
providerName: "openai",
},
{
name: "groq with custom endpoint",
endpoint: &provider.EndpointConfig{BaseURL: "https://api.groq.com/openai", Path: "/v1/audio/transcriptions"},
apiKey: "gsk-test-key",
model: "whisper-large-v3",
language: "fr",
keywords: []string{"bonjour"},
providerName: "groq",
},
{
name: "mistral with custom endpoint",
endpoint: &provider.EndpointConfig{BaseURL: "https://api.mistral.ai", Path: "/v1/audio/transcriptions"},
apiKey: "mistral-test-key",
model: "voxtral-mini-latest",
language: "de",
keywords: nil,
providerName: "mistral",
},
{
name: "auto language",
endpoint: nil,
apiKey: "sk-test-key",
model: "whisper-1",
language: "", // auto
keywords: nil,
providerName: "openai",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
adapter := NewOpenAIAdapter(tt.endpoint, tt.apiKey, tt.model, tt.language, tt.keywords, tt.providerName)
if adapter == nil {
t.Errorf("NewOpenAIAdapter() returned nil")
return
}
if adapter.model != tt.model {
t.Errorf("model = %q, want %q", adapter.model, tt.model)
}
if adapter.language != tt.language {
t.Errorf("language = %q, want %q", adapter.language, tt.language)
}
if adapter.providerName != tt.providerName {
t.Errorf("providerName = %q, want %q", adapter.providerName, tt.providerName)
}
if len(adapter.keywords) != len(tt.keywords) {
t.Errorf("keywords len = %d, want %d", len(adapter.keywords), len(tt.keywords))
}
})
}
}
// MockStreamingAdapter implements StreamingAdapter for testing
type MockStreamingAdapter struct {
StartFunc func(ctx context.Context, language string) error
SendChunkFunc func(audio []byte) error
ResultsFunc func() <-chan TranscriptionResult
FinalizeFunc func(ctx context.Context) error
CloseFunc func() error
resultsCh chan TranscriptionResult
}
func NewMockStreamingAdapter() *MockStreamingAdapter {
return &MockStreamingAdapter{
resultsCh: make(chan TranscriptionResult, 10),
}
}
func (m *MockStreamingAdapter) Start(ctx context.Context, language string) error {
if m.StartFunc != nil {
return m.StartFunc(ctx, language)
}
return nil
}
func (m *MockStreamingAdapter) SendChunk(audio []byte) error {
if m.SendChunkFunc != nil {
return m.SendChunkFunc(audio)
}
return nil
}
func (m *MockStreamingAdapter) Results() <-chan TranscriptionResult {
if m.ResultsFunc != nil {
return m.ResultsFunc()
}
return m.resultsCh
}
func (m *MockStreamingAdapter) Finalize(ctx context.Context) error {
if m.FinalizeFunc != nil {
return m.FinalizeFunc(ctx)
}
return nil
}
func (m *MockStreamingAdapter) Close() error {
if m.CloseFunc != nil {
return m.CloseFunc()
}
close(m.resultsCh)
return nil
}
func (m *MockStreamingAdapter) SendResult(result TranscriptionResult) {
m.resultsCh <- result
}
func TestStreamingTranscriber_Start(t *testing.T) {
adapter := NewMockStreamingAdapter()
transcriber := NewStreamingTranscriber(adapter, "en")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
frameCh := make(chan recording.AudioFrame, 10)
errCh, err := transcriber.Start(ctx, frameCh)
if err != nil {
t.Errorf("Start() error = %v", err)
return
}
if errCh == nil {
t.Errorf("Start() returned nil error channel")
}
close(frameCh)
err = transcriber.Stop(ctx)
if err != nil {
t.Errorf("Stop() error = %v", err)
}
}
func TestStreamingTranscriber_StartError(t *testing.T) {
adapter := NewMockStreamingAdapter()
adapter.StartFunc = func(ctx context.Context, language string) error {
return fmt.Errorf("connection failed")
}
transcriber := NewStreamingTranscriber(adapter, "en")
ctx := context.Background()
frameCh := make(chan recording.AudioFrame, 10)
_, err := transcriber.Start(ctx, frameCh)
if err == nil {
t.Errorf("Start() should fail when adapter.Start fails")
}
}
func TestStreamingTranscriber_AccumulatesResults(t *testing.T) {
adapter := NewMockStreamingAdapter()
transcriber := NewStreamingTranscriber(adapter, "en")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
frameCh := make(chan recording.AudioFrame, 10)
_, err := transcriber.Start(ctx, frameCh)
if err != nil {
t.Errorf("Start() error = %v", err)
return
}
// send some final results
adapter.SendResult(TranscriptionResult{Text: "hello", IsFinal: true})
adapter.SendResult(TranscriptionResult{Text: "world", IsFinal: true})
// give time for results to be processed
time.Sleep(50 * time.Millisecond)
close(frameCh)
err = transcriber.Stop(ctx)
if err != nil {
t.Errorf("Stop() error = %v", err)
}
result, err := transcriber.GetFinalTranscription()
if err != nil {
t.Errorf("GetFinalTranscription() error = %v", err)
return
}
if result != "hello world" {
t.Errorf("GetFinalTranscription() = %q, want %q", result, "hello world")
}
}
func TestStreamingTranscriber_IgnoresPartialResults(t *testing.T) {
adapter := NewMockStreamingAdapter()
transcriber := NewStreamingTranscriber(adapter, "en")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
frameCh := make(chan recording.AudioFrame, 10)
_, err := transcriber.Start(ctx, frameCh)
if err != nil {
t.Errorf("Start() error = %v", err)
return
}
// partial results should be ignored
adapter.SendResult(TranscriptionResult{Text: "hel", IsFinal: false})
adapter.SendResult(TranscriptionResult{Text: "hello", IsFinal: true})
adapter.SendResult(TranscriptionResult{Text: "hello wor", IsFinal: false})
time.Sleep(50 * time.Millisecond)
close(frameCh)
err = transcriber.Stop(ctx)
if err != nil {
t.Errorf("Stop() error = %v", err)
}
result, err := transcriber.GetFinalTranscription()
if err != nil {
t.Errorf("GetFinalTranscription() error = %v", err)
return
}
if result != "hello" {
t.Errorf("GetFinalTranscription() = %q, want %q", result, "hello")
}
}
func TestStreamingTranscriber_HandlesErrors(t *testing.T) {
adapter := NewMockStreamingAdapter()
transcriber := NewStreamingTranscriber(adapter, "en")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
frameCh := make(chan recording.AudioFrame, 10)
errCh, err := transcriber.Start(ctx, frameCh)
if err != nil {
t.Errorf("Start() error = %v", err)
return
}
// send an error result
adapter.SendResult(TranscriptionResult{Error: fmt.Errorf("transcription error")})
// error should be received on errCh
select {
case e := <-errCh:
if e == nil {
t.Errorf("expected error on errCh")
}
case <-time.After(100 * time.Millisecond):
t.Errorf("timeout waiting for error on errCh")
}
close(frameCh)
_ = transcriber.Stop(ctx)
}
func TestStreamingTranscriber_SendsAudioChunks(t *testing.T) {
var receivedChunks [][]byte
adapter := NewMockStreamingAdapter()
adapter.SendChunkFunc = func(audio []byte) error {
chunk := make([]byte, len(audio))
copy(chunk, audio)
receivedChunks = append(receivedChunks, chunk)
return nil
}
transcriber := NewStreamingTranscriber(adapter, "en")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
frameCh := make(chan recording.AudioFrame, 10)
_, err := transcriber.Start(ctx, frameCh)
if err != nil {
t.Errorf("Start() error = %v", err)
return
}
// send audio frames
frameCh <- recording.AudioFrame{Data: []byte{1, 2, 3, 4}}
frameCh <- recording.AudioFrame{Data: []byte{5, 6, 7, 8}}
time.Sleep(50 * time.Millisecond)
close(frameCh)
err = transcriber.Stop(ctx)
if err != nil {
t.Errorf("Stop() error = %v", err)
}
if len(receivedChunks) != 2 {
t.Errorf("expected 2 chunks, got %d", len(receivedChunks))
}
}
func TestStreamingTranscriber_ContextCancellation(t *testing.T) {
adapter := NewMockStreamingAdapter()
transcriber := NewStreamingTranscriber(adapter, "en")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
frameCh := make(chan recording.AudioFrame, 10)
_, err := transcriber.Start(ctx, frameCh)
if err != nil {
t.Errorf("Start() error = %v", err)
return
}
// cancel context
cancel()
// stop should complete without hanging
done := make(chan struct{})
go func() {
_ = transcriber.Stop(context.Background())
close(done)
}()
select {
case <-done:
// success
case <-time.After(2 * time.Second):
t.Errorf("Stop() timed out after context cancellation")
}
}
func TestStreamingTranscriber_GetFinalTranscriptionSafe(t *testing.T) {
adapter := NewMockStreamingAdapter()
transcriber := NewStreamingTranscriber(adapter, "en")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
frameCh := make(chan recording.AudioFrame, 10)
_, err := transcriber.Start(ctx, frameCh)
if err != nil {
t.Errorf("Start() error = %v", err)
return
}
// call GetFinalTranscription concurrently while results are being added
done := make(chan struct{})
go func() {
for i := 0; i < 100; i++ {
_, _ = transcriber.GetFinalTranscription()
time.Sleep(time.Millisecond)
}
close(done)
}()
// send results concurrently
for i := 0; i < 10; i++ {
adapter.SendResult(TranscriptionResult{Text: "word", IsFinal: true})
time.Sleep(5 * time.Millisecond)
}
<-done
close(frameCh)
err = transcriber.Stop(ctx)
if err != nil {
t.Errorf("Stop() error = %v", err)
}
}
func TestNewTranscriber_UnsupportedLanguageErrors(t *testing.T) {
// test that incompatible language returns an error
// base.en only supports English
config := Config{
Provider: "whisper-cpp",
Language: "es", // Spanish not supported by English-only model
Model: "base.en",
}
// should error, not silently fall back
_, err := NewTranscriber(config)
if err == nil {
t.Errorf("NewTranscriber() should error on unsupported language")
return
}
if !strings.Contains(err.Error(), "does not support language") {
t.Errorf("expected 'does not support language' error, got: %v", err)
}
}
func TestNewTranscriber_AutoLanguageNoFallback(t *testing.T) {
// test that auto language never triggers warning/fallback
config := Config{
Provider: "whisper-cpp",
Language: "", // auto
Model: "base.en",
}
transcriber, err := NewTranscriber(config)
if err != nil {
t.Errorf("NewTranscriber() error = %v", err)
return
}
if transcriber == nil {
t.Errorf("NewTranscriber() returned nil transcriber")
}
}
func TestNewTranscriber_CompatibleLanguageNoFallback(t *testing.T) {
// test that compatible language works normally
config := Config{
Provider: "whisper-cpp",
Language: "en", // English supported by English-only model
Model: "base.en",
}
transcriber, err := NewTranscriber(config)
if err != nil {
t.Errorf("NewTranscriber() error = %v", err)
return
}
if transcriber == nil {
t.Errorf("NewTranscriber() returned nil transcriber")
}
}
func TestNewTranscriber_MultilingualModelAllLanguages(t *testing.T) {
// test that multilingual model accepts any language without fallback
config := Config{
Provider: "groq-transcription",
APIKey: "test-key",
Language: "es", // Spanish
Model: "whisper-large-v3", // multilingual
}
transcriber, err := NewTranscriber(config)
if err != nil {
t.Errorf("NewTranscriber() error = %v", err)
return
}
if transcriber == nil {
t.Errorf("NewTranscriber() returned nil transcriber")
}
}
@@ -0,0 +1,101 @@
package tui
import (
"strings"
"testing"
"github.com/leonardotrapani/hyprvoice/internal/provider"
)
func TestGetTranscriptionModelOptions_ShowsCapabilities(t *testing.T) {
// test elevenlabs - includes batch+streaming and streaming-only models
options := getTranscriptionModelOptions("elevenlabs")
// should have 3 models: scribe_v1, scribe_v2, scribe_v2_realtime
if len(options) != 3 {
t.Errorf("expected 3 options for elevenlabs, got %d", len(options))
}
// verify models show capability tags
for _, opt := range options {
model, _, _ := provider.FindModelByID(opt.ID)
if model == nil {
continue
}
if model.SupportsBothModes() {
// both modes should mention batch+streaming
if !strings.Contains(opt.Desc, "batch+streaming") {
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
}
}
func TestGetTranscriptionModelOptions_NoHeadersAnymore(t *testing.T) {
// we removed batch/streaming section headers
options := getTranscriptionModelOptions("elevenlabs")
for _, opt := range options {
if opt.ID == "" {
t.Errorf("should not have headers anymore, got empty id")
}
}
}
func TestGetTranscriptionModelOptions_OpenAI_ShowsCapabilities(t *testing.T) {
options := getTranscriptionModelOptions("openai")
// OpenAI has 4 transcription models: whisper-1, gpt-4o-transcribe, gpt-4o-mini-transcribe, gpt-4o-realtime-preview
if len(options) != 4 {
t.Errorf("expected 4 options for openai, got %d", len(options))
}
// gpt-4o-realtime-preview should mention streaming
for _, opt := range options {
switch opt.ID {
case "gpt-4o-realtime-preview":
if !strings.Contains(opt.Desc, "streaming") {
t.Errorf("gpt-4o-realtime-preview should mention streaming: %s", opt.Desc)
}
case "gpt-4o-transcribe", "gpt-4o-mini-transcribe":
if strings.Contains(opt.Desc, "streaming") {
t.Errorf("batch-only model %s should not mention streaming: %s", opt.ID, opt.Desc)
}
}
}
}
func TestGetTranscriptionModelOptions_Deepgram_ShowsBothModes(t *testing.T) {
options := getTranscriptionModelOptions("deepgram")
// Deepgram has 2 models: nova-3, nova-2
if len(options) != 2 {
t.Errorf("expected 2 options for deepgram, got %d", len(options))
}
// all deepgram models support both modes
for _, opt := range options {
if !strings.Contains(opt.Desc, "batch+streaming") {
t.Errorf("deepgram model %s should mention batch+streaming: %s", opt.ID, opt.Desc)
}
}
}
func TestGetTranscriptionModelOptions_Groq_BatchOnly(t *testing.T) {
// test groq - batch only (no streaming models)
options := getTranscriptionModelOptions("groq-transcription")
// should have 2 models: whisper-large-v3, whisper-large-v3-turbo
if len(options) != 2 {
t.Errorf("expected 2 options for groq, got %d", len(options))
}
// batch-only models should not have any mode tags
for _, opt := range options {
if strings.Contains(opt.Desc, "streaming") {
t.Errorf("batch-only model should not mention streaming: %s", opt.Desc)
}
}
}
File diff suppressed because it is too large Load Diff
+132
View File
@@ -0,0 +1,132 @@
package tui
import (
"fmt"
"sort"
"strings"
"github.com/leonardotrapani/hyprvoice/internal/config"
"github.com/leonardotrapani/hyprvoice/internal/models/whisper"
"github.com/leonardotrapani/hyprvoice/internal/provider"
)
// AllProviders is the list of all supported cloud providers (require API keys).
var AllProviders = []string{"openai", "groq", "mistral", "elevenlabs", "deepgram"}
// LocalProviders is the list of local providers (no API key required).
var LocalProviders = []string{"whisper-cpp"}
// providerDisplayNames maps provider IDs to human-readable names.
var providerDisplayNames = map[string]string{
"openai": "OpenAI",
"groq": "Groq",
"mistral": "Mistral",
"elevenlabs": "ElevenLabs",
"deepgram": "Deepgram",
"whisper-cpp": "Whisper.cpp (local)",
}
func getProviderDisplayName(providerName string) string {
if name, ok := providerDisplayNames[providerName]; ok {
return name
}
return providerName
}
func getProviderKeyURL(providerName string) string {
p := provider.GetProvider(providerName)
if p == nil {
return ""
}
return p.APIKeyURL()
}
func maskAPIKey(key string) string {
if len(key) <= 8 {
return "***"
}
return key[:7] + "..." + key[len(key)-4:]
}
func getConfiguredProviders(cfg *config.Config) []string {
providers := make([]string, 0, len(cfg.Providers))
for name, pc := range cfg.Providers {
if pc.APIKey != "" {
providers = append(providers, name)
}
}
sort.Strings(providers)
return providers
}
func isProviderConfigured(cfg *config.Config, providerName string) bool {
if pc, ok := cfg.Providers[providerName]; ok {
return pc.APIKey != ""
}
return false
}
func mapConfigProviderToRegistry(configProvider string) string {
switch configProvider {
case "groq-transcription":
return "groq"
case "mistral-transcription":
return "mistral"
default:
return configProvider
}
}
func buildModelDesc(m provider.Model) string {
parts := []string{}
if m.Description != "" {
parts = append(parts, m.Description)
} else if m.Name != "" {
parts = append(parts, m.Name)
}
if m.Local {
parts = append(parts, "local model")
}
if m.SupportsBothModes() {
parts = append(parts, "batch+streaming")
} else if m.SupportsStreaming {
parts = append(parts, "streaming")
} else {
parts = append(parts, "batch-only")
}
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 {
registryName := mapConfigProviderToRegistry(configProvider)
p := provider.GetProvider(registryName)
if p == nil {
return []modelOption{}
}
models := provider.ModelsOfType(p, provider.Transcription)
options := make([]modelOption, 0, len(models))
for _, m := range models {
desc := buildModelDesc(m)
if m.Local && registryName == "whisper-cpp" {
if whisper.IsInstalled(m.ID) {
desc = desc + " - installed"
} else {
desc = desc + " - not installed"
}
}
options = append(options, modelOption{ID: m.ID, Title: m.ID, Desc: desc})
}
return options
}
+640
View File
@@ -0,0 +1,640 @@
package tui
import (
"fmt"
"strings"
"time"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type listScreen struct {
state *wizardState
title string
desc []string
list list.Model
footer string
errText string
onPick func(optionItem) screen
onBack func() screen
}
func newListScreen(state *wizardState, title string, desc []string, items []optionItem, onPick func(optionItem) screen, onBack func() screen) *listScreen {
delegate := list.NewDefaultDelegate()
l := list.New(itemsToList(items), delegate, 0, 0)
l.DisableQuitKeybindings()
l.SetShowHelp(false)
l.SetFilteringEnabled(true)
l.SetShowStatusBar(false)
l.Title = title
return &listScreen{
state: state,
title: title,
desc: desc,
list: l,
footer: "enter select • esc back • / filter",
onPick: onPick,
onBack: onBack,
}
}
func (s *listScreen) Init() tea.Cmd {
return nil
}
func (s *listScreen) Update(msg tea.Msg) (screen, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
extraFooterLines := strings.Count(s.footer, "\n")
s.list.SetSize(msg.Width-4, msg.Height-8-extraFooterLines)
case tea.KeyMsg:
switch msg.String() {
case "enter":
if s.list.FilterState() != list.Filtering {
if item, ok := s.list.SelectedItem().(optionItem); ok {
if item.disabled {
s.errText = "That option isn't available in this environment."
break
}
if s.onPick != nil {
return s.onPick(item), nil
}
}
}
case "esc", "q":
if s.list.FilterState() == list.Unfiltered {
if s.onBack != nil {
return s.onBack(), nil
}
}
}
}
var cmd tea.Cmd
s.list, cmd = s.list.Update(msg)
return s, cmd
}
func (s *listScreen) View() string {
header := renderHeader(s.title, s.desc, s.errText)
footer := renderFooter(s.footer, s.list.FilterState() != list.Unfiltered)
return header + s.list.View() + "\n" + footer
}
type confirmScreen struct {
state *wizardState
title string
desc []string
list list.Model
footer string
onYes func() screen
onNo func() screen
onBack func() screen
errText string
}
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{
{title: yesLabel, desc: yesDesc, value: "yes"},
{title: noLabel, desc: noDesc, value: "no"},
}
footer := "enter select • esc cancel"
if onBack != nil {
footer = "enter select • esc back"
}
delegate := list.NewDefaultDelegate()
l := list.New(itemsToList(items), delegate, 0, 0)
l.DisableQuitKeybindings()
l.SetShowHelp(false)
l.SetFilteringEnabled(false)
l.SetShowStatusBar(false)
l.Title = title
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) Update(msg tea.Msg) (screen, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
s.list.SetSize(msg.Width-4, msg.Height-8)
case tea.KeyMsg:
switch msg.String() {
case "enter":
if item, ok := s.list.SelectedItem().(optionItem); ok {
if item.value == "yes" && s.onYes != nil {
return s.onYes(), nil
}
if item.value == "no" && s.onNo != nil {
return s.onNo(), nil
}
}
case "esc", "q":
if s.onBack != nil {
return s.onBack(), nil
}
}
}
var cmd tea.Cmd
s.list, cmd = s.list.Update(msg)
return s, cmd
}
func (s *confirmScreen) View() string {
header := renderHeader(s.title, s.desc, s.errText)
footer := renderFooter(s.footer, false)
return header + s.list.View() + "\n" + footer
}
type inputScreen struct {
state *wizardState
title string
desc []string
input textinput.Model
footer string
errText string
onSubmit func(string) screen
onCancel func() screen
validateFn func(string) error
}
func newInputScreen(state *wizardState, title string, desc []string, value string, placeholder string, password bool, validateFn func(string) error, onSubmit func(string) screen, onCancel func() screen) *inputScreen {
input := textinput.New()
input.SetValue(value)
input.Placeholder = placeholder
if password {
input.EchoMode = textinput.EchoPassword
input.EchoCharacter = '*'
}
input.Focus()
input.CharLimit = 0
return &inputScreen{
state: state,
title: title,
desc: desc,
input: input,
footer: "enter save • esc back",
onSubmit: onSubmit,
onCancel: onCancel,
validateFn: validateFn,
}
}
func (s *inputScreen) Init() tea.Cmd { return textinput.Blink }
func (s *inputScreen) Update(msg tea.Msg) (screen, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "enter":
value := strings.TrimSpace(s.input.Value())
if s.validateFn != nil {
if err := s.validateFn(value); err != nil {
s.errText = err.Error()
break
}
}
if s.onSubmit != nil {
return s.onSubmit(value), nil
}
case "esc", "q":
if s.onCancel != nil {
return s.onCancel(), nil
}
}
}
var cmd tea.Cmd
s.input, cmd = s.input.Update(msg)
return s, cmd
}
func (s *inputScreen) View() string {
header := renderHeader(s.title, s.desc, s.errText)
footer := renderFooter(s.footer, false)
return header + s.input.View() + "\n\n" + footer
}
type multiSelectScreen struct {
state *wizardState
title string
desc []string
list list.Model
footer string
errText string
onSubmit func([]toggleItem) screen
onCancel func() screen
requireOne bool
}
func newMultiSelectScreen(state *wizardState, title string, desc []string, items []toggleItem, requireOne bool, onSubmit func([]toggleItem) screen, onCancel func() screen) *multiSelectScreen {
delegate := list.NewDefaultDelegate()
l := list.New(toggleItemsToList(items), delegate, 0, 0)
l.DisableQuitKeybindings()
l.SetShowHelp(false)
l.SetFilteringEnabled(true)
l.SetShowStatusBar(false)
l.Title = title
return &multiSelectScreen{
state: state,
title: title,
desc: desc,
list: l,
footer: "space toggle • enter save • esc back • / filter",
onSubmit: onSubmit,
onCancel: onCancel,
requireOne: requireOne,
}
}
func (s *multiSelectScreen) Init() tea.Cmd { return nil }
func (s *multiSelectScreen) Update(msg tea.Msg) (screen, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
s.list.SetSize(msg.Width-4, msg.Height-8)
case tea.KeyMsg:
switch msg.String() {
case " ":
idx := s.list.Index()
items := s.list.Items()
if idx >= 0 && idx < len(items) {
if item, ok := items[idx].(toggleItem); ok {
item.selected = !item.selected
items[idx] = item
s.list.SetItems(items)
}
}
case "enter":
items := listToToggleItems(s.list.Items())
if s.requireOne {
has := false
for _, item := range items {
if item.selected {
has = true
break
}
}
if !has {
s.errText = "Select at least one option to continue."
break
}
}
if s.onSubmit != nil {
return s.onSubmit(items), nil
}
case "esc", "q":
if s.list.FilterState() == list.Unfiltered {
if s.onCancel != nil {
return s.onCancel(), nil
}
}
}
}
var cmd tea.Cmd
s.list, cmd = s.list.Update(msg)
return s, cmd
}
func (s *multiSelectScreen) View() string {
header := renderHeader(s.title, s.desc, s.errText)
footer := renderFooter(s.footer, s.list.FilterState() != list.Unfiltered)
return header + s.list.View() + "\n" + footer
}
type infoScreen struct {
state *wizardState
title string
desc []string
footer string
next func() screen
back func() screen
}
func newInfoScreen(state *wizardState, title string, desc []string, next func() screen, back func() screen) *infoScreen {
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) Update(msg tea.Msg) (screen, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "enter":
if s.next != nil {
return s.next(), nil
}
case "esc", "q":
if s.back != nil {
return s.back(), nil
}
}
}
return s, nil
}
func (s *infoScreen) View() string {
header := renderHeader(s.title, s.desc, "")
footer := renderFooter(s.footer, false)
return header + "\n" + footer
}
type formField struct {
key string
label string
desc string
input textinput.Model
validate func(string) error
required bool
sensitive bool
}
type formScreen struct {
state *wizardState
title string
desc []string
fields []formField
focused int
footer string
errText string
onSubmit func(map[string]string) screen
onCancel func() screen
}
func newFormScreen(state *wizardState, title string, desc []string, fields []formField, onSubmit func(map[string]string) screen, onCancel func() screen) *formScreen {
if len(fields) > 0 {
fields[0].input.Focus()
}
return &formScreen{state: state, title: title, desc: desc, fields: fields, onSubmit: onSubmit, onCancel: onCancel}
}
func (s *formScreen) Init() tea.Cmd { return textinput.Blink }
func (s *formScreen) Update(msg tea.Msg) (screen, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "esc", "q":
if s.onCancel != nil {
return s.onCancel(), nil
}
case "tab", "down":
s.moveFocus(1)
case "shift+tab", "up":
s.moveFocus(-1)
case "enter":
if s.focused == len(s.fields)-1 {
values, err := s.validateAll()
if err != nil {
s.errText = err.Error()
break
}
if s.onSubmit != nil {
return s.onSubmit(values), nil
}
} else {
s.moveFocus(1)
}
}
}
var cmd tea.Cmd
if s.focused >= 0 && s.focused < len(s.fields) {
s.fields[s.focused].input, cmd = s.fields[s.focused].input.Update(msg)
}
return s, cmd
}
func (s *formScreen) View() string {
header := renderHeader(s.title, s.desc, s.errText)
body := strings.Builder{}
for i, field := range s.fields {
label := StyleLabel.Render(field.label)
if i == s.focused {
label = StyleHighlight.Render(field.label)
}
body.WriteString(label)
if field.desc != "" {
body.WriteString("\n")
body.WriteString(StyleSubtle.Render(field.desc))
}
body.WriteString("\n")
body.WriteString(field.input.View())
body.WriteString("\n\n")
}
footer := renderFooter(s.footer, false)
return header + body.String() + footer
}
func (s *formScreen) moveFocus(delta int) {
if len(s.fields) == 0 {
return
}
s.fields[s.focused].input.Blur()
s.focused = (s.focused + delta + len(s.fields)) % len(s.fields)
s.fields[s.focused].input.Focus()
}
func (s *formScreen) validateAll() (map[string]string, error) {
values := make(map[string]string, len(s.fields))
for _, field := range s.fields {
value := strings.TrimSpace(field.input.Value())
if field.required && value == "" {
return nil, fmt.Errorf("%s is required", field.label)
}
if field.validate != nil {
if err := field.validate(value); err != nil {
return nil, err
}
}
values[field.key] = value
}
return values, nil
}
type downloadProgressMsg struct {
downloaded int64
total int64
}
type downloadDoneMsg struct {
err error
}
type downloadScreen struct {
state *wizardState
title string
desc []string
modelID string
progress int
total int64
footer string
errText string
onSuccess func() screen
onCancel func() screen
updates chan tea.Msg
started bool
}
func newDownloadScreen(state *wizardState, title string, desc []string, modelID string, onSuccess func() screen, onCancel func() screen) *downloadScreen {
return &downloadScreen{
state: state,
title: title,
desc: desc,
modelID: modelID,
onSuccess: onSuccess,
onCancel: onCancel,
updates: make(chan tea.Msg),
}
}
func (s *downloadScreen) Init() tea.Cmd {
if s.started {
return listenForDownload(s.updates)
}
s.started = true
return tea.Batch(s.startDownloadCmd(), listenForDownload(s.updates))
}
func (s *downloadScreen) Update(msg tea.Msg) (screen, tea.Cmd) {
switch msg := msg.(type) {
case downloadProgressMsg:
s.total = msg.total
if msg.total > 0 {
s.progress = int(msg.downloaded * 100 / msg.total)
}
return s, listenForDownload(s.updates)
case downloadDoneMsg:
if msg.err != nil {
s.errText = msg.err.Error()
return s, nil
}
if s.onSuccess != nil {
return s.onSuccess(), nil
}
case tea.KeyMsg:
switch msg.String() {
case "esc", "q":
if s.onCancel != nil {
return s.onCancel(), nil
}
}
}
return s, nil
}
func (s *downloadScreen) View() string {
header := renderHeader(s.title, s.desc, s.errText)
progressLine := "Downloading"
if s.total > 0 {
progressLine = fmt.Sprintf("Downloading... %d%%", s.progress)
}
body := StyleMuted.Render(progressLine) + "\n\n"
footer := renderFooter(s.footer, false)
return header + body + footer
}
func (s *downloadScreen) startDownloadCmd() tea.Cmd {
modelID := s.modelID
ch := s.updates
return func() tea.Msg {
err := downloadWhisperModel(modelID, func(downloaded, total int64) {
ch <- downloadProgressMsg{downloaded: downloaded, total: total}
})
ch <- downloadDoneMsg{err: err}
return nil
}
}
func listenForDownload(ch <-chan tea.Msg) tea.Cmd {
return func() tea.Msg {
msg, ok := <-ch
if !ok {
return nil
}
return msg
}
}
func itemsToList(items []optionItem) []list.Item {
result := make([]list.Item, len(items))
for i, item := range items {
result[i] = item
}
return result
}
func toggleItemsToList(items []toggleItem) []list.Item {
result := make([]list.Item, len(items))
for i, item := range items {
result[i] = item
}
return result
}
func listToToggleItems(items []list.Item) []toggleItem {
result := make([]toggleItem, 0, len(items))
for _, item := range items {
if t, ok := item.(toggleItem); ok {
result = append(result, t)
}
}
return result
}
func renderHeader(title string, desc []string, errText string) string {
var b strings.Builder
if title != "" {
b.WriteString(StyleHeader.Render(title))
b.WriteString("\n")
}
for _, line := range desc {
if line == "" {
continue
}
b.WriteString(StyleMuted.Render(line))
b.WriteString("\n")
}
if errText != "" {
b.WriteString("\n")
b.WriteString(StyleError.Render(errText))
b.WriteString("\n")
}
b.WriteString("\n")
return b.String()
}
func renderFooter(extra string, filtering bool) string {
if extra != "" {
return StyleSubtle.Render(extra)
}
if filtering {
return StyleSubtle.Render("enter apply • esc clear")
}
return StyleSubtle.Render("enter select • esc back")
}
func makeInputField(key, label, desc, value, placeholder string, validate func(string) error) formField {
input := textinput.New()
input.SetValue(value)
input.Placeholder = placeholder
input.Prompt = ""
input.Cursor.Style = lipgloss.NewStyle().Foreground(ColorPrimary)
return formField{key: key, label: label, desc: desc, input: input, validate: validate}
}
func parseDurationOrEmpty(value string) (time.Duration, error) {
if strings.TrimSpace(value) == "" {
return 0, fmt.Errorf("duration is required")
}
return time.ParseDuration(value)
}
+82
View File
@@ -0,0 +1,82 @@
package tui
import (
"strings"
"github.com/charmbracelet/lipgloss"
)
// Base styles for hyprvoice TUI components
var (
// Header style for titles and section headers
StyleHeader = lipgloss.NewStyle().
Bold(true).
Foreground(ColorPrimary).
MarginBottom(1)
// Label style for form field labels
StyleLabel = lipgloss.NewStyle().
Foreground(ColorText).
Bold(true)
// Success style for positive feedback
StyleSuccess = lipgloss.NewStyle().
Foreground(ColorSuccess)
// Error style for error messages
StyleError = lipgloss.NewStyle().
Foreground(ColorError).
Bold(true)
// Warning style for warnings
StyleWarning = lipgloss.NewStyle().
Foreground(ColorWarning)
// Muted style for secondary text
StyleMuted = lipgloss.NewStyle().
Foreground(ColorMuted)
// Subtle style for hints and descriptions
StyleSubtle = lipgloss.NewStyle().
Foreground(ColorSubtle).
Italic(true)
// Highlight style for selected/focused items
StyleHighlight = lipgloss.NewStyle().
Foreground(ColorSecondary).
Bold(true)
// Selected style for chosen options
StyleSelected = lipgloss.NewStyle().
Foreground(ColorPrimary).
Bold(true)
// Box style for bordered containers
StyleBox = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(ColorSubtle).
Padding(1, 2)
// FocusedBox style for focused containers
StyleFocusedBox = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(ColorPrimary).
Padding(1, 2)
)
const logoASCII = `
_ _
| |__ _ _ _ __ _ ____ _(_) ___ ___
| '_ \| | | | '_ \| '__\ \ / / |/ __/ _ \
| | | | |_| | |_) | | \ V /| | (_| __/
|_| |_|\__, | .__/|_| \_/ |_|\___\___|
|___/|_| `
// 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")
}
+26
View File
@@ -0,0 +1,26 @@
package tui
import "github.com/charmbracelet/lipgloss"
// Color palette for hyprvoice TUI
// Using a modern, accessible color scheme
var (
// Primary colors
ColorPrimary = lipgloss.Color("#7C3AED") // Purple - main accent
ColorSecondary = lipgloss.Color("#06B6D4") // Cyan - secondary accent
// Status colors
ColorSuccess = lipgloss.Color("#22C55E") // Green
ColorError = lipgloss.Color("#EF4444") // Red
ColorWarning = lipgloss.Color("#F59E0B") // Amber
// Text colors
ColorText = lipgloss.Color("#F8FAFC") // Bright white
ColorMuted = lipgloss.Color("#94A3B8") // Slate gray
ColorSubtle = lipgloss.Color("#64748B") // Darker gray
// Background colors
ColorBg = lipgloss.Color("#0F172A") // Dark slate
ColorBgAlt = lipgloss.Color("#1E293B") // Slightly lighter
ColorHighlight = lipgloss.Color("#334155") // Selection highlight
)
+67
View File
@@ -0,0 +1,67 @@
package tui
import (
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/leonardotrapani/hyprvoice/internal/config"
)
// ConfigureResult holds the configuration result from the TUI.
type ConfigureResult struct {
Config *config.Config
Cancelled bool
}
type screen interface {
Init() tea.Cmd
Update(tea.Msg) (screen, tea.Cmd)
View() string
}
type wizardState struct {
cfg *config.Config
onboarding bool
cancelled bool
err error
result *ConfigureResult
}
type optionItem struct {
title string
desc string
value string
disabled bool
}
func (i optionItem) Title() string { return i.title }
func (i optionItem) Description() string { return i.desc }
func (i optionItem) FilterValue() string {
return strings.TrimSpace(i.title + " " + i.desc)
}
type toggleItem struct {
title string
desc string
value string
selected bool
}
func (i toggleItem) Title() string {
prefix := "[ ]"
if i.selected {
prefix = "[x]"
}
return prefix + " " + i.title
}
func (i toggleItem) Description() string { return i.desc }
func (i toggleItem) FilterValue() string {
return strings.TrimSpace(i.title + " " + i.desc)
}
type modelOption struct {
ID string
Title string
Desc string
}
+108
View File
@@ -0,0 +1,108 @@
package tui
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/leonardotrapani/hyprvoice/internal/config"
)
type wizardModel struct {
state *wizardState
screen screen
width int
height int
}
func newWizardModel(state *wizardState, start screen) wizardModel {
return wizardModel{state: state, screen: start}
}
func (m wizardModel) Init() tea.Cmd {
if m.screen == nil {
return tea.Quit
}
return m.screen.Init()
}
func (m wizardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "ctrl+c" {
m.state.cancelled = true
m.state.result = &ConfigureResult{Cancelled: true}
return m, tea.Quit
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
}
if m.screen == nil {
return m, tea.Quit
}
next, cmd := m.screen.Update(msg)
if next == nil {
if m.state.result == nil {
m.state.result = &ConfigureResult{Config: m.state.cfg, Cancelled: m.state.cancelled}
}
return m, tea.Quit
}
if next != m.screen {
var sizeCmd tea.Cmd
if m.width > 0 && m.height > 0 {
updated, scmd := next.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
if updated == nil {
if m.state.result == nil {
m.state.result = &ConfigureResult{Config: m.state.cfg, Cancelled: m.state.cancelled}
}
return m, tea.Quit
}
next = updated
sizeCmd = scmd
}
m.screen = next
initCmd := m.screen.Init()
return m, tea.Batch(cmd, sizeCmd, initCmd)
}
m.screen = next
return m, cmd
}
func (m wizardModel) View() string {
if m.screen == nil {
return ""
}
return m.screen.View()
}
// Run starts the TUI configuration wizard.
// If onboarding is true, starts the guided onboarding flow.
func Run(existingConfig *config.Config, onboarding bool) (*ConfigureResult, error) {
if existingConfig == nil {
return nil, fmt.Errorf("config is required")
}
state := &wizardState{cfg: existingConfig, onboarding: onboarding}
var start screen
if onboarding {
start = newWelcomeScreen(state)
} else {
start = newMenuScreen(state)
}
model := newWizardModel(state, start)
if _, err := tea.NewProgram(model, tea.WithAltScreen()).Run(); err != nil {
return &ConfigureResult{Cancelled: true}, err
}
if state.err != nil {
return &ConfigureResult{Cancelled: true}, state.err
}
if state.result == nil {
state.result = &ConfigureResult{Config: existingConfig, Cancelled: state.cancelled}
}
return state.result, nil
}
+32
View File
@@ -0,0 +1,32 @@
package tui
import (
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/leonardotrapani/hyprvoice/internal/config"
)
func TestWizardMenuTransitionAppliesSize(t *testing.T) {
cfg := &config.Config{}
state := &wizardState{cfg: cfg}
model := newWizardModel(state, newMenuScreen(state))
updated, _ := model.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
model = updated.(wizardModel)
// move down to "Voice Model" item (index 1) which leads to a listScreen
updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyDown})
model = updated.(wizardModel)
updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyEnter})
model = updated.(wizardModel)
listScreen, ok := model.screen.(*listScreen)
if !ok {
t.Fatalf("expected list screen after selection, got %T", model.screen)
}
if listScreen.list.Width() <= 0 || listScreen.list.Height() <= 0 {
t.Fatalf("expected list size to be set, got width=%d height=%d", listScreen.list.Width(), listScreen.list.Height())
}
}
+6 -4
View File
@@ -2,14 +2,16 @@ post_install() {
echo "==> Hyprvoice installed successfully!" echo "==> Hyprvoice installed successfully!"
echo "" echo ""
echo " 📋 To use hyprvoice:" echo " 📋 To use hyprvoice:"
echo " 1. Configure: hyprvoice configure" echo " 1. Run onboarding: hyprvoice onboarding"
echo " 2. Enable service: systemctl --user enable hyprvoice.service" echo " 2. Enable service: systemctl --user enable --now hyprvoice.service"
echo " 3. Start service: systemctl --user start hyprvoice.service" echo " 3. Add keybinding to your window manager"
echo " 4. Add keybinding to your window manager" echo " 4. Test voice input: hyprvoice toggle"
echo "" echo ""
echo " 🔑 For Hyprland, add to ~/.config/hypr/hyprland.conf:" echo " 🔑 For Hyprland, add to ~/.config/hypr/hyprland.conf:"
echo " bind = SUPER, R, exec, hyprvoice toggle" echo " bind = SUPER, R, exec, hyprvoice toggle"
echo "" echo ""
echo " Later: hyprvoice configure for advanced settings"
echo ""
} }
post_upgrade() { post_upgrade() {
BIN
View File
Binary file not shown.