* feat(audio): opt-in lazy stream close for bluetooth mic latency
keep the microphone stream open for 30s after recording stops to
reduce latency on back-to-back transcriptions. gated behind an
experimental setting (off by default) since it keeps the mic actively
capturing while idle — degrading bluetooth audio quality on macOS.
adds lazy_stream_close setting, tauri command, toggle in experimental
settings section, and i18n strings.
* fix: gate lazy close on setting in cancel_recording path
cancel_recording was unconditionally calling schedule_lazy_close,
keeping the mic open for 30s even when the setting was disabled.
also bump close_generation on AlwaysOn→OnDemand switch to cancel
any stale lazy close timers.
* feat(audio): opt-in lazy stream close for bluetooth mic latency
keep the microphone stream open for 30s after recording stops to
reduce latency on back-to-back transcriptions. gated behind an
experimental setting (off by default) since it keeps the mic actively
capturing while idle — degrading bluetooth audio quality on macOS.
adds lazy_stream_close setting, tauri command, toggle in experimental
settings section, and i18n strings.
* fix: gate lazy close on setting in cancel_recording path
cancel_recording was unconditionally calling schedule_lazy_close,
keeping the mic open for 30s even when the setting was disabled.
also bump close_generation on AlwaysOn→OnDemand switch to cancel
any stale lazy close timers.
* chore: fix cargo fmt on model.rs line from main merge
* fix race
---------
Co-authored-by: CJ Pais <cj@cjpais.com>
* fix: prevent idle watcher from unloading model during recording
the idle watcher treats ModelUnloadTimeout::Immediately as a 0s
timeout, causing idle_ms > 0 to always be true. this unloads the
model on the next 10s tick — even while the user is still recording.
the Immediately variant is already handled correctly by
maybe_unload_immediately() which fires after each transcription
completes. the idle watcher should skip it entirely.
fixes regression introduced in d1da935.
* fix race condition
* cleanup
* format
---------
Co-authored-by: CJ Pais <cj@cjpais.com>
* feat(audio): use device default sample rate and always downsample
instead of forcing the microphone to open at 16kHz (which can cause
issues with bluetooth codecs, some ALSA drivers, and other devices
that advertise 16kHz support but produce suboptimal audio), use the
device's native/default sample rate and let the existing FrameResampler
downsample to 16kHz for the whisper pipeline.
the resampling infrastructure (rubato FftFixedIn) already exists in
run_consumer() and short-circuits when in_hz == out_hz, so devices
that natively default to 16kHz are unaffected.
* fix: graceful fallback when supported_input_configs fails
some ALSA/PipeWire drivers support default_input_config but have
a broken supported_input_configs implementation. fall back to the
default config instead of propagating the error. also add a warn
log when no config matches the device's default rate.
- Default model_unload_timeout from Never to Min5
- Fix Drop impl: use take() on watcher handle so clones from
initiate_model_load() don't kill the watcher thread
- Reset last_activity on model load to prevent immediate unload
- Upgrade watcher logging from debug to info level
- Remove duplicate "unloaded" event (unload_model already emits it)
Co-authored-by: CJ Pais <cj@cjpais.com>
* first pass at gpu acceleration
* unload the model when changing
* mock accelerator
* translations and build fix
* format
* clean up helptext
* type cleanup
* format
* lint
* refactor: migrate transcribe-rs from v0.2.8 to v0.3.0
Breaking API migration:
- engines::* → onnx::* module paths, whisper_cpp::* for Whisper
- TranscriptionEngine trait → SpeechModel trait
- Two-step new()+load_model() → one-step Model::load()
- transcribe_samples(Vec<f32>) → transcribe(&[f32])
- Explicit unload_model() → RAII drop
- Canary engine now from transcribe-rs instead of local crate
Removes canary-engine local crate dependency.
Uses path dependency to local transcribe-rs clone (temporary).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: upgrade transcribe-rs to v0.3.1, add Canary models
Update transcribe-rs dependency from v0.2.8 to v0.3.1 with
restructured features (whisper + onnx). Add Canary 180M Flash
(4 languages, 146MB) and Canary 1B v2 (25 EU languages, 692MB)
with translation support. Add i18n translations for all 17 locales.
Languages verified against HuggingFace model cards:
- Canary 180M Flash: en, de, es, fr
- Canary 1B v2: bg, hr, cs, da, nl, en, et, fi, fr, de, el, hu,
it, lv, lt, mt, pl, pt, ro, sk, sl, es, sv, ru, uk
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* make sure when changing models the language changes with it
* format
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: CJ Pais <cj@cjpais.com>
Whisper supports an `initial_prompt` parameter that biases the model's
vocabulary during transcription. For Whisper models, we now join the
user's custom words list into a comma-separated string and pass it as
the initial_prompt. This guides the model to prefer those spellings
during decoding rather than relying solely on fuzzy post-correction.
For non-Whisper engines (Parakeet, Moonshine, SenseVoice, GigaAM),
the existing apply_custom_words post-correction continues to apply
since those engines don't support prompt-based vocabulary biasing.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* Handle microphone start failures without aborting
Return recorder init errors instead of panicking/aborting in the worker thread and reset UI state when recording start fails.
Refs #436
* prop errors to ui
* format
* Add missing recording error translation keys
---------
Co-authored-by: CJ Pais <cj@cjpais.com>
* Remove filler words that conflict with European languages
Removed three filler words from the transcription filter that are actual
words in European languages:
- 'um' - Portuguese/Spanish indefinite article meaning 'a/an' (masculine)
Example: Portuguese 'Isto é um teste' (This is a test)
Removing this was breaking Portuguese transcriptions
- 'ha' - Spanish/Italian/Norwegian/Swedish auxiliary verb meaning 'has/have'
Example: Spanish 'Él ha comido' (He has eaten)
This is a very common verb form in Romance and Scandinavian languages
- 'eh' - Italian interjection and Canadian English discourse marker
While less critical, this can appear in legitimate Italian speech
The remaining filler words are primarily English
vocalized hesitations that don't conflict with common words in other
European languages.
Updated tests to use 'uhm' instead of 'um' where needed.
Fixes#941
* customize list
---------
Co-authored-by: Alexander Yastrebov <yastrebov.alex@gmail.com>
* ci: add temporary test workflow for portable ZIP validation
* ci: fix mock setup in test workflow
* ci: remove temporary test workflow
* feat: add portable mode option to NSIS installer
Custom NSIS template based on tauri-v2.9.1 upstream with 6 targeted
modifications for portable mode support:
- New PortableMode variable and /PORTABLE CLI flag for silent installs
- Install type selection page with Normal/Portable radio buttons
- Conditional default directory (Desktop for portable, LocalAppData for normal)
- Skip registry writes, file associations, deep links, and uninstaller for portable
- Skip Start Menu and desktop shortcuts for portable installs
- Create portable marker file and Data/ directory on portable install
The existing portable.rs runtime detection is unchanged — it looks for the
same marker file this installer creates.
Usage:
GUI: select "Portable Installation" on the install type page
Silent: Handy_setup.exe /S /PORTABLE /D=C:\path\to\Handy
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: implement true portable mode via source code path override
Detect a `portable` marker file next to the executable at startup.
When present, redirect all data paths (settings, models, recordings,
database, logs) to a local `Data/` directory instead of %APPDATA%.
This approach works because it intercepts path resolution at the Rust
level, bypassing Tauri/Windows Shell API limitations that made the
previous environment-variable and junction approaches unreliable.
Changes:
- Add src-tauri/src/portable.rs with OnceLock-based detection
- Replace all app.path().app_data_dir() calls with portable helper
- Configure tauri-plugin-log to use Folder target when portable
- Configure tauri-plugin-store with absolute path when portable
- Simplify build.yml portable ZIP (exe + resources + marker file)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: add missing Manager trait import in portable.rs
The .path() method on AppHandle requires `tauri::Manager` in scope.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: redirect WebView2 cache to portable Data dir
Move main window creation from tauri.conf.json to setup closure so
we can set data_directory when in portable mode. This prevents
WebView2 from creating %LOCALAPPDATA%\com.pais.handy.
Also set data_directory on the recording overlay window.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: auto-detect portable mode during updates
When the Tauri updater runs the installer with /UPDATE, the install type
page is skipped and /PORTABLE is not passed. Detect the existing portable
marker file in .onInit so updates preserve portable mode automatically.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* style: fix prettier formatting in portable.rs and lib.rs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: CJ Pais <cj@cjpais.com>
* feat: add GigaAM v3 model for Russian speech recognition
Add GigaAM v3 e2e_ctc as a new transcription engine using
transcribe-rs 0.2.7 gigaam feature. Russian speech recognition
with punctuation, Latin characters and digit support.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: cargo fmt formatting
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Keep the file name of the model download the same as the file on the
blob website.
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: CJ Pais <cj@cjpais.com>
The tray locale lookup truncated "zh-TW" to "zh", always hitting
Simplified Chinese. Now tries full locale first, then language prefix,
then English.
Follow-up fix for #796.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: CJ Pais <cj@cjpais.com>
* feat: add CLI parameters for Linux (--start-hidden, --no-tray, --toggle-transcription, --debug)
Add command-line interface using clap for better Linux desktop integration:
- --start-hidden: launch without showing the main window
- --no-tray: launch without system tray icon (closing window quits app)
- --toggle-transcription: toggle recording on/off on a running instance
via tauri_plugin_single_instance
- --debug: enable debug mode with Trace-level logging (runtime-only)
Extract toggle_transcription() from signal_handle.rs into a reusable
function shared between SIGUSR2 handler and CLI single-instance callback.
Update CLAUDE.md and README.md with CLI documentation including setup
instructions for GNOME, KDE Plasma, Sway/i3, and Hyprland.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* add cancel and post processs
* cleanup
* format
* docs
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: CJ Pais <cj@cjpais.com>
* feat: implement structured outputs for Cerebras, OpenRouter, OpenAI, and Apple Intelligence
- Add structured output support with JSON schema in llm_client.rs
- Update actions.rs to use system prompt + user content approach
- Remove legacy ${output} variable substitution for supported providers
- Update Apple Intelligence Swift code to accept system prompts
- All providers now use consistent structured output format
- Remove duplicate check_apple_intelligence_availability function
* wip changes
* fix(structured-outputs): address PR #706 review comments
- Add settings migration to sync supports_structured_output field for existing providers
- Fix fallback behavior: structured output failures now fall through to legacy mode
- Clone api_key to prevent ownership issues in fallback path
- Clean up build_system_prompt(): remove placeholder entirely
(instead of replacing with 'the user's message' which reads awkwardly)
- Add warn import from log crate
* refactor(structured-outputs): apply best practice improvements
- Optimize settings migration: use single match instead of double iteration
- Add TRANSCRIPTION_FIELD constant to replace magic strings
- Keep Apple Intelligence behavior unchanged (no API fallback for privacy)
Addresses code review feedback on PR #706:
1. More efficient provider lookup in ensure_post_process_defaults()
2. Eliminates hardcoded 'transcription' string in JSON parsing
3. Maintains privacy-first approach for Apple Intelligence
* fix groq output
---------
Co-authored-by: CJ Pais <cj@cjpais.com>
* feat(linux): add SIGUSR1 signal for transcribe_with_post_process
Register SIGUSR1 alongside SIGUSR2 in the signal handler so Linux
users can trigger transcription with post-processing via system
signals (e.g. `pkill -SIGUSR1 -x handy`). This mirrors the existing
SIGUSR2 toggle pattern and maps to the `transcribe_with_post_process`
action already present in ACTION_MAP.
Closes#758
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* readme
* format
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: CJ Pais <cj@cjpais.com>
* fix: add TranscriptionState to prevent race conditions & add debounce
* refactor(transcription): replace state machine with actor coordinator
* some cleanup
* format
* minor cleanup
* format
---------
Co-authored-by: CJ Pais <cj@cjpais.com>
* feat(i18n): add Traditional Chinese (zh-TW) locale
* style(i18n): clarify zh as Simplified Chinese and format languages.ts
* tweak
---------
Co-authored-by: CJ Pais <cj@cjpais.com>
The previous implementation only applied the workaround on X11 sessions,
leaving Wayland users affected by WebkitGTK DMABUF crashes. This affects
various GPU configurations (at least Intel and NVIDIA) on both X11 and Wayland.
This change applies the workaround unconditionally on Linux, ensuring
the app doesn't crash on affected systems while having minimal performance
impact.
Related: https://github.com/tauri-apps/tauri/issues/9394
Co-authored-by: suuuehgi <>
* feat(linux): Add option to select typing tool
- Added a Linux-only typing tool setting shown for direct paste, with a new dropdown in advanced settings.
- Wired new typing tool setting through settings store, bindings, and Tauri commands.
- Added typing tool enum/defaults and selection handling in Linux clipboard direct typing.
- Added translations for the new typing tool setting across locales.
* fix compilation on macos
* format
* only list available tools
* format
---------
Co-authored-by: Thinh Vu <phuocthinhvu@gmail.com>
Co-authored-by: CJ Pais <cj@cjpais.com>
* feat: add configurable auto-submit after transcription paste
Add an Advanced output setting that can send Enter, Ctrl+Enter, or Cmd+Enter after text insertion so Handy works smoothly with different editor and agent submit shortcuts. This keeps the behavior opt-in, persists it in settings, and adds focused Rust tests for defaults and paste auto-submit decision logic.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat: simplify auto submit configuration UX
Replace the auto-submit toggle and key dropdown with a single dropdown that includes an Off state, so output behavior is clearer and faster to configure. Clarify Meta-key behavior by showing Cmd+Enter on macOS and Super+Enter on Windows/Linux, with matching localized copy.
Co-authored-by: Cursor <cursoragent@cursor.com>
* use bindings generated on macos
* and translations.
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: CJ Pais <cj@cjpais.com>
* feat: add show tray icon setting
Allow hiding system tray icon via Settings > Advanced.
When hidden, closing window quits app instead of hiding.
* i8n
---------
Co-authored-by: CJ Pais <cj@cjpais.com>
* feat(models): auto-discover custom Whisper models in models directory
Enable automatic discovery of custom GGML-format Whisper models (.bin files)
placed in the models directory, so users don't need to modify source code
to use their own fine-tuned models.
Backend changes:
- Add discover_custom_whisper_models() to scan for .bin files
- Generate display names from filenames (e.g., "my-model" → "My Model")
- Skip predefined model filenames to avoid duplicates
- Add 3 unit tests for discovery logic
Frontend changes:
- Split model dropdown into 3 sections: Custom, Downloaded, Downloadable
- Add collapsible section for downloadable models to reduce clutter
- Add max-height with scroll for long model lists
- Add "Custom" badge for user-provided models
* feat(models): make custom Whisper model discovery opt-in
[why] Address maintainer concern about support burden from community
models. Custom models should be a power-user feature, not enabled by
default.
[how] Add custom_models_enabled setting (default: false) in Debug
settings. Discovery only runs when enabled. Models show "Not officially
supported" messaging. Documentation updated with enable steps.
* feat(models): add is_custom field and integrate custom models with new UI
[why]
PR review requested an explicit is_custom field instead of inferring
custom status from url === null. Custom models also need proper
integration with the new models settings page from PR #478.
[how]
- Add is_custom: bool to ModelInfo struct, set true on discovered models
- Change discover_custom_whisper_models signature from &PathBuf to &Path
- Use 0.0 sentinel scores so UI hides score bars for custom models
- Add "Custom Models" section to ModelsSettings with 3-way model split
- Show "Custom" badge in ModelCard and ModelDropdown
- Hide language/translation tags when supported_languages is empty
- Remove custom models from available_models on delete instead of
just marking as not downloaded (they have no re-download URL)
- Update tests with new fields and assertions
* chore(i18n): add custom model translation keys to all locales
[why]
Custom model feature introduces 5 new translation keys that need
to be present in all 15 non-English locales for CI to pass.
[how]
Add English placeholder values for: customModelDescription,
modelSelector.custom, settings.models.customModels,
settings.debug.customModels.label, settings.debug.customModels.description
* fix(models): keep language filter visible when no models match
[why]
The language filter was inside a conditionally rendered section
that disappeared when no downloaded models matched the selected
language, leaving the user stuck with no way to change the filter.
[how]
Always render the "Your Models" header row with the language filter,
only conditionally render the model cards below it.
* docs: fix custom models section reference in README
Model selector → Models settings page.
* fix(models): apply custom models toggle immediately without restart
[why]
Two bugs reported in PR review: (1) app breaks on restart when a
custom model was selected and the toggle is disabled — selected_model
still points to the missing model, causing "Model not found" error.
(2) Custom models remain visible in the UI after toggle-off because
the in-memory model list is never updated and no event is emitted.
[how]
- Add remove_custom_models() and add_custom_models() to ModelManager
for runtime mutation of the available_models mutex
- On disable: reset selected_model to empty if it's a custom model,
then remove custom models from the in-memory list
- On enable: run discover_custom_whisper_models against the mutex
- Emit model-state-changed event so the frontend refreshes immediately
* chore(i18n): remove "restart required" from custom model descriptions
Toggle now takes effect immediately, so the restart sentence
is inaccurate. Updated all 16 locales and README instructions.
* fix(models): clear stale model selection on startup
[why]
If a custom model file is deleted from disk while it's the selected
model, the app gets stuck on "Loading..." forever on next launch
because the model ID is not in available_models but
auto_select_model_if_needed only checked for empty string.
[how]
Validate that selected_model exists in available_models before
accepting it. If not found, clear the selection so auto-select
picks a valid downloaded model.
* remove toggle and clean up
* format
---------
Co-authored-by: CJ Pais <cj@cjpais.com>
* feat: add models settings page with filtering and management
- add dedicated models settings page in sidebar
- reuse ModelCard component from onboarding for consistent UI
- add filter buttons for all/multi-language/translation models
- add model deletion with native confirmation dialog (tauri-plugin-dialog)
- consolidate model event listeners into useModels hook (Zustand store)
- remove duplicate event listeners from ModelSelector, LanguageSelector, TranslateToEnglish
- gate AccessibilityPermissions to macOS only
- add is_recommended field to model registry for featured models
- remove unused get_recommended_first_model command
- add translations for all 9 supported languages
* feat: add language filter to models settings page
adds a searchable language dropdown filter to the models page that lets users filter models by language support. when a non-english language is selected, models that don't support multiple languages (like parakeet) are hidden.
- add searchable language dropdown (right-aligned in filter row)
- filter models using supports_language_selection capability
- add allLanguages translation key to all 10 locales
* feat: add translation consistency checker script
- add scripts/check-translations.cjs to validate all language files
- script dynamically discovers languages from directory structure
- checks that all languages have same keys as english reference
- detects missing and extra keys in each language
- add check:translations npm script
- integrate into github actions lint workflow
- validates translations on every pull request
* fix: update imports and add missing translations after rebase
- replace useModels hook with useModelStore in components
- add permission error keys to all languages
- add model settings keys to cs and tr (new languages from main)
* fix: translate english placeholders in cs and tr locale files
* refactor: simplify model dropdown and migrate store to immer
- model dropdown now only shows downloaded models (no download/delete)
- convert store to immer with record types for immutability
- remove unused translation keys (welcome, downloadPrompt, etc.)
- add missing moonshine-base model fields
- sync translations after rebase
* fix: add download cancellation support and ui improvements
- add full download cancellation with Arc<AtomicBool> flags in rust backend
- add progress event throttling (100ms) to prevent ui freeze
- add cancel button to model card in settings page
- add model-deleted event listener to refresh dropdown after deletion
- remove pink background from recommended models in settings (keep badge only)
- add cancel/cancelDownload translation keys to all 14 languages
* fix: remove duplicate language/translate settings from general and advanced
settings are now only in ModelSettingsCard, not duplicated in their old locations
* fix: prevent model dropdown from being clipped by window edge
* add languages explicitly, clean up some ui
* fix: clear bottom progress bar when download is cancelled
The ModelSelector component maintains its own local state for download
progress. When a download was cancelled, the Rust backend would update
its state but never emitted an event to notify the frontend. This caused
the bottom progress bar to remain stuck showing "Downloading X%".
Added model-download-cancelled event emission in Rust and corresponding
listener in ModelSelector to clear progress state on cancellation.
* fix: align model card content to top to prevent floating elements
Changed ModelCard flex alignment from items-center to items-start so
the accuracy/speed bars stay at the top when the card expands (e.g.,
during download with progress bar visible).
* fix: prevent model deletion from interrupting active extractions
Added extracting_models HashSet to track models currently being
extracted. The update_download_status() function now skips cleanup
of .extracting directories for models that are actively extracting,
preventing a race condition where deleting one model would interrupt
another model's extraction process.
* refactor: migrate ModelCard buttons to Button component
Added two new Button variants for common patterns:
- primary-soft: soft/tinted primary buttons (used for download)
- danger-ghost: subtle destructive actions (used for delete/cancel)
Migrated all hardcoded buttons in ModelCard to use the shared Button
component for consistency and maintainability.
* feat: separate downloaded and available models into sections
Split the models list into "Your Models" and "Available to Download"
sections for clearer visual distinction between downloaded and
downloadable models.
Also adds missing translation keys to all locales:
- modelSelector.capabilities.singleLanguage
- modelSelector.capabilities.languageOnly
- settings.models.yourModels
- settings.models.availableModels
* fix: add missing translations after rebase onto main
add post-processing hotkey translations to all 15 locales and
backfill 29 missing keys for korean locale added in main.
* fix: add label to model delete button for clearer destructive state
* check translations as typescript
* format
* better text for dropdown
* wip ui tweaks
* rounded + ui tweaks
* fix download not 0% immediately, ui tweaks
* tweak name
* block for model downloading in onboarding
* small fixes
---------
Co-authored-by: CJ Pais <cj@cjpais.com>
* feat: add post-processing hotkey
* rewrite
* bring back toggle
* Make sure the toggle on and off propagates for the key binding.
* rename for clarity
* cleanup
---------
Co-authored-by: CJ Pais <cj@cjpais.com>
* feat(linux): add KDE Plasma 6 Wayland support via kwtype
On KDE Plasma 6 Wayland, existing input tools have limitations:
- ydotool type: cannot handle German umlauts (ö, ä, ü, ß)
- wtype: doesn't work (KDE lacks zwp_virtual_keyboard_manager_v1)
- ydotool key: key combos don't reach native Wayland apps like Kate
This adds support for kwtype, which uses the KDE Fake Input protocol:
- Properly handles Unicode characters including German umlauts
- Works with native Wayland applications (Kate, Dolphin, etc.)
- Requires kwtype to be installed: https://github.com/Sporif/KWtype
Changes:
- Add is_kde_plasma() and is_kde_wayland() detection in utils.rs
- Add kwtype integration for direct text typing on KDE Wayland
- Add wl-copy integration for clipboard operations on Wayland
- Skip wtype on KDE (known not to work)
Recommended: Use PasteMethod::Direct on KDE Wayland for best results.
* add -- to make sure input is good
---------
Co-authored-by: CJ Pais <cj@cjpais.com>