Merge branch 'main' of github.com:cjpais/Handy

This commit is contained in:
CJ Pais 2025-09-03 19:25:46 -07:00
commit cd6102c69a
13 changed files with 456 additions and 44 deletions

View file

@ -1,5 +1,6 @@
pub mod audio;
pub mod models;
pub mod transcription;
use crate::utils::cancel_current_operation;
use tauri::{AppHandle, Manager};

View file

@ -0,0 +1,32 @@
use crate::managers::transcription::TranscriptionManager;
use crate::settings::{get_settings, write_settings, ModelUnloadTimeout};
use tauri::{AppHandle, State};
#[tauri::command]
pub fn set_model_unload_timeout(app: AppHandle, timeout: ModelUnloadTimeout) {
let mut settings = get_settings(&app);
settings.model_unload_timeout = timeout;
write_settings(&app, settings);
}
#[tauri::command]
pub fn get_model_load_status(
transcription_manager: State<TranscriptionManager>,
) -> Result<serde_json::Value, String> {
let is_loaded = transcription_manager.is_model_loaded();
let current_model = transcription_manager.get_current_model();
Ok(serde_json::json!({
"is_loaded": is_loaded,
"current_model": current_model
}))
}
#[tauri::command]
pub fn unload_model_manually(
transcription_manager: State<TranscriptionManager>,
) -> Result<(), String> {
transcription_manager
.unload_model()
.map_err(|e| format!("Failed to unload model: {}", e))
}

View file

