feat: fanalize straeming adapters
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
# 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
|
||||
|
||||
## Where to look
|
||||
- docs/structure.md: architecture and code map
|
||||
- docs/config.md: config reference and paths
|
||||
- docs/providers.md: provider and model details
|
||||
- packaging/RELEASE.md: release and AUR workflow
|
||||
@@ -5,6 +5,7 @@ Press a toggle key, speak, and get instant text input. Built natively for Waylan
|
||||
## Features
|
||||
|
||||
- **Toggle workflow**: Press once to start recording, press again to stop and inject text
|
||||
- **Interactive configuration**: User-friendly TUI wizard - no manual config file editing required
|
||||
- **LLM post-processing**: Automatically cleans up transcriptions - removes stutters, fixes grammar, adds punctuation (enabled by default)
|
||||
- **Wayland native**: Purpose-built for Wayland compositors - no legacy X11 dependencies or hacky workarounds
|
||||
- **Real-time feedback**: Desktop notifications for recording states and transcription status
|
||||
@@ -129,6 +130,28 @@ hyprvoice toggle
|
||||
hyprvoice toggle # Stop and transcribe
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The recommended way to configure hyprvoice is through the interactive wizard:
|
||||
|
||||
```bash
|
||||
hyprvoice configure
|
||||
```
|
||||
|
||||
The wizard guides you through all settings with a user-friendly interface:
|
||||
|
||||
- **Providers** - API keys for OpenAI, Groq, Mistral, ElevenLabs, Deepgram
|
||||
- **Transcription** - Speech-to-text provider, model, and language selection (cloud or local)
|
||||
- **LLM** - Post-processing to clean up transcriptions (enabled by default)
|
||||
- **Keywords** - Domain-specific terms for better accuracy
|
||||
- **Injection** - How text is typed (ydotool, wtype, clipboard)
|
||||
- **Notifications** - Desktop notification preferences
|
||||
- **Advanced Settings** - Recording parameters, timeouts
|
||||
|
||||
Configuration is stored in `~/.config/hyprvoice/config.toml`. Changes are applied immediately without restarting the daemon.
|
||||
|
||||
For manual configuration and detailed options, see [docs/config.md](docs/config.md).
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Common Commands
|
||||
@@ -227,29 +250,6 @@ hyprvoice toggle
|
||||
hyprvoice status
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The recommended way to configure hyprvoice is through the interactive wizard:
|
||||
|
||||
```bash
|
||||
hyprvoice configure
|
||||
```
|
||||
|
||||
The wizard guides you through all settings with a user-friendly interface:
|
||||
|
||||
- **Providers** - API keys for OpenAI, Groq, Mistral, ElevenLabs, Deepgram
|
||||
- **Language** - Global language setting for all transcription (57 languages + auto-detect)
|
||||
- **Transcription** - Speech-to-text provider and model selection (cloud or local)
|
||||
- **LLM** - Post-processing to clean up transcriptions (enabled by default)
|
||||
- **Keywords** - Domain-specific terms for better accuracy
|
||||
- **Injection** - How text is typed (ydotool, wtype, clipboard)
|
||||
- **Notifications** - Desktop notification preferences
|
||||
- **Advanced Settings** - Recording parameters, timeouts
|
||||
|
||||
Configuration is stored in `~/.config/hyprvoice/config.toml`. Changes are applied immediately without restarting the daemon.
|
||||
|
||||
For manual configuration and detailed options, see [docs/config.md](docs/config.md).
|
||||
|
||||
## Local Transcription
|
||||
|
||||
For complete offline privacy, use whisper.cpp for local transcription - no API keys, no cloud, no data leaves your machine.
|
||||
@@ -297,17 +297,7 @@ For complete offline privacy, use whisper.cpp for local transcription - no API k
|
||||
|
||||
**Recommendation**: Start with `base.en` for English or `base` for multilingual. Models ending in `.en` are English-only but slightly faster.
|
||||
|
||||
### Configuration
|
||||
|
||||
```toml
|
||||
[general]
|
||||
language = "" # empty for auto-detect, or "en", "es", etc.
|
||||
|
||||
[transcription]
|
||||
provider = "whisper-cpp"
|
||||
model = "base.en" # or "base" for multilingual
|
||||
threads = 0 # 0 = auto (NumCPU - 1)
|
||||
```
|
||||
Run `hyprvoice configure` to set up local transcription, or see [docs/config.md](docs/config.md) for manual configuration.
|
||||
|
||||
## Streaming Transcription
|
||||
|
||||
@@ -321,30 +311,9 @@ For real-time transcription results as you speak, use streaming providers. Text
|
||||
| Deepgram | nova-3, nova-2 | ~100ms | 40+ langs |
|
||||
| OpenAI | gpt-4o-realtime-preview | ~200ms | 57 langs |
|
||||
|
||||
### Configuration
|
||||
Streaming models show partial results while recording. Final text is accumulated and injected when you toggle off.
|
||||
|
||||
```toml
|
||||
[general]
|
||||
language = "" # empty for auto-detect
|
||||
|
||||
# ElevenLabs streaming
|
||||
[providers.elevenlabs]
|
||||
api_key = "..."
|
||||
|
||||
[transcription]
|
||||
provider = "elevenlabs"
|
||||
model = "scribe_v2-streaming"
|
||||
|
||||
# Deepgram streaming
|
||||
[providers.deepgram]
|
||||
api_key = "..."
|
||||
|
||||
[transcription]
|
||||
provider = "deepgram"
|
||||
model = "nova-3"
|
||||
```
|
||||
|
||||
**Note**: Streaming models show partial results while recording. Final text is accumulated and injected when you toggle off.
|
||||
Run `hyprvoice configure` to set up streaming, or see [docs/config.md](docs/config.md) for manual configuration.
|
||||
|
||||
### Service Management
|
||||
|
||||
|
||||
+41
-117
@@ -10,7 +10,6 @@ Configuration is stored in `~/.config/hyprvoice/config.toml` and changes are app
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [General Settings](#general-settings)
|
||||
- [Unified Provider System](#unified-provider-system)
|
||||
- [Transcription Providers](#transcription-providers)
|
||||
- [Cloud Providers](#cloud-providers)
|
||||
@@ -26,41 +25,6 @@ Configuration is stored in `~/.config/hyprvoice/config.toml` and changes are app
|
||||
- [Example Configurations](#example-configurations)
|
||||
- [Migration from Old Config Format](#migration-from-old-config-format)
|
||||
|
||||
## General Settings
|
||||
|
||||
The `[general]` section contains application-wide settings:
|
||||
|
||||
```toml
|
||||
[general]
|
||||
language = "" # ISO 639-1 code (e.g., "en", "es", "de"). Empty for auto-detect.
|
||||
```
|
||||
|
||||
### Language
|
||||
|
||||
The global language setting applies to all transcription providers:
|
||||
|
||||
```toml
|
||||
[general]
|
||||
language = "" # Auto-detect (recommended)
|
||||
# language = "en" # English
|
||||
# language = "es" # Spanish
|
||||
# language = "de" # German
|
||||
```
|
||||
|
||||
**Override behavior:** You can override the global language for a specific transcription provider:
|
||||
|
||||
```toml
|
||||
[general]
|
||||
language = "en" # Default to English
|
||||
|
||||
[transcription]
|
||||
# language = "es" # Uncomment to override for this provider only
|
||||
```
|
||||
|
||||
When `transcription.language` is set, it takes precedence over `general.language`. This allows you to set a default language but override it for specific use cases.
|
||||
|
||||
See [Language Configuration](#language-configuration) for the full list of supported languages and model compatibility.
|
||||
|
||||
## Unified Provider System
|
||||
|
||||
Hyprvoice uses a unified provider system where API keys are configured once and shared between transcription and LLM features:
|
||||
@@ -99,12 +63,10 @@ Hyprvoice supports multiple transcription backends. See [docs/providers.md](./pr
|
||||
Cloud-based transcription using OpenAI's Whisper API:
|
||||
|
||||
```toml
|
||||
[general]
|
||||
language = "" # Empty for auto-detect, or "en", "es", "fr", etc.
|
||||
|
||||
[transcription]
|
||||
provider = "openai"
|
||||
model = "whisper-1"
|
||||
language = "" # Empty for auto-detect, or "en", "es", "fr", etc.
|
||||
```
|
||||
|
||||
**Features:**
|
||||
@@ -118,12 +80,10 @@ model = "whisper-1"
|
||||
Fast cloud-based transcription using Groq's Whisper API:
|
||||
|
||||
```toml
|
||||
[general]
|
||||
language = "" # Empty for auto-detect, or "en", "es", "fr", etc.
|
||||
|
||||
[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:**
|
||||
@@ -156,12 +116,10 @@ model = "whisper-large-v3"
|
||||
Transcription using Mistral's Voxtral API, excellent for European languages:
|
||||
|
||||
```toml
|
||||
[general]
|
||||
language = "" # Empty for auto-detect
|
||||
|
||||
[transcription]
|
||||
provider = "mistral-transcription"
|
||||
model = "voxtral-mini-latest" # Or "voxtral-mini-2507"
|
||||
language = "" # Empty for auto-detect
|
||||
```
|
||||
|
||||
### ElevenLabs Scribe
|
||||
@@ -169,12 +127,10 @@ model = "voxtral-mini-latest" # Or "voxtral-mini-2507"
|
||||
Transcription using ElevenLabs' Scribe API with 57+ language support:
|
||||
|
||||
```toml
|
||||
[general]
|
||||
language = "" # Empty for auto-detect
|
||||
|
||||
[transcription]
|
||||
provider = "elevenlabs"
|
||||
model = "scribe_v1" # Or "scribe_v2" for lower latency
|
||||
language = "" # Empty for auto-detect
|
||||
```
|
||||
|
||||
**Features:**
|
||||
@@ -188,15 +144,13 @@ model = "scribe_v1" # Or "scribe_v2" for lower latency
|
||||
Fast streaming transcription using Deepgram's Nova models:
|
||||
|
||||
```toml
|
||||
[general]
|
||||
language = "" # Empty for auto-detect
|
||||
|
||||
[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:**
|
||||
@@ -216,12 +170,10 @@ Run Whisper models locally on your machine. No API keys, no network latency, com
|
||||
2. Download a model: `hyprvoice model download base.en`
|
||||
|
||||
```toml
|
||||
[general]
|
||||
language = "" # Empty for auto-detect
|
||||
|
||||
[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)
|
||||
```
|
||||
|
||||
@@ -276,36 +228,27 @@ model = "gpt-4o-realtime-preview"
|
||||
| Deepgram | `nova-2` | Very Low | 33 |
|
||||
| OpenAI | `gpt-4o-realtime-preview` | Low | 57 |
|
||||
|
||||
## Language Configuration
|
||||
### Language Configuration
|
||||
|
||||
Configure the expected spoken language for better accuracy. Language is set globally in `[general]`:
|
||||
Language is configured per transcription model in the `[transcription]` section:
|
||||
|
||||
```toml
|
||||
[general]
|
||||
[transcription]
|
||||
provider = "openai"
|
||||
model = "whisper-1"
|
||||
language = "" # Empty for auto-detect (recommended)
|
||||
# Or specify a language code:
|
||||
# language = "en" # English
|
||||
# language = "es" # Spanish
|
||||
# language = "fr" # French
|
||||
# language = "zh" # Chinese
|
||||
# language = "ja" # Japanese
|
||||
```
|
||||
|
||||
**Override per-provider:** If you need different languages for different setups:
|
||||
|
||||
```toml
|
||||
[general]
|
||||
language = "en" # Global default
|
||||
|
||||
[transcription]
|
||||
# language = "es" # Uncomment to override for transcription only
|
||||
```
|
||||
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)
|
||||
- Required for English-only models if you speak English
|
||||
- English-only models (e.g., `base.en`) only support `language = "en"` or auto-detect
|
||||
|
||||
### Supported Languages
|
||||
|
||||
@@ -315,7 +258,7 @@ Afrikaans (af), Arabic (ar), Armenian (hy), Azerbaijani (az), Belarusian (be), B
|
||||
|
||||
### Language-Model Compatibility
|
||||
|
||||
Some models only support English. Hyprvoice validates compatibility:
|
||||
Some models only support English. When configuring via `hyprvoice configure`, only supported languages are shown for selection.
|
||||
|
||||
**English-only models:**
|
||||
|
||||
@@ -328,17 +271,15 @@ Some models only support English. Hyprvoice validates compatibility:
|
||||
|
||||
**Validation behavior:**
|
||||
|
||||
1. **At config time (TUI/validation):** Selecting an English-only model with a non-English language shows an error and prevents saving
|
||||
2. **At runtime (safety net):** If config was manually edited to an invalid combination, hyprvoice logs a warning, sends a desktop notification, and falls back to auto-detect
|
||||
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:
|
||||
[general]
|
||||
language = "es" # Error: model does not support Spanish
|
||||
|
||||
# This combination will be rejected at validation:
|
||||
[transcription]
|
||||
provider = "groq-transcription"
|
||||
model = "distil-whisper-large-v3-en" # English only!
|
||||
language = "es" # Error: model does not support Spanish
|
||||
```
|
||||
|
||||
## Model Management
|
||||
@@ -585,15 +526,13 @@ You can customize notification text via the `[notifications.messages]` section:
|
||||
### Fast Transcription Only (No LLM)
|
||||
|
||||
```toml
|
||||
[general]
|
||||
language = "" # Auto-detect
|
||||
|
||||
[providers.groq]
|
||||
api_key = "gsk_..."
|
||||
|
||||
[transcription]
|
||||
provider = "groq-transcription"
|
||||
model = "whisper-large-v3-turbo"
|
||||
language = "" # Auto-detect
|
||||
|
||||
[llm]
|
||||
enabled = false
|
||||
@@ -602,15 +541,13 @@ language = "" # Auto-detect
|
||||
### High Quality with OpenAI (Default)
|
||||
|
||||
```toml
|
||||
[general]
|
||||
language = "" # Auto-detect
|
||||
|
||||
[providers.openai]
|
||||
api_key = "sk-..."
|
||||
|
||||
[transcription]
|
||||
provider = "openai"
|
||||
model = "whisper-1"
|
||||
language = "" # Auto-detect
|
||||
|
||||
[llm]
|
||||
enabled = true
|
||||
@@ -621,15 +558,13 @@ language = "" # Auto-detect
|
||||
### Budget-Friendly with Groq
|
||||
|
||||
```toml
|
||||
[general]
|
||||
language = "" # Auto-detect
|
||||
|
||||
[providers.groq]
|
||||
api_key = "gsk_..."
|
||||
|
||||
[transcription]
|
||||
provider = "groq-transcription"
|
||||
model = "whisper-large-v3-turbo"
|
||||
language = "" # Auto-detect
|
||||
|
||||
[llm]
|
||||
enabled = true
|
||||
@@ -640,9 +575,6 @@ language = "" # Auto-detect
|
||||
### Mixed Providers (Groq Transcription + OpenAI LLM)
|
||||
|
||||
```toml
|
||||
[general]
|
||||
language = "" # Auto-detect
|
||||
|
||||
[providers.openai]
|
||||
api_key = "sk-..."
|
||||
|
||||
@@ -652,6 +584,7 @@ language = "" # Auto-detect
|
||||
[transcription]
|
||||
provider = "groq-transcription"
|
||||
model = "whisper-large-v3-turbo"
|
||||
language = "" # Auto-detect
|
||||
|
||||
[llm]
|
||||
enabled = true
|
||||
@@ -664,12 +597,10 @@ language = "" # Auto-detect
|
||||
```toml
|
||||
# No API keys needed!
|
||||
|
||||
[general]
|
||||
language = "" # Auto-detect
|
||||
|
||||
[transcription]
|
||||
provider = "whisper-cpp"
|
||||
model = "base.en"
|
||||
language = "" # Auto-detect
|
||||
threads = 0 # Auto-detect (NumCPU - 1)
|
||||
|
||||
[llm]
|
||||
@@ -679,15 +610,13 @@ language = "" # Auto-detect
|
||||
### Real-Time Streaming with Deepgram
|
||||
|
||||
```toml
|
||||
[general]
|
||||
language = "" # Auto-detect
|
||||
|
||||
[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
|
||||
@@ -696,32 +625,28 @@ language = "" # Auto-detect
|
||||
### Ultra-Low Latency Streaming
|
||||
|
||||
```toml
|
||||
[general]
|
||||
language = "" # Auto-detect
|
||||
|
||||
[providers.elevenlabs]
|
||||
api_key = "..."
|
||||
|
||||
[transcription]
|
||||
provider = "elevenlabs"
|
||||
model = "scribe_v2-streaming" # <150ms latency
|
||||
language = "" # Auto-detect
|
||||
|
||||
[llm]
|
||||
enabled = false
|
||||
```
|
||||
|
||||
### Multilingual Setup with Specific Language
|
||||
### Specific Language Setup
|
||||
|
||||
```toml
|
||||
[general]
|
||||
language = "es" # Always transcribe as Spanish
|
||||
|
||||
[providers.openai]
|
||||
api_key = "sk-..."
|
||||
|
||||
[transcription]
|
||||
provider = "openai"
|
||||
model = "whisper-1"
|
||||
language = "es" # Always transcribe as Spanish
|
||||
|
||||
[llm]
|
||||
enabled = true
|
||||
@@ -731,32 +656,31 @@ language = "es" # Always transcribe as Spanish
|
||||
|
||||
## Migration from Old Config Format
|
||||
|
||||
### Language Migration
|
||||
### Language Configuration Change
|
||||
|
||||
If you have `transcription.language` set in your config, it will continue to work but is now an override. The recommended approach is to move it to `[general]`:
|
||||
Language is now configured per transcription model in `[transcription].language`. If you had `[general].language` set, move it to the transcription section:
|
||||
|
||||
**Old format (still works as override):**
|
||||
|
||||
```toml
|
||||
[transcription]
|
||||
provider = "openai"
|
||||
language = "en" # Works but is now an override
|
||||
model = "whisper-1"
|
||||
```
|
||||
|
||||
**New format (recommended):**
|
||||
**Old format:**
|
||||
|
||||
```toml
|
||||
[general]
|
||||
language = "en" # Global setting
|
||||
language = "en"
|
||||
|
||||
[transcription]
|
||||
provider = "openai"
|
||||
model = "whisper-1"
|
||||
# language = "es" # Only set here to override [general]
|
||||
```
|
||||
|
||||
When loading, if `transcription.language` is set but `general.language` is not, the language is automatically migrated to the general section. Run `hyprvoice configure` and save to persist this change.
|
||||
**New format:**
|
||||
|
||||
```toml
|
||||
[transcription]
|
||||
provider = "openai"
|
||||
model = "whisper-1"
|
||||
language = "en"
|
||||
```
|
||||
|
||||
Run `hyprvoice configure` to interactively update your config.
|
||||
|
||||
### API Key Migration
|
||||
|
||||
|
||||
@@ -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
|
||||
- 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.
|
||||
+20
-201
@@ -1984,16 +1984,13 @@ func TestConfig_ToTranscriberConfig_Threads(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_EffectiveLanguage(t *testing.T) {
|
||||
t.Run("only general.language set", func(t *testing.T) {
|
||||
func TestConfig_TranscriptionLanguage(t *testing.T) {
|
||||
t.Run("language set in transcription", func(t *testing.T) {
|
||||
config := &Config{
|
||||
General: GeneralConfig{
|
||||
Language: "es",
|
||||
},
|
||||
Transcription: TranscriptionConfig{
|
||||
Provider: "openai",
|
||||
Model: "whisper-1",
|
||||
Language: "", // not set
|
||||
Language: "es",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2003,29 +2000,8 @@ func TestConfig_EffectiveLanguage(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("transcription.language overrides general.language", func(t *testing.T) {
|
||||
t.Run("empty language results in auto-detect", func(t *testing.T) {
|
||||
config := &Config{
|
||||
General: GeneralConfig{
|
||||
Language: "es",
|
||||
},
|
||||
Transcription: TranscriptionConfig{
|
||||
Provider: "openai",
|
||||
Model: "whisper-1",
|
||||
Language: "en", // overrides general
|
||||
},
|
||||
}
|
||||
|
||||
transcriberConfig := config.ToTranscriberConfig()
|
||||
if transcriberConfig.Language != "en" {
|
||||
t.Errorf("Language = %q, want %q", transcriberConfig.Language, "en")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("neither set results in auto", func(t *testing.T) {
|
||||
config := &Config{
|
||||
General: GeneralConfig{
|
||||
Language: "",
|
||||
},
|
||||
Transcription: TranscriptionConfig{
|
||||
Provider: "openai",
|
||||
Model: "whisper-1",
|
||||
@@ -2040,7 +2016,7 @@ func TestConfig_EffectiveLanguage(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestConfig_Validate_GeneralLanguage(t *testing.T) {
|
||||
func TestConfig_Validate_TranscriptionLanguage(t *testing.T) {
|
||||
baseConfig := func() *Config {
|
||||
return &Config{
|
||||
Recording: RecordingConfig{
|
||||
@@ -2066,63 +2042,46 @@ func TestConfig_Validate_GeneralLanguage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("valid general.language passes validation", func(t *testing.T) {
|
||||
t.Run("valid transcription.language passes validation", func(t *testing.T) {
|
||||
config := baseConfig()
|
||||
config.General.Language = "es"
|
||||
config.Transcription.Language = "es"
|
||||
|
||||
err := config.Validate()
|
||||
if err != nil {
|
||||
t.Errorf("Validate() should pass with valid general.language: %v", err)
|
||||
t.Errorf("Validate() should pass with valid transcription.language: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("general.language validated against model", func(t *testing.T) {
|
||||
t.Run("transcription.language validated against model", func(t *testing.T) {
|
||||
config := baseConfig()
|
||||
config.General.Language = "es"
|
||||
config.Transcription.Language = "es" // incompatible
|
||||
config.Transcription.Provider = "whisper-cpp"
|
||||
config.Transcription.Model = "base.en" // english-only model
|
||||
|
||||
err := config.Validate()
|
||||
if err == nil {
|
||||
t.Error("Validate() should fail when general.language incompatible with model")
|
||||
t.Error("Validate() should fail when transcription.language incompatible with model")
|
||||
}
|
||||
if err != nil && !strings.Contains(err.Error(), "does not support Spanish") {
|
||||
t.Errorf("error should mention Spanish, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("transcription.language override validated against model", func(t *testing.T) {
|
||||
t.Run("compatible language passes", func(t *testing.T) {
|
||||
config := baseConfig()
|
||||
config.General.Language = "en" // compatible
|
||||
config.Transcription.Language = "es" // override with incompatible
|
||||
config.Transcription.Provider = "whisper-cpp"
|
||||
config.Transcription.Model = "base.en" // english-only model
|
||||
|
||||
err := config.Validate()
|
||||
if err == nil {
|
||||
t.Error("Validate() should fail when transcription.language override is incompatible")
|
||||
}
|
||||
if err != nil && !strings.Contains(err.Error(), "does not support Spanish") {
|
||||
t.Errorf("error should mention Spanish, got: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid override with compatible language", func(t *testing.T) {
|
||||
config := baseConfig()
|
||||
config.General.Language = "es" // would be incompatible
|
||||
config.Transcription.Language = "en" // override with compatible
|
||||
config.Transcription.Language = "en" // compatible
|
||||
config.Transcription.Provider = "whisper-cpp"
|
||||
config.Transcription.Model = "base.en" // english-only model
|
||||
|
||||
err := config.Validate()
|
||||
if err != nil {
|
||||
t.Errorf("Validate() should pass when transcription.language override is compatible: %v", err)
|
||||
t.Errorf("Validate() should pass when transcription.language is compatible: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("auto language always passes", func(t *testing.T) {
|
||||
config := baseConfig()
|
||||
config.General.Language = "" // auto
|
||||
config.Transcription.Language = "" // auto
|
||||
config.Transcription.Provider = "whisper-cpp"
|
||||
config.Transcription.Model = "base.en" // english-only model
|
||||
|
||||
@@ -2133,8 +2092,8 @@ func TestConfig_Validate_GeneralLanguage(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestConfig_MigrateLanguageToGeneral(t *testing.T) {
|
||||
t.Run("old config with transcription.language migrates to general.language", func(t *testing.T) {
|
||||
func TestConfig_LoadWithTranscriptionLanguage(t *testing.T) {
|
||||
t.Run("config with transcription.language loads correctly", func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
configPath := filepath.Join(tempDir, "hyprvoice", "config.toml")
|
||||
|
||||
@@ -2143,77 +2102,7 @@ func TestConfig_MigrateLanguageToGeneral(t *testing.T) {
|
||||
t.Fatalf("Failed to create config directory: %v", err)
|
||||
}
|
||||
|
||||
// Old config with language in transcription section
|
||||
oldConfig := `[recording]
|
||||
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"
|
||||
language = "es"
|
||||
|
||||
[injection]
|
||||
backends = ["clipboard"]
|
||||
ydotool_timeout = "5s"
|
||||
wtype_timeout = "5s"
|
||||
clipboard_timeout = "3s"
|
||||
|
||||
[notifications]
|
||||
type = "log"`
|
||||
|
||||
err = os.WriteFile(configPath, []byte(oldConfig), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create config file: %v", err)
|
||||
}
|
||||
|
||||
originalConfigDir := os.Getenv("XDG_CONFIG_HOME")
|
||||
os.Setenv("XDG_CONFIG_HOME", tempDir)
|
||||
defer func() {
|
||||
if originalConfigDir == "" {
|
||||
os.Unsetenv("XDG_CONFIG_HOME")
|
||||
} else {
|
||||
os.Setenv("XDG_CONFIG_HOME", originalConfigDir)
|
||||
}
|
||||
}()
|
||||
|
||||
config, err := Load()
|
||||
if err != nil {
|
||||
t.Errorf("Load() error = %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Should have migrated to general.language
|
||||
if config.General.Language != "es" {
|
||||
t.Errorf("Expected general.language='es' after migration, got %q", config.General.Language)
|
||||
}
|
||||
|
||||
// Effective language should be 'es'
|
||||
transcriberConfig := config.ToTranscriberConfig()
|
||||
if transcriberConfig.Language != "es" {
|
||||
t.Errorf("Expected effective language 'es', got %q", transcriberConfig.Language)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("migration does not run when general.language already set", func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
configPath := filepath.Join(tempDir, "hyprvoice", "config.toml")
|
||||
|
||||
err := os.MkdirAll(filepath.Dir(configPath), 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create config directory: %v", err)
|
||||
}
|
||||
|
||||
// Config with both general.language and transcription.language set
|
||||
configContent := `[general]
|
||||
language = "fr"
|
||||
|
||||
[recording]
|
||||
configContent := `[recording]
|
||||
sample_rate = 16000
|
||||
channels = 1
|
||||
format = "s16"
|
||||
@@ -2257,80 +2146,10 @@ type = "log"`
|
||||
return
|
||||
}
|
||||
|
||||
// general.language should remain 'fr', not overwritten by migration
|
||||
if config.General.Language != "fr" {
|
||||
t.Errorf("Expected general.language='fr' (not migrated), got %q", config.General.Language)
|
||||
}
|
||||
|
||||
// transcription.language should still override
|
||||
// Effective language should be 'es'
|
||||
transcriberConfig := config.ToTranscriberConfig()
|
||||
if transcriberConfig.Language != "es" {
|
||||
t.Errorf("Expected effective language 'es' (transcription override), got %q", transcriberConfig.Language)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("original file not modified until explicit save", func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
configPath := filepath.Join(tempDir, "hyprvoice", "config.toml")
|
||||
|
||||
err := os.MkdirAll(filepath.Dir(configPath), 0755)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create config directory: %v", err)
|
||||
}
|
||||
|
||||
oldConfig := `[recording]
|
||||
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"
|
||||
language = "de"
|
||||
|
||||
[injection]
|
||||
backends = ["clipboard"]
|
||||
ydotool_timeout = "5s"
|
||||
wtype_timeout = "5s"
|
||||
clipboard_timeout = "3s"
|
||||
|
||||
[notifications]
|
||||
type = "log"`
|
||||
|
||||
err = os.WriteFile(configPath, []byte(oldConfig), 0644)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create config file: %v", err)
|
||||
}
|
||||
|
||||
originalConfigDir := os.Getenv("XDG_CONFIG_HOME")
|
||||
os.Setenv("XDG_CONFIG_HOME", tempDir)
|
||||
defer func() {
|
||||
if originalConfigDir == "" {
|
||||
os.Unsetenv("XDG_CONFIG_HOME")
|
||||
} else {
|
||||
os.Setenv("XDG_CONFIG_HOME", originalConfigDir)
|
||||
}
|
||||
}()
|
||||
|
||||
_, err = Load()
|
||||
if err != nil {
|
||||
t.Errorf("Load() error = %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Read the file again - should still have old format
|
||||
content, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read config file: %v", err)
|
||||
}
|
||||
|
||||
// File should NOT have [general] section (migration is in-memory only)
|
||||
if strings.Contains(string(content), "[general]") {
|
||||
t.Error("Original file should not be modified by migration - [general] section found")
|
||||
t.Errorf("Expected effective language 'es', got %q", transcriberConfig.Language)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -36,14 +36,10 @@ func (c *Config) ToTranscriberConfig() transcriber.Config {
|
||||
return config
|
||||
}
|
||||
|
||||
// resolveEffectiveLanguage returns the effective language for transcription.
|
||||
// transcription.language overrides general.language if set.
|
||||
// resolveEffectiveLanguage returns the language for transcription
|
||||
func (c *Config) resolveEffectiveLanguage() string {
|
||||
if c.Transcription.Language != "" {
|
||||
return c.Transcription.Language
|
||||
}
|
||||
return c.General.Language
|
||||
}
|
||||
|
||||
// resolveAPIKeyForProvider returns the API key for a provider from multiple sources
|
||||
func (c *Config) resolveAPIKeyForProvider(providerName string) string {
|
||||
|
||||
@@ -78,7 +78,6 @@ func Load() (*Config, error) {
|
||||
|
||||
config.applyLLMDefaults()
|
||||
config.applyThreadsDefault()
|
||||
config.migrateLanguageToGeneral()
|
||||
|
||||
log.Printf("Config: configuration loaded successfully")
|
||||
return &config, nil
|
||||
@@ -133,14 +132,6 @@ func (c *Config) applyLLMDefaults() {
|
||||
}
|
||||
}
|
||||
|
||||
// migrateLanguageToGeneral migrates old transcription.language to general.language
|
||||
func (c *Config) migrateLanguageToGeneral() {
|
||||
if c.Transcription.Language != "" && c.General.Language == "" {
|
||||
c.General.Language = c.Transcription.Language
|
||||
log.Printf("Config: migrated language setting to [general] section")
|
||||
}
|
||||
}
|
||||
|
||||
// migrateInjectionMode converts old mode field to new backends array
|
||||
func (c *Config) migrateInjectionMode(mode string) {
|
||||
switch mode {
|
||||
|
||||
+2
-15
@@ -41,13 +41,6 @@ func Save(cfg *Config) error {
|
||||
sb.WriteString("]\n\n")
|
||||
}
|
||||
|
||||
// General section
|
||||
sb.WriteString(`# General Settings
|
||||
[general]
|
||||
`)
|
||||
sb.WriteString(fmt.Sprintf(" language = %q\n", cfg.General.Language))
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Providers section
|
||||
if len(cfg.Providers) > 0 {
|
||||
sb.WriteString("# API Keys for providers\n")
|
||||
@@ -210,13 +203,6 @@ func SaveDefaultConfig() error {
|
||||
# will be automatically migrated to the new [providers.X] format. Run 'hyprvoice configure'
|
||||
# to update your config file structure.
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# General Settings
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[general]
|
||||
language = "" # Language for transcription (ISO 639-1 code, e.g., en, es, de). Empty for auto-detect.
|
||||
|
||||
# Keywords help both transcription and LLM understand domain-specific terms
|
||||
# Add names, technical terms, or brand names that might be misheard
|
||||
keywords = []
|
||||
@@ -262,8 +248,8 @@ keywords = []
|
||||
[transcription]
|
||||
provider = "openai" # "openai", "groq-transcription", "groq-translation", "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)
|
||||
# language = "" # Override general.language for this provider only
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# LLM Post-Processing (Recommended)
|
||||
@@ -353,6 +339,7 @@ keywords = []
|
||||
# - "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 {
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
// GeneralConfig holds global settings that apply across the application
|
||||
type GeneralConfig struct {
|
||||
Language string `toml:"language"` // ISO 639-1 code (e.g., en, es, de). Empty for auto-detect.
|
||||
// reserved for future use
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
|
||||
@@ -86,9 +86,6 @@ func (c *Config) Validate() error {
|
||||
}
|
||||
|
||||
// validate language codes - warn if not recognized but don't error
|
||||
if c.General.Language != "" && !language.IsValidCode(c.General.Language) {
|
||||
log.Printf("warning: unrecognized language code '%s' in general.language, will be passed as-is to provider", c.General.Language)
|
||||
}
|
||||
if c.Transcription.Language != "" && !language.IsValidCode(c.Transcription.Language) {
|
||||
log.Printf("warning: unrecognized language code '%s' in transcription.language, will be passed as-is to provider", c.Transcription.Language)
|
||||
}
|
||||
|
||||
@@ -32,6 +32,14 @@ type DeepgramAdapter struct {
|
||||
// reconnection config
|
||||
maxRetries int
|
||||
retryDelays []time.Duration
|
||||
|
||||
// finalization signaling
|
||||
finalizeDone chan struct{}
|
||||
}
|
||||
|
||||
// deepgramCloseStream message to signal end of audio
|
||||
type deepgramCloseStream struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// Deepgram WebSocket response types (incoming)
|
||||
@@ -84,6 +92,7 @@ func NewDeepgramAdapter(endpoint *provider.EndpointConfig, apiKey, model, lang s
|
||||
resultsCh: make(chan TranscriptionResult, 100),
|
||||
maxRetries: 3,
|
||||
retryDelays: defaultRetryDelays,
|
||||
finalizeDone: make(chan struct{}, 1),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,6 +303,11 @@ func (a *DeepgramAdapter) readLoop() {
|
||||
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}
|
||||
}
|
||||
@@ -371,6 +385,53 @@ 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:
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
||||
@@ -36,6 +36,9 @@ type ElevenLabsStreamingAdapter struct {
|
||||
// reconnection config
|
||||
maxRetries int
|
||||
retryDelays []time.Duration
|
||||
|
||||
// finalization signaling
|
||||
commitDone chan struct{}
|
||||
}
|
||||
|
||||
// ElevenLabs WebSocket message types (outgoing)
|
||||
@@ -69,6 +72,7 @@ func NewElevenLabsStreamingAdapter(endpoint *provider.EndpointConfig, apiKey, mo
|
||||
resultsCh: make(chan TranscriptionResult, 100),
|
||||
maxRetries: 3,
|
||||
retryDelays: defaultRetryDelays,
|
||||
commitDone: make(chan struct{}, 1),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,10 +274,15 @@ func (a *ElevenLabsStreamingAdapter) readLoop() {
|
||||
|
||||
case "committed_transcript", "committed_transcript_with_timestamps":
|
||||
// final result
|
||||
if msg.Text != "" {
|
||||
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",
|
||||
@@ -353,6 +362,59 @@ 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()
|
||||
|
||||
@@ -35,6 +35,9 @@ type OpenAIRealtimeAdapter struct {
|
||||
|
||||
// track current item for transcription
|
||||
currentItemID string
|
||||
|
||||
// finalization signaling
|
||||
transcriptionDone chan struct{}
|
||||
}
|
||||
|
||||
// OpenAI Realtime WebSocket message types (outgoing)
|
||||
@@ -110,6 +113,7 @@ func NewOpenAIRealtimeAdapter(endpoint *provider.EndpointConfig, apiKey, model,
|
||||
resultsCh: make(chan TranscriptionResult, 100),
|
||||
maxRetries: 3,
|
||||
retryDelays: defaultRetryDelays,
|
||||
transcriptionDone: make(chan struct{}, 1),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,10 +376,15 @@ func (a *OpenAIRealtimeAdapter) handleEvent(event openaiRealtimeServerEvent) {
|
||||
|
||||
case "conversation.item.input_audio_transcription.completed":
|
||||
// final transcription result
|
||||
if event.Transcript != "" {
|
||||
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)
|
||||
@@ -500,6 +509,56 @@ 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()
|
||||
|
||||
@@ -20,6 +20,11 @@ type StreamingAdapter interface {
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/leonardotrapani/hyprvoice/internal/recording"
|
||||
)
|
||||
@@ -83,18 +84,26 @@ func (t *StreamingTranscriber) receiveResults(errCh chan<- error) {
|
||||
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 {
|
||||
select {
|
||||
case errCh <- result.Error:
|
||||
default:
|
||||
}
|
||||
log.Printf("streaming transcriber: result error: %v", result.Error)
|
||||
continue
|
||||
return
|
||||
}
|
||||
if result.IsFinal && result.Text != "" {
|
||||
t.mu.Lock()
|
||||
@@ -105,10 +114,38 @@ func (t *StreamingTranscriber) receiveResults(errCh chan<- error) {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -688,6 +688,7 @@ 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
|
||||
@@ -720,6 +721,13 @@ func (m *MockStreamingAdapter) Results() <-chan TranscriptionResult {
|
||||
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()
|
||||
|
||||
@@ -37,7 +37,6 @@ type ConfigSection string
|
||||
|
||||
const (
|
||||
SectionProviders ConfigSection = "providers"
|
||||
SectionLanguage ConfigSection = "language"
|
||||
SectionTranscription ConfigSection = "transcription"
|
||||
SectionLLM ConfigSection = "llm"
|
||||
SectionKeywords ConfigSection = "keywords"
|
||||
@@ -111,11 +110,6 @@ func runEditExisting(cfg *config.Config) (*ConfigureResult, error) {
|
||||
}
|
||||
configuredProviders = getConfiguredProviders(cfg)
|
||||
|
||||
case SectionLanguage:
|
||||
if err := editLanguage(cfg); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
case SectionTranscription:
|
||||
var err error
|
||||
configuredProviders, err = editTranscription(cfg, configuredProviders)
|
||||
@@ -160,7 +154,6 @@ func runEditExisting(cfg *config.Config) (*ConfigureResult, error) {
|
||||
func selectSection(cfg *config.Config) (ConfigSection, error) {
|
||||
options := []huh.Option[ConfigSection]{
|
||||
huh.NewOption(formatProvidersLabel(cfg), SectionProviders),
|
||||
huh.NewOption(formatLanguageMenuLabel(cfg), SectionLanguage),
|
||||
huh.NewOption(formatTranscriptionLabel(cfg), SectionTranscription),
|
||||
huh.NewOption(formatLLMLabel(cfg), SectionLLM),
|
||||
huh.NewOption(formatKeywordsLabel(cfg), SectionKeywords),
|
||||
|
||||
@@ -13,11 +13,6 @@ func formatProvidersLabel(cfg *config.Config) string {
|
||||
return "Providers"
|
||||
}
|
||||
|
||||
// formatLanguageMenuLabel formats the language menu option
|
||||
func formatLanguageMenuLabel(cfg *config.Config) string {
|
||||
return "Language"
|
||||
}
|
||||
|
||||
// formatTranscriptionLabel formats the transcription menu option
|
||||
func formatTranscriptionLabel(cfg *config.Config) string {
|
||||
return "Transcription"
|
||||
@@ -54,10 +49,11 @@ func showSummary(cfg *config.Config) (bool, error) {
|
||||
}
|
||||
fmt.Printf(" %s %s\n", StyleLabel.Render("Providers:"), strings.Join(providers, ", "))
|
||||
|
||||
fmt.Printf(" %s %s (%s)\n", StyleLabel.Render("Transcription:"), cfg.Transcription.Provider, cfg.Transcription.Model)
|
||||
if cfg.Transcription.Language != "" {
|
||||
fmt.Printf(" %s %s\n", StyleLabel.Render("Language:"), cfg.Transcription.Language)
|
||||
lang := cfg.Transcription.Language
|
||||
if lang == "" {
|
||||
lang = "auto-detect"
|
||||
}
|
||||
fmt.Printf(" %s %s/%s (%s)\n", StyleLabel.Render("Transcription:"), cfg.Transcription.Provider, cfg.Transcription.Model, lang)
|
||||
|
||||
if cfg.LLM.Enabled {
|
||||
fmt.Printf(" %s %s (%s)\n", StyleLabel.Render("LLM:"), cfg.LLM.Provider, cfg.LLM.Model)
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/leonardotrapani/hyprvoice/internal/config"
|
||||
"github.com/leonardotrapani/hyprvoice/internal/language"
|
||||
"github.com/leonardotrapani/hyprvoice/internal/provider"
|
||||
)
|
||||
|
||||
// editLanguage allows the user to select the global transcription language
|
||||
func editLanguage(cfg *config.Config) error {
|
||||
// no model-specific warnings for global language selection
|
||||
languageOptions := getLanguageOptions(nil, cfg.General.Language)
|
||||
|
||||
selectedLanguage := cfg.General.Language
|
||||
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("Language").
|
||||
Description("Select language for transcription (applies globally)").
|
||||
Options(languageOptions...).
|
||||
Filtering(true).
|
||||
Value(&selectedLanguage),
|
||||
),
|
||||
).WithTheme(getTheme())
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// check if current transcription model supports the selected language
|
||||
if selectedLanguage != "" && cfg.Transcription.Provider != "" && cfg.Transcription.Model != "" {
|
||||
registryName := mapConfigProviderToRegistry(cfg.Transcription.Provider)
|
||||
model, err := provider.GetModel(registryName, cfg.Transcription.Model)
|
||||
if err == nil && !model.SupportsLanguage(selectedLanguage) {
|
||||
langName := language.FromCode(selectedLanguage).Name
|
||||
if langName == "" {
|
||||
langName = selectedLanguage
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println(StyleWarning.Render("Language-Model Compatibility Warning"))
|
||||
fmt.Printf("Your current model '%s' does not support %s.\n", model.Name, langName)
|
||||
fmt.Println()
|
||||
fmt.Println(StyleMuted.Render("You can:"))
|
||||
fmt.Println(StyleMuted.Render(" - Keep this language and change the model later"))
|
||||
fmt.Println(StyleMuted.Render(" - Use 'Auto-detect' for language"))
|
||||
fmt.Println(StyleMuted.Render(" - Choose a different language"))
|
||||
fmt.Println()
|
||||
|
||||
var action string
|
||||
actionForm := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("What would you like to do?").
|
||||
Options(
|
||||
huh.NewOption("Keep this language (change model later)", "keep"),
|
||||
huh.NewOption("Use Auto-detect instead", "auto"),
|
||||
huh.NewOption("Choose a different language", "retry"),
|
||||
).
|
||||
Value(&action),
|
||||
),
|
||||
).WithTheme(getTheme())
|
||||
|
||||
if err := actionForm.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch action {
|
||||
case "auto":
|
||||
selectedLanguage = ""
|
||||
case "retry":
|
||||
return editLanguage(cfg)
|
||||
case "keep":
|
||||
// proceed with incompatible language
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cfg.General.Language = selectedLanguage
|
||||
return nil
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/leonardotrapani/hyprvoice/internal/config"
|
||||
"github.com/leonardotrapani/hyprvoice/internal/deps"
|
||||
"github.com/leonardotrapani/hyprvoice/internal/language"
|
||||
"github.com/leonardotrapani/hyprvoice/internal/models/whisper"
|
||||
"github.com/leonardotrapani/hyprvoice/internal/provider"
|
||||
)
|
||||
@@ -114,13 +113,7 @@ func editTranscription(cfg *config.Config, configuredProviders []string) ([]stri
|
||||
}
|
||||
cfg.Transcription.Provider = selectedProvider
|
||||
|
||||
// use effective language for model compatibility display
|
||||
effectiveLanguage := cfg.General.Language
|
||||
if cfg.Transcription.Language != "" {
|
||||
effectiveLanguage = cfg.Transcription.Language
|
||||
}
|
||||
|
||||
modelOptions := getTranscriptionModelOptions(selectedProvider, effectiveLanguage)
|
||||
modelOptions := getTranscriptionModelOptions(selectedProvider)
|
||||
selectedModel := cfg.Transcription.Model
|
||||
if selectedModel == "" && len(modelOptions) > 0 {
|
||||
// skip header options (empty value) to find first real model
|
||||
@@ -156,41 +149,7 @@ func editTranscription(cfg *config.Config, configuredProviders []string) ([]stri
|
||||
return editTranscription(cfg, configuredProviders)
|
||||
}
|
||||
|
||||
// validate language-model compatibility before saving
|
||||
registryName := mapConfigProviderToRegistry(selectedProvider)
|
||||
if err := provider.ValidateModelLanguage(registryName, selectedModel, effectiveLanguage); err != nil {
|
||||
// show error dialog - user needs to change language in Language menu
|
||||
fmt.Println()
|
||||
fmt.Println(StyleError.Render("Language-Model Incompatibility"))
|
||||
fmt.Println(StyleMuted.Render(err.Error()))
|
||||
fmt.Println()
|
||||
fmt.Println(StyleMuted.Render("You can:"))
|
||||
fmt.Println(StyleMuted.Render(" - Choose a different model that supports your language"))
|
||||
fmt.Println(StyleMuted.Render(" - Change language to 'Auto-detect' in the Language menu"))
|
||||
fmt.Println()
|
||||
|
||||
var retry bool
|
||||
retryForm := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewConfirm().
|
||||
Title("Try again?").
|
||||
Description("Choose a different model").
|
||||
Affirmative("Yes, let me pick another model").
|
||||
Negative("Cancel").
|
||||
Value(&retry),
|
||||
),
|
||||
).WithTheme(getTheme())
|
||||
|
||||
if err := retryForm.Run(); err != nil {
|
||||
return configuredProviders, err
|
||||
}
|
||||
|
||||
if retry {
|
||||
// recurse to let user pick another model
|
||||
return editTranscription(cfg, configuredProviders)
|
||||
}
|
||||
return configuredProviders, nil
|
||||
}
|
||||
|
||||
// for whisper-cpp, check if model needs download
|
||||
if selectedProvider == "whisper-cpp" && !whisper.IsInstalled(selectedModel) {
|
||||
@@ -245,11 +204,34 @@ func editTranscription(cfg *config.Config, configuredProviders []string) ([]stri
|
||||
|
||||
cfg.Transcription.Model = selectedModel
|
||||
|
||||
// set streaming mode based on model capabilities
|
||||
// select language for this model
|
||||
model, err := provider.GetModel(registryName, selectedModel)
|
||||
if err == nil {
|
||||
if err != nil {
|
||||
return configuredProviders, err
|
||||
}
|
||||
|
||||
languageOptions := getModelLanguageOptions(model, cfg.Transcription.Language)
|
||||
selectedLanguage := cfg.Transcription.Language
|
||||
|
||||
languageForm := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("Language").
|
||||
Description("Select language for transcription").
|
||||
Options(languageOptions...).
|
||||
Filtering(true).
|
||||
Value(&selectedLanguage),
|
||||
),
|
||||
).WithTheme(getTheme())
|
||||
|
||||
if err := languageForm.Run(); err != nil {
|
||||
return configuredProviders, err
|
||||
}
|
||||
|
||||
cfg.Transcription.Language = selectedLanguage
|
||||
|
||||
// set streaming mode based on model capabilities
|
||||
if model.SupportsBothModes() {
|
||||
// model supports both: ask user
|
||||
useStreaming := cfg.Transcription.Streaming
|
||||
streamingForm := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
@@ -267,14 +249,11 @@ func editTranscription(cfg *config.Config, configuredProviders []string) ([]stri
|
||||
}
|
||||
cfg.Transcription.Streaming = useStreaming
|
||||
} else if model.SupportsStreaming {
|
||||
// streaming-only model
|
||||
cfg.Transcription.Streaming = true
|
||||
fmt.Println(StyleSuccess.Render("Streaming mode enabled (this model only supports streaming)"))
|
||||
} else {
|
||||
// batch-only model
|
||||
cfg.Transcription.Streaming = false
|
||||
}
|
||||
}
|
||||
|
||||
return configuredProviders, nil
|
||||
}
|
||||
@@ -304,7 +283,7 @@ func getUnconfiguredTranscriptionOptions(configuredProviders []string) []huh.Opt
|
||||
return options
|
||||
}
|
||||
|
||||
func getTranscriptionModelOptions(configProvider string, currentLang string) []huh.Option[string] {
|
||||
func getTranscriptionModelOptions(configProvider string) []huh.Option[string] {
|
||||
// special case: groq-translation only supports whisper-large-v3
|
||||
if configProvider == "groq-translation" {
|
||||
return []huh.Option[string]{
|
||||
@@ -323,7 +302,7 @@ func getTranscriptionModelOptions(configProvider string, currentLang string) []h
|
||||
|
||||
var options []huh.Option[string]
|
||||
for _, m := range models {
|
||||
label := buildModelLabel(m, currentLang)
|
||||
label := buildModelLabel(m)
|
||||
if m.Local && registryName == "whisper-cpp" {
|
||||
if whisper.IsInstalled(m.ID) {
|
||||
label = "[x] " + label
|
||||
@@ -350,7 +329,7 @@ func mapConfigProviderToRegistry(configProvider string) string {
|
||||
}
|
||||
|
||||
// buildModelLabel creates the display label for a model option
|
||||
func buildModelLabel(m provider.Model, currentLang string) string {
|
||||
func buildModelLabel(m provider.Model) string {
|
||||
label := fmt.Sprintf("%s (%s)", m.Name, m.Description)
|
||||
|
||||
// append size for local models
|
||||
@@ -364,22 +343,6 @@ func buildModelLabel(m provider.Model, currentLang string) string {
|
||||
} else if m.SupportsStreaming {
|
||||
label += " [streaming]"
|
||||
}
|
||||
// batch-only models don't need a tag (it's the default)
|
||||
|
||||
// append language warning if model doesn't support current language
|
||||
if currentLang != "" && !m.SupportsLanguage(currentLang) {
|
||||
langName := getLangName(currentLang)
|
||||
label += fmt.Sprintf(" (does not support %s)", langName)
|
||||
}
|
||||
|
||||
return label
|
||||
}
|
||||
|
||||
// getLangName returns a human-readable language name for a code
|
||||
func getLangName(code string) string {
|
||||
lang := language.FromCode(code)
|
||||
if lang.Code == "" {
|
||||
return code // unknown code, return as-is
|
||||
}
|
||||
return lang.Name
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
func TestGetTranscriptionModelOptions_ShowsCapabilities(t *testing.T) {
|
||||
// test elevenlabs - has batch-only and streaming-only models
|
||||
options := getTranscriptionModelOptions("elevenlabs", "")
|
||||
options := getTranscriptionModelOptions("elevenlabs")
|
||||
|
||||
// should have 3 models: scribe_v1, scribe_v2, scribe_v2_realtime
|
||||
if len(options) != 3 {
|
||||
@@ -40,7 +40,7 @@ func TestGetTranscriptionModelOptions_ShowsCapabilities(t *testing.T) {
|
||||
|
||||
func TestGetTranscriptionModelOptions_NoHeadersAnymore(t *testing.T) {
|
||||
// we removed batch/streaming section headers
|
||||
options := getTranscriptionModelOptions("elevenlabs", "")
|
||||
options := getTranscriptionModelOptions("elevenlabs")
|
||||
|
||||
for _, opt := range options {
|
||||
if opt.Value == "" {
|
||||
@@ -50,7 +50,7 @@ func TestGetTranscriptionModelOptions_NoHeadersAnymore(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetTranscriptionModelOptions_OpenAI_ShowsCapabilities(t *testing.T) {
|
||||
options := getTranscriptionModelOptions("openai", "")
|
||||
options := getTranscriptionModelOptions("openai")
|
||||
|
||||
// OpenAI has 3 transcription models: whisper-1, gpt-4o-transcribe, gpt-4o-mini-transcribe
|
||||
if len(options) != 3 {
|
||||
@@ -68,7 +68,7 @@ func TestGetTranscriptionModelOptions_OpenAI_ShowsCapabilities(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetTranscriptionModelOptions_Deepgram_ShowsBothModes(t *testing.T) {
|
||||
options := getTranscriptionModelOptions("deepgram", "")
|
||||
options := getTranscriptionModelOptions("deepgram")
|
||||
|
||||
// Deepgram has 2 models: nova-3, nova-2 - both support batch+streaming
|
||||
if len(options) != 2 {
|
||||
@@ -84,7 +84,7 @@ func TestGetTranscriptionModelOptions_Deepgram_ShowsBothModes(t *testing.T) {
|
||||
|
||||
func TestGetTranscriptionModelOptions_Groq_BatchOnly(t *testing.T) {
|
||||
// test groq - batch only (no streaming models)
|
||||
options := getTranscriptionModelOptions("groq-transcription", "")
|
||||
options := getTranscriptionModelOptions("groq-transcription")
|
||||
|
||||
// should have 2 models: whisper-large-v3, whisper-large-v3-turbo
|
||||
if len(options) != 2 {
|
||||
|
||||
@@ -39,12 +39,7 @@ func runFreshInstall(cfg *config.Config) (*ConfigureResult, error) {
|
||||
return &ConfigureResult{Cancelled: true}, nil
|
||||
}
|
||||
|
||||
// 4. Language selection
|
||||
if err := editLanguage(cfg); err != nil {
|
||||
return &ConfigureResult{Cancelled: true}, nil
|
||||
}
|
||||
|
||||
// 5. Keywords
|
||||
// 4. Keywords
|
||||
keywords, err := inputKeywords(cfg.Keywords)
|
||||
if err != nil {
|
||||
return &ConfigureResult{Cancelled: true}, nil
|
||||
|
||||
@@ -8,10 +8,8 @@ import (
|
||||
"github.com/leonardotrapani/hyprvoice/internal/provider"
|
||||
)
|
||||
|
||||
// getLanguageOptions returns language options for the dropdown
|
||||
// if currentModel is provided, languages unsupported by that model will be marked
|
||||
// currentLang is the currently selected language code (empty string for auto-detect)
|
||||
func getLanguageOptions(currentModel *provider.Model, currentLang string) []huh.Option[string] {
|
||||
// getModelLanguageOptions returns language options supported by the given model
|
||||
func getModelLanguageOptions(model *provider.Model, currentLang string) []huh.Option[string] {
|
||||
var options []huh.Option[string]
|
||||
|
||||
// auto-detect is always first
|
||||
@@ -21,18 +19,15 @@ func getLanguageOptions(currentModel *provider.Model, currentLang string) []huh.
|
||||
}
|
||||
options = append(options, huh.NewOption(autoLabel, ""))
|
||||
|
||||
// add all languages
|
||||
// only show languages supported by the model
|
||||
for _, lang := range language.List() {
|
||||
label := formatLanguageLabel(lang)
|
||||
|
||||
// mark current selection
|
||||
if lang.Code == currentLang {
|
||||
label += " (current)"
|
||||
if model != nil && !model.SupportsLanguage(lang.Code) {
|
||||
continue
|
||||
}
|
||||
|
||||
// add warning if model doesn't support this language
|
||||
if currentModel != nil && !currentModel.SupportsLanguage(lang.Code) {
|
||||
label += " (not supported by current model)"
|
||||
label := formatLanguageLabel(lang)
|
||||
if lang.Code == currentLang {
|
||||
label += " (current)"
|
||||
}
|
||||
|
||||
options = append(options, huh.NewOption(label, lang.Code))
|
||||
|
||||
Reference in New Issue
Block a user