Skip to content

Commit cd6102c

Browse files
committed
Merge branch 'main' of github.com:cjpais/Handy
2 parents f52a43f + ae5f640 commit cd6102c

File tree

13 files changed

+456
-44
lines changed

13 files changed

+456
-44
lines changed

src-tauri/src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
pub mod audio;
22
pub mod models;
3+
pub mod transcription;
34

45
use crate::utils::cancel_current_operation;
56
use tauri::{AppHandle, Manager};
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
use crate::managers::transcription::TranscriptionManager;
2+
use crate::settings::{get_settings, write_settings, ModelUnloadTimeout};
3+
use tauri::{AppHandle, State};
4+
5+
#[tauri::command]
6+
pub fn set_model_unload_timeout(app: AppHandle, timeout: ModelUnloadTimeout) {
7+
let mut settings = get_settings(&app);
8+
settings.model_unload_timeout = timeout;
9+
write_settings(&app, settings);
10+
}
11+
12+
#[tauri::command]
13+
pub fn get_model_load_status(
14+
transcription_manager: State<TranscriptionManager>,
15+
) -> Result<serde_json::Value, String> {
16+
let is_loaded = transcription_manager.is_model_loaded();
17+
let current_model = transcription_manager.get_current_model();
18+
19+
Ok(serde_json::json!({
20+
"is_loaded": is_loaded,
21+
"current_model": current_model
22+
}))
23+
}
24+
25+
#[tauri::command]
26+
pub fn unload_model_manually(
27+
transcription_manager: State<TranscriptionManager>,
28+
) -> Result<(), String> {
29+
transcription_manager
30+
.unload_model()
31+
.map_err(|e| format!("Failed to unload model: {}", e))
32+
}