@ -83,20 +83,10 @@ pub fn run() {
.manage(Mutex::new(ShortcutToggleStates::default()))
.setup(move |app| {
// Get the current theme to set the appropriate initial icon
let initial_theme = if let Some(main_window) = app.get_webview_window("main") {
main_window.theme().unwrap_or(tauri::Theme::Dark)
} else {
tauri::Theme::Dark
};
println!("Initial system theme: {:?}", initial_theme);
let initial_theme = tray::get_current_theme(&app.handle());
// Choose the appropriate initial icon based on theme
let initial_icon_path = match initial_theme {
tauri::Theme::Dark => "resources/tray_idle.png",
tauri::Theme::Light => "resources/tray_idle_dark.png",
_ => "resources/tray_idle.png", // Default fallback
};
let initial_icon_path = tray::get_icon_path(initial_theme, tray::TrayIconState::Idle);
let tray = TrayIconBuilder::new()
.icon(Image::from_path(app.path().resolve(
@ -213,7 +203,10 @@ pub fn run() {
commands::audio::get_selected_microphone,
commands::audio::get_available_output_devices,
commands::audio::set_selected_output_device,
commands::audio::get_selected_output_device
commands::audio::get_selected_output_device,
commands::transcription::set_model_unload_timeout,
commands::transcription::get_model_load_status,
commands::transcription::unload_model_manually
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View file

@ -1,9 +1,13 @@
use crate::managers::model::ModelManager;
use crate::settings::get_settings;
use crate::settings::{get_settings, ModelUnloadTimeout};
use anyhow::Result;
use log::debug;
use natural::phonetics::soundex;
use serde::Serialize;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, SystemTime};
use strsim::levenshtein;
use tauri::{App, AppHandle, Emitter, Manager};
use whisper_rs::{
@ -18,12 +22,16 @@ pub struct ModelStateEvent {
pub error: Option<String>,
}
#[derive(Clone)]
pub struct TranscriptionManager {
state: Mutex<Option<WhisperState>>,
context: Mutex<Option<WhisperContext>>,
state: Arc<Mutex<Option<WhisperState>>>,
context: Arc<Mutex<Option<WhisperContext>>>,
model_manager: Arc<ModelManager>,
app_handle: AppHandle,
current_model_id: Mutex<Option<String>>,
current_model_id: Arc<Mutex<Option<String>>>,
last_activity: Arc<AtomicU64>,
shutdown_signal: Arc<AtomicBool>,
watcher_handle: Arc<Mutex<Option<thread::JoinHandle<()>>>>,
}
fn apply_custom_words(text: &str, custom_words: &[String], threshold: f64) -> String {
@ -139,13 +147,81 @@ impl TranscriptionManager {
let app_handle = app.app_handle().clone();
let manager = Self {
state: Mutex::new(None),
context: Mutex::new(None),
state: Arc::new(Mutex::new(None)),
context: Arc::new(Mutex::new(None)),
model_manager,
app_handle: app_handle.clone(),
current_model_id: Mutex::new(None),
current_model_id: Arc::new(Mutex::new(None)),
last_activity: Arc::new(AtomicU64::new(
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_millis() as u64,
)),
shutdown_signal: Arc::new(AtomicBool::new(false)),
watcher_handle: Arc::new(Mutex::new(None)),
};
// Start the idle watcher
{
let app_handle_cloned = app_handle.clone();
let manager_cloned = manager.clone();
let shutdown_signal = manager.shutdown_signal.clone();
let handle = thread::spawn(move || {
while !shutdown_signal.load(Ordering::Relaxed) {
thread::sleep(Duration::from_secs(10)); // Check every 10 seconds
// Check shutdown signal again after sleep
if shutdown_signal.load(Ordering::Relaxed) {
break;
}
let settings = get_settings(&app_handle_cloned);
let timeout_seconds = settings.model_unload_timeout.to_seconds();
if let Some(limit_seconds) = timeout_seconds {
// Skip polling-based unloading for immediate timeout since it's handled directly in transcribe()
if settings.model_unload_timeout == ModelUnloadTimeout::Immediately {
continue;
}
let last = manager_cloned.last_activity.load(Ordering::Relaxed);
let now_ms = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
if now_ms.saturating_sub(last) > limit_seconds * 1000 {
// idle -> unload
if manager_cloned.is_model_loaded() {
let unload_start = std::time::Instant::now();
debug!("Starting to unload model due to inactivity");
if let Ok(()) = manager_cloned.unload_model() {
let _ = app_handle_cloned.emit(
"model-state-changed",
ModelStateEvent {
event_type: "unloaded_due_to_idle".to_string(),
model_id: None,
model_name: None,
error: None,
},
);
let unload_duration = unload_start.elapsed();
debug!(
"Model unloaded due to inactivity (took {}ms)",
unload_duration.as_millis()
);
}
}
}
}
}
debug!("Idle watcher thread shutting down gracefully");
});
*manager.watcher_handle.lock().unwrap() = Some(handle);
}
// Try to load the default model from settings, but don't fail if no models are available
let settings = get_settings(&app_handle);
let _ = manager.load_model(&settings.selected_model);
@ -153,7 +229,51 @@ impl TranscriptionManager {
Ok(manager)
}
pub fn is_model_loaded(&self) -> bool {
let state = self.state.lock().unwrap();
state.is_some()
}
pub fn unload_model(&self) -> Result<()> {
let unload_start = std::time::Instant::now();
debug!("Starting to unload model");
{
let mut state = self.state.lock().unwrap();
*state = None; // Dropping state frees GPU/CPU memory
}
{
let mut context = self.context.lock().unwrap();
*context = None; // Dropping context frees additional memory
}
{
let mut current_model = self.current_model_id.lock().unwrap();
*current_model = None;
}
// Emit unloaded event
let _ = self.app_handle.emit(
"model-state-changed",
ModelStateEvent {
event_type: "unloaded_manually".to_string(),
model_id: None,
model_name: None,
error: None,
},
);
let unload_duration = unload_start.elapsed();
debug!(
"Model unloaded manually (took {}ms)",
unload_duration.as_millis()
);
Ok(())
}
pub fn load_model(&self, model_id: &str) -> Result<()> {
let load_start = std::time::Instant::now();
debug!("Starting to load model: {}", model_id);
// Emit loading started event
let _ = self.app_handle.emit(
"model-state-changed",
@ -252,7 +372,12 @@ impl TranscriptionManager {
},
);
println!("Successfully loaded transcription model: {}", model_id);
let load_duration = load_start.elapsed();
debug!(
"Successfully loaded transcription model: {} (took {}ms)",
model_id,
load_duration.as_millis()
);
Ok(())
}
@ -262,6 +387,15 @@ impl TranscriptionManager {
}
pub fn transcribe(&self, audio: Vec<f32>) -> Result<String> {
// Update last activity timestamp
self.last_activity.store(
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_millis() as u64,
Ordering::Relaxed,
);
let st = std::time::Instant::now();
let mut result = String::new();
@ -272,10 +406,34 @@ impl TranscriptionManager {
return Ok(result);
}
// Check if model is loaded, if not try to load it
{
let state_guard = self.state.lock().unwrap();
if state_guard.is_none() {
// Model not loaded, try to load the selected model from settings
let settings = get_settings(&self.app_handle);
println!(
"Model not loaded, attempting to load: {}",
settings.selected_model
);
// Drop the guard before calling load_model to avoid deadlock
drop(state_guard);
// Try to load the model
if let Err(e) = self.load_model(&settings.selected_model) {
return Err(anyhow::anyhow!(
"Failed to auto-load model '{}': {}. Please check that the model is downloaded and try again.",
settings.selected_model, e
));
}
}
}
let mut state_guard = self.state.lock().unwrap();
let state = state_guard.as_mut().ok_or_else(|| {
anyhow::anyhow!(
"No model loaded. Please download and select a model from settings first."
"Model failed to load after auto-load attempt. Please check your model settings."
)
})?;
@ -333,6 +491,34 @@ impl TranscriptionManager {
};
println!("\ntook {}ms{}", (et - st).as_millis(), translation_note);
// Check if we should immediately unload the model after transcription
if settings.model_unload_timeout == ModelUnloadTimeout::Immediately {
println!("⚡ Immediately unloading model after transcription");
// Drop the state guard first to avoid deadlock
drop(state_guard);
if let Err(e) = self.unload_model() {
eprintln!("Failed to immediately unload model: {}", e);
}
}
Ok(corrected_result.trim().to_string())
}
}
impl Drop for TranscriptionManager {
fn drop(&mut self) {
debug!("Shutting down TranscriptionManager");
// Signal the watcher thread to shutdown
self.shutdown_signal.store(true, Ordering::Relaxed);
// Wait for the thread to finish gracefully
if let Some(handle) = self.watcher_handle.lock().unwrap().take() {
if let Err(e) = handle.join() {
eprintln!("Failed to join idle watcher thread: {:?}", e);
} else {
debug!("Idle watcher thread joined successfully");
}
}
}
}

View file

@ -20,6 +20,49 @@ pub enum OverlayPosition {
Bottom,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ModelUnloadTimeout {
Never,
Immediately,
Min2,
Min5,
Min10,
Min15,
Hour1,
Sec5, // Debug mode only
}
impl Default for ModelUnloadTimeout {
fn default() -> Self {
ModelUnloadTimeout::Never
}
}
impl ModelUnloadTimeout {
pub fn to_minutes(self) -> Option<u64> {
match self {
ModelUnloadTimeout::Never => None,
ModelUnloadTimeout::Immediately => Some(0), // Special case for immediate unloading
ModelUnloadTimeout::Min2 => Some(2),
ModelUnloadTimeout::Min5 => Some(5),
ModelUnloadTimeout::Min10 => Some(10),
ModelUnloadTimeout::Min15 => Some(15),
ModelUnloadTimeout::Hour1 => Some(60),
ModelUnloadTimeout::Sec5 => Some(0), // Special case for debug - handled separately
}
}
pub fn to_seconds(self) -> Option<u64> {
match self {
ModelUnloadTimeout::Never => None,
ModelUnloadTimeout::Immediately => Some(0), // Special case for immediate unloading
ModelUnloadTimeout::Sec5 => Some(5),
_ => self.to_minutes().map(|m| m * 60),
}
}
}
/* still handy for composing the initial JSON in the store ------------- */
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct AppSettings {
@ -44,6 +87,8 @@ pub struct AppSettings {
pub debug_mode: bool,
#[serde(default)]
pub custom_words: Vec<String>,
#[serde(default)]
pub model_unload_timeout: ModelUnloadTimeout,
#[serde(default = "default_word_correction_threshold")]
pub word_correction_threshold: f64,
}
@ -113,6 +158,7 @@ pub fn get_default_settings() -> AppSettings {
overlay_position: OverlayPosition::Bottom,
debug_mode: false,
custom_words: Vec::new(),
model_unload_timeout: ModelUnloadTimeout::Never,
word_correction_threshold: default_word_correction_threshold(),
}
}

View file

@ -1,5 +1,5 @@
use serde::Serialize;
use tauri::{App, AppHandle, Manager};
use tauri::{App, AppHandle, Emitter, Manager};
use tauri_plugin_global_shortcut::GlobalShortcutExt;
use tauri_plugin_global_shortcut::{Shortcut, ShortcutState};
@ -160,6 +160,16 @@ pub fn change_debug_mode_setting(app: AppHandle, enabled: bool) -> Result<(), St
let mut settings = settings::get_settings(&app);
settings.debug_mode = enabled;
settings::write_settings(&app, settings);
// Emit event to notify frontend of debug mode change
let _ = app.emit(
"settings-changed",
serde_json::json!({
"setting": "debug_mode",
"value": enabled
}),
);
Ok(())
}

View file

@ -10,12 +10,47 @@ pub enum TrayIconState {
Transcribing,
}
/// Gets the current system theme, defaulting to Dark if unavailable
fn get_current_theme(app: &AppHandle) -> Theme {
if let Some(main_window) = app.get_webview_window("main") {
main_window.theme().unwrap_or(Theme::Dark)
#[derive(Clone, Debug, PartialEq)]
pub enum AppTheme {
Dark,
Light,
Colored, // Pink/colored theme for Linux
}
/// Gets the current app theme, with Linux defaulting to Colored theme
pub fn get_current_theme(app: &AppHandle) -> AppTheme {
if cfg!(target_os = "linux") {
// On Linux, always use the colored theme
AppTheme::Colored
} else {
Theme::Dark
// On other platforms, map system theme to our app theme
if let Some(main_window) = app.get_webview_window("main") {
match main_window.theme().unwrap_or(Theme::Dark) {
Theme::Light => AppTheme::Light,
Theme::Dark => AppTheme::Dark,
_ => AppTheme::Dark, // Default fallback
}
} else {
AppTheme::Dark
}
}
}
/// Gets the appropriate icon path for the given theme and state
pub fn get_icon_path(theme: AppTheme, state: TrayIconState) -> &'static str {
match (theme, state) {
// Dark theme uses light icons
(AppTheme::Dark, TrayIconState::Idle) => "resources/tray_idle.png",
(AppTheme::Dark, TrayIconState::Recording) => "resources/tray_recording.png",
(AppTheme::Dark, TrayIconState::Transcribing) => "resources/tray_transcribing.png",
// Light theme uses dark icons
(AppTheme::Light, TrayIconState::Idle) => "resources/tray_idle_dark.png",
(AppTheme::Light, TrayIconState::Recording) => "resources/tray_recording_dark.png",
(AppTheme::Light, TrayIconState::Transcribing) => "resources/tray_transcribing_dark.png",
// Colored theme uses pink icons (for Linux)
(AppTheme::Colored, TrayIconState::Idle) => "resources/handy.png",
(AppTheme::Colored, TrayIconState::Recording) => "resources/recording.png",
(AppTheme::Colored, TrayIconState::Transcribing) => "resources/transcribing.png",
}
}
@ -23,20 +58,7 @@ pub fn change_tray_icon(app: &AppHandle, icon: TrayIconState) {
let tray = app.state::<TrayIcon>();
let theme = get_current_theme(app);
let icon_path = match (theme, &icon) {
// Dark theme uses regular icons (lighter colored for visibility)
(Theme::Dark, TrayIconState::Idle) => "resources/tray_idle.png",
(Theme::Dark, TrayIconState::Recording) => "resources/tray_recording.png",
(Theme::Dark, TrayIconState::Transcribing) => "resources/tray_transcribing.png",
// Light theme uses dark icons (darker colored for visibility)
(Theme::Light, TrayIconState::Idle) => "resources/tray_idle_dark.png",
(Theme::Light, TrayIconState::Recording) => "resources/tray_recording_dark.png",
(Theme::Light, TrayIconState::Transcribing) => "resources/tray_transcribing_dark.png",
// Fallback for any other theme variants
(_, TrayIconState::Idle) => "resources/tray_idle.png",
(_, TrayIconState::Recording) => "resources/tray_recording.png",
(_, TrayIconState::Transcribing) => "resources/tray_transcribing.png",
};
let icon_path = get_icon_path(theme, icon.clone());
let _ = tray.set_icon(Some(
Image::from_path(

View file

@ -15,9 +15,9 @@
{
"title": "Handy",
"width": 540,
"height": 730,
"height": 740,
"minWidth": 540,
"minHeight": 730,
"minHeight": 740,
"resizable": true,
"maximizable": false
}

View file

@ -0,0 +1,70 @@
import React, { useMemo } from "react";
import { invoke } from "@tauri-apps/api/core";
import { useSettings } from "../../hooks/useSettings";
import { ModelUnloadTimeout } from "../../lib/types";
import { Dropdown } from "../ui/Dropdown";
import { SettingContainer } from "../ui/SettingContainer";
interface ModelUnloadTimeoutProps {
descriptionMode?: "tooltip" | "inline";
grouped?: boolean;
}
const timeoutOptions = [
{ value: "never" as ModelUnloadTimeout, label: "Never" },
{ value: "immediately" as ModelUnloadTimeout, label: "Immediately" },
{ value: "min2" as ModelUnloadTimeout, label: "After 2 minutes" },
{ value: "min5" as ModelUnloadTimeout, label: "After 5 minutes" },
{ value: "min10" as ModelUnloadTimeout, label: "After 10 minutes" },
{ value: "min15" as ModelUnloadTimeout, label: "After 15 minutes" },
{ value: "hour1" as ModelUnloadTimeout, label: "After 1 hour" },
];
const debugTimeoutOptions = [
...timeoutOptions,
{ value: "sec5" as ModelUnloadTimeout, label: "After 5 seconds (Debug)" },
];
export const ModelUnloadTimeoutSetting: React.FC<ModelUnloadTimeoutProps> = ({
descriptionMode = "inline",
grouped = false,
}) => {
const { settings, getSetting, updateSetting } = useSettings();
const handleChange = async (event: React.ChangeEvent<HTMLSelectElement>) => {
const newTimeout = event.target.value as ModelUnloadTimeout;
try {
await invoke("set_model_unload_timeout", { timeout: newTimeout });
updateSetting("model_unload_timeout", newTimeout);
} catch (error) {
console.error("Failed to update model unload timeout:", error);
}
};
const currentValue = getSetting("model_unload_timeout") ?? "never";
const options = useMemo(() => {
return settings?.debug_mode === true ? debugTimeoutOptions : timeoutOptions;
}, [settings]);
return (
<SettingContainer
title="Unload Model"
description="Automatically free GPU/CPU memory when the model hasn't been used for the specified time"
descriptionMode={descriptionMode}
grouped={grouped}
>
<Dropdown
options={options}
selectedValue={currentValue}
onSelect={(value) =>
handleChange({
target: { value },
} as React.ChangeEvent<HTMLSelectElement>)
}
disabled={false}
/>
</SettingContainer>
);
};

View file

@ -9,6 +9,7 @@ import { HandyShortcut } from "./HandyShortcut";
import { TranslateToEnglish } from "./TranslateToEnglish";
import { LanguageSelector } from "./LanguageSelector";
import { CustomWords } from "./CustomWords";
import { ModelUnloadTimeoutSetting } from "./ModelUnloadTimeout";
import { SettingsGroup } from "../ui/SettingsGroup";
import { WordCorrectionThreshold } from "./debug/WordCorrectionThreshold";
import { AppDataDirectory } from "./AppDataDirectory";
@ -56,6 +57,7 @@ export const Settings: React.FC = () => {
<OutputDeviceSelector descriptionMode="tooltip" grouped={true} />
<ShowOverlay descriptionMode="tooltip" grouped={true} />
<TranslateToEnglish descriptionMode="tooltip" grouped={true} />
<ModelUnloadTimeoutSetting descriptionMode="tooltip" grouped={true} />
<CustomWords descriptionMode="tooltip" grouped />
<AlwaysOnMicrophone descriptionMode="tooltip" grouped={true} />
</SettingsGroup>

View file

@ -0,0 +1,36 @@
import React from "react";
import { SettingContainer } from "../../ui/SettingContainer";
interface DebugPathsProps {
descriptionMode?: "tooltip" | "inline";
grouped?: boolean;
}
export const DebugPaths: React.FC<DebugPathsProps> = ({
descriptionMode = "inline",
grouped = false,
}) => {
return (
<SettingContainer
title="Debug Paths"
description="Display internal file paths and directories for debugging purposes"
descriptionMode={descriptionMode}
grouped={grouped}
>
<div className="text-sm text-gray-600 space-y-2">
<div>
<span className="font-medium">App Data:</span>{" "}
<span className="font-mono text-xs">%APPDATA%/handy</span>
</div>
<div>
<span className="font-medium">Models:</span>{" "}
<span className="font-mono text-xs">%APPDATA%/handy/models</span>
</div>
<div>
<span className="font-medium">Settings:</span>{" "}
<span className="font-mono text-xs">%APPDATA%/handy/settings_store.json</span>
</div>
</div>
</SettingContainer>
);
};

View file

@ -9,3 +9,4 @@ export { HandyShortcut } from "./HandyShortcut";
export { TranslateToEnglish } from "./TranslateToEnglish";
export { CustomWords } from "./CustomWords";
export { AppDataDirectory } from "./AppDataDirectory";
export { ModelUnloadTimeoutSetting } from "./ModelUnloadTimeout";

View file

@ -22,6 +22,18 @@ export const AudioDeviceSchema = z.object({
export const OverlayPositionSchema = z.enum(["none", "top", "bottom"]);
export type OverlayPosition = z.infer<typeof OverlayPositionSchema>;
export const ModelUnloadTimeoutSchema = z.enum([
"never",
"immediately",
"min2",
"min5",
"min10",
"min15",
"hour1",
"sec5",
]);
export type ModelUnloadTimeout = z.infer<typeof ModelUnloadTimeoutSchema>;
export const SettingsSchema = z.object({
bindings: ShortcutBindingsMapSchema,
push_to_talk: z.boolean(),
@ -35,6 +47,7 @@ export const SettingsSchema = z.object({
overlay_position: OverlayPositionSchema,
debug_mode: z.boolean(),
custom_words: z.array(z.string()).optional().default([]),
model_unload_timeout: ModelUnloadTimeoutSchema.optional().default("never"),
word_correction_threshold: z.number().optional().default(0.18),
});