src-tauri/src/lib.rs

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -83,20 +83,10 @@ pub fn run() {
8383
.manage(Mutex::new(ShortcutToggleStates::default()))
8484
.setup(move |app| {
8585
// Get the current theme to set the appropriate initial icon
86-
let initial_theme = if let Some(main_window) = app.get_webview_window("main") {
87-
main_window.theme().unwrap_or(tauri::Theme::Dark)
88-
} else {
89-
tauri::Theme::Dark
90-
};
91-
92-
println!("Initial system theme: {:?}", initial_theme);
86+
let initial_theme = tray::get_current_theme(&app.handle());
9387

9488
// Choose the appropriate initial icon based on theme
95-
let initial_icon_path = match initial_theme {
96-
tauri::Theme::Dark => "resources/tray_idle.png",
97-
tauri::Theme::Light => "resources/tray_idle_dark.png",
98-
_ => "resources/tray_idle.png", // Default fallback
99-
};
89+
let initial_icon_path = tray::get_icon_path(initial_theme, tray::TrayIconState::Idle);
10090

10191
let tray = TrayIconBuilder::new()
10292
.icon(Image::from_path(app.path().resolve(
@@ -213,7 +203,10 @@ pub fn run() {
213203
commands::audio::get_selected_microphone,
214204
commands::audio::get_available_output_devices,
215205
commands::audio::set_selected_output_device,
216-
commands::audio::get_selected_output_device
206+
commands::audio::get_selected_output_device,
207+
commands::transcription::set_model_unload_timeout,
208+
commands::transcription::get_model_load_status,
209+
commands::transcription::unload_model_manually
217210
])
218211
.run(tauri::generate_context!())
219212
.expect("error while running tauri application");

src-tauri/src/managers/transcription.rs

Lines changed: 195 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
use crate::managers::model::ModelManager;
2-
use crate::settings::get_settings;
2+
use crate::settings::{get_settings, ModelUnloadTimeout};
33
use anyhow::Result;
4+
use log::debug;
45
use natural::phonetics::soundex;
56
use serde::Serialize;
7+
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
68
use std::sync::{Arc, Mutex};
9+
use std::thread;
10+
use std::time::{Duration, SystemTime};
711
use strsim::levenshtein;
812
use tauri::{App, AppHandle, Emitter, Manager};
913
use whisper_rs::{
@@ -18,12 +22,16 @@ pub struct ModelStateEvent {
1822
pub error: Option<String>,
1923
}
2024

25+
#[derive(Clone)]
2126
pub struct TranscriptionManager {
22-
state: Mutex<Option<WhisperState>>,
23-
context: Mutex<Option<WhisperContext>>,
27+
state: Arc<Mutex<Option<WhisperState>>>,
28+
context: Arc<Mutex<Option<WhisperContext>>>,
2429
model_manager: Arc<ModelManager>,
2530
app_handle: AppHandle,
26-
current_model_id: Mutex<Option<String>>,
31+
current_model_id: Arc<Mutex<Option<String>>>,
32+
last_activity: Arc<AtomicU64>,
33+
shutdown_signal: Arc<AtomicBool>,
34+
watcher_handle: Arc<Mutex<Option<thread::JoinHandle<()>>>>,
2735
}
2836

2937
fn apply_custom_words(text: &str, custom_words: &[String], threshold: f64) -> String {
@@ -139,21 +147,133 @@ impl TranscriptionManager {
139147
let app_handle = app.app_handle().clone();
140148

141149
let manager = Self {
142-
state: Mutex::new(None),
143-
context: Mutex::new(None),
150+
state: Arc::new(Mutex::new(None)),
151+
context: Arc::new(Mutex::new(None)),
144152
model_manager,
145153
app_handle: app_handle.clone(),
146-
current_model_id: Mutex::new(None),
154+
current_model_id: Arc::new(Mutex::new(None)),
155+
last_activity: Arc::new(AtomicU64::new(
156+
SystemTime::now()
157+
.duration_since(SystemTime::UNIX_EPOCH)
158+
.unwrap()
159+
.as_millis() as u64,
160+
)),
161+
shutdown_signal: Arc::new(AtomicBool::new(false)),
162+
watcher_handle: Arc::new(Mutex::new(None)),
147163
};
148164

165+
// Start the idle watcher
166+
{
167+
let app_handle_cloned = app_handle.clone();
168+
let manager_cloned = manager.clone();
169+
let shutdown_signal = manager.shutdown_signal.clone();
170+
let handle = thread::spawn(move || {
171+
while !shutdown_signal.load(Ordering::Relaxed) {
172+
thread::sleep(Duration::from_secs(10)); // Check every 10 seconds
173+
174+
// Check shutdown signal again after sleep
175+
if shutdown_signal.load(Ordering::Relaxed) {
176+
break;
177+
}
178+
179+
let settings = get_settings(&app_handle_cloned);
180+
let timeout_seconds = settings.model_unload_timeout.to_seconds();
181+
182+
if let Some(limit_seconds) = timeout_seconds {
183+
// Skip polling-based unloading for immediate timeout since it's handled directly in transcribe()
184+
if settings.model_unload_timeout == ModelUnloadTimeout::Immediately {
185+
continue;
186+
}
187+
188+
let last = manager_cloned.last_activity.load(Ordering::Relaxed);
189+
let now_ms = SystemTime::now()
190+
.duration_since(SystemTime::UNIX_EPOCH)
191+
.unwrap()
192+
.as_millis() as u64;
193+
194+
if now_ms.saturating_sub(last) > limit_seconds * 1000 {
195+
// idle -> unload
196+
if manager_cloned.is_model_loaded() {
197+
let unload_start = std::time::Instant::now();
198+
debug!("Starting to unload model due to inactivity");
199+
200+
if let Ok(()) = manager_cloned.unload_model() {
201+
let _ = app_handle_cloned.emit(
202+
"model-state-changed",
203+
ModelStateEvent {
204+
event_type: "unloaded_due_to_idle".to_string(),
205+
model_id: None,
206+
model_name: None,
207+
error: None,
208+
},
209+
);
210+
let unload_duration = unload_start.elapsed();
211+
debug!(
212+
"Model unloaded due to inactivity (took {}ms)",
213+
unload_duration.as_millis()
214+
);
215+
}
216+
}
217+
}
218+
}
219+
}
220+
debug!("Idle watcher thread shutting down gracefully");
221+
});
222+
*manager.watcher_handle.lock().unwrap() = Some(handle);
223+
}
224+
149225
// Try to load the default model from settings, but don't fail if no models are available
150226
let settings = get_settings(&app_handle);
151227
let _ = manager.load_model(&settings.selected_model);
152228

153229
Ok(manager)
154230
}
155231

232+
pub fn is_model_loaded(&self) -> bool {
233+
let state = self.state.lock().unwrap();
234+
state.is_some()
235+
}
236+
237+
pub fn unload_model(&self) -> Result<()> {
238+
let unload_start = std::time::Instant::now();
239+
debug!("Starting to unload model");
240+
241+
{
242+
let mut state = self.state.lock().unwrap();
243+
*state = None; // Dropping state frees GPU/CPU memory
244+
}
245+
{
246+
let mut context = self.context.lock().unwrap();
247+
*context = None; // Dropping context frees additional memory
248+
}
249+
{
250+
let mut current_model = self.current_model_id.lock().unwrap();
251+
*current_model = None;
252+
}
253+
254+
// Emit unloaded event
255+
let _ = self.app_handle.emit(
256+
"model-state-changed",
257+
ModelStateEvent {
258+
event_type: "unloaded_manually".to_string(),
259+
model_id: None,
260+
model_name: None,
261+
error: None,
262+
},
263+
);
264+
265+
let unload_duration = unload_start.elapsed();
266+
debug!(
267+
"Model unloaded manually (took {}ms)",
268+
unload_duration.as_millis()
269+
);
270+
Ok(())
271+
}
272+
156273
pub fn load_model(&self, model_id: &str) -> Result<()> {
274+
let load_start = std::time::Instant::now();
275+
debug!("Starting to load model: {}", model_id);
276+
157277
// Emit loading started event
158278
let _ = self.app_handle.emit(
159279
"model-state-changed",
@@ -252,7 +372,12 @@ impl TranscriptionManager {
252372
},
253373
);
254374

255-
println!("Successfully loaded transcription model: {}", model_id);
375+
let load_duration = load_start.elapsed();
376+
debug!(
377+
"Successfully loaded transcription model: {} (took {}ms)",
378+
model_id,
379+
load_duration.as_millis()
380+
);
256381
Ok(())
257382
}
258383

@@ -262,6 +387,15 @@ impl TranscriptionManager {
262387
}
263388

264389
pub fn transcribe(&self, audio: Vec<f32>) -> Result<String> {
390+
// Update last activity timestamp
391+
self.last_activity.store(
392+
SystemTime::now()
393+
.duration_since(SystemTime::UNIX_EPOCH)
394+
.unwrap()
395+
.as_millis() as u64,
396+
Ordering::Relaxed,
397+
);
398+
265399
let st = std::time::Instant::now();
266400

267401
let mut result = String::new();
@@ -272,10 +406,34 @@ impl TranscriptionManager {
272406
return Ok(result);
273407
}
274408

409+
// Check if model is loaded, if not try to load it
410+
{
411+
let state_guard = self.state.lock().unwrap();
412+
if state_guard.is_none() {
413+
// Model not loaded, try to load the selected model from settings
414+
let settings = get_settings(&self.app_handle);
415+
println!(
416+
"Model not loaded, attempting to load: {}",
417+
settings.selected_model
418+
);
419+
420+
// Drop the guard before calling load_model to avoid deadlock
421+
drop(state_guard);
422+
423+
// Try to load the model
424+
if let Err(e) = self.load_model(&settings.selected_model) {
425+
return Err(anyhow::anyhow!(
426+
"Failed to auto-load model '{}': {}. Please check that the model is downloaded and try again.",
427+
settings.selected_model, e
428+
));
429+
}
430+
}
431+
}
432+
275433
let mut state_guard = self.state.lock().unwrap();
276434
let state = state_guard.as_mut().ok_or_else(|| {
277435
anyhow::anyhow!(
278-
"No model loaded. Please download and select a model from settings first."
436+
"Model failed to load after auto-load attempt. Please check your model settings."
279437
)
280438
})?;
281439

@@ -333,6 +491,34 @@ impl TranscriptionManager {
333491
};
334492
println!("\ntook {}ms{}", (et - st).as_millis(), translation_note);
335493

494+
// Check if we should immediately unload the model after transcription
495+
if settings.model_unload_timeout == ModelUnloadTimeout::Immediately {
496+
println!("⚡ Immediately unloading model after transcription");
497+
// Drop the state guard first to avoid deadlock
498+
drop(state_guard);
499+
if let Err(e) = self.unload_model() {
500+
eprintln!("Failed to immediately unload model: {}", e);
501+
}
502+
}
503+
336504
Ok(corrected_result.trim().to_string())
337505
}
338506
}
507+
508+
impl Drop for TranscriptionManager {
509+
fn drop(&mut self) {
510+
debug!("Shutting down TranscriptionManager");
511+
512+
// Signal the watcher thread to shutdown
513+
self.shutdown_signal.store(true, Ordering::Relaxed);
514+
515+
// Wait for the thread to finish gracefully
516+
if let Some(handle) = self.watcher_handle.lock().unwrap().take() {
517+
if let Err(e) = handle.join() {
518+
eprintln!("Failed to join idle watcher thread: {:?}", e);
519+
} else {
520+
debug!("Idle watcher thread joined successfully");
521+
}
522+
}
523+
}
524+
}

0 commit comments

Comments
 (0)