fix: prevent idle watcher from unloading model during recording (#1085)

* 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>
This commit is contained in:
Viren Mohindra 2026-03-19 01:45:09 -04:00 committed by GitHub
parent 0b3322faf3
commit 095f4ac455
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 60 additions and 50 deletions

View file

@ -1,4 +1,5 @@
use crate::audio_toolkit::{apply_custom_words, filter_transcription_output};
use crate::managers::audio::AudioRecordingManager;
use crate::managers::model::{EngineType, ModelManager};
use crate::settings::{
get_settings, ModelUnloadTimeout, OrtAcceleratorSetting, WhisperAcceleratorSetting,
@ -12,7 +13,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Condvar, Mutex, MutexGuard};
use std::thread;
use std::time::{Duration, SystemTime};
use tauri::{AppHandle, Emitter};
use tauri::{AppHandle, Emitter, Manager};
use transcribe_rs::{
onnx::{
canary::CanaryModel,
@ -79,12 +80,7 @@ impl TranscriptionManager {
model_manager,
app_handle: app_handle.clone(),
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,
)),
last_activity: Arc::new(AtomicU64::new(Self::now_ms())),
shutdown_signal: Arc::new(AtomicBool::new(false)),
watcher_handle: Arc::new(Mutex::new(None)),
is_loading: Arc::new(Mutex::new(false)),
@ -107,14 +103,28 @@ impl TranscriptionManager {
}
let settings = get_settings(&app_handle_cloned);
let timeout_seconds = settings.model_unload_timeout.to_seconds();
let timeout = settings.model_unload_timeout;
if let Some(limit_seconds) = timeout_seconds {
// Skip Immediately — that variant is handled by
// maybe_unload_immediately() after each transcription.
// Treating it as 0s here would unload the model mid-recording.
if timeout == ModelUnloadTimeout::Immediately {
continue;
}
// While recording, keep the idle timer fresh so the
// model is never unloaded mid-session.
let is_recording = app_handle_cloned
.try_state::<Arc<AudioRecordingManager>>()
.map_or(false, |a| a.is_recording());
if is_recording {
manager_cloned.touch_activity();
continue;
}
if let Some(limit_seconds) = timeout.to_seconds() {
let last = manager_cloned.last_activity.load(Ordering::Relaxed);
let now_ms = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
let now_ms = TranscriptionManager::now_ms();
let idle_ms = now_ms.saturating_sub(last);
let limit_ms = limit_seconds * 1000;
@ -213,6 +223,18 @@ impl TranscriptionManager {
Ok(())
}
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_millis() as u64
}
/// Reset the idle timer to now.
fn touch_activity(&self) {
self.last_activity.store(Self::now_ms(), Ordering::Relaxed);
}
/// Unloads the model immediately if the setting is enabled and the model is loaded
pub fn maybe_unload_immediately(&self, context: &str) {
let settings = get_settings(&self.app_handle);
@ -402,13 +424,7 @@ impl TranscriptionManager {
}
// Reset idle timer so the watcher doesn't immediately unload a just-loaded model
self.last_activity.store(
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_millis() as u64,
Ordering::Relaxed,
);
self.touch_activity();
// Emit loading completed event
let _ = self.app_handle.emit(
@ -457,13 +473,7 @@ 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,
);
self.touch_activity();
let st = std::time::Instant::now();

View file

@ -123,7 +123,7 @@ pub enum ModelUnloadTimeout {
Min10,
Min15,
Hour1,
Sec5, // Debug mode only
Sec15, // Debug mode only
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Type)]
@ -216,7 +216,7 @@ impl ModelUnloadTimeout {
ModelUnloadTimeout::Min10 => Some(10),
ModelUnloadTimeout::Min15 => Some(15),
ModelUnloadTimeout::Hour1 => Some(60),
ModelUnloadTimeout::Sec5 => Some(0), // Special case for debug - handled separately
ModelUnloadTimeout::Sec15 => Some(0), // Special case for debug - handled separately
}
}
@ -224,7 +224,7 @@ impl ModelUnloadTimeout {
match self {
ModelUnloadTimeout::Never => None,
ModelUnloadTimeout::Immediately => Some(0), // Special case for immediate unloading
ModelUnloadTimeout::Sec5 => Some(5),
ModelUnloadTimeout::Sec15 => Some(15),
_ => self.to_minutes().map(|m| m * 60),
}
}

View file

@ -809,7 +809,7 @@ export type LLMPrompt = { id: string; name: string; prompt: string }
export type LogLevel = "trace" | "debug" | "info" | "warn" | "error"
export type ModelInfo = { id: string; name: string; description: string; filename: string; url: string | null; size_mb: number; is_downloaded: boolean; is_downloading: boolean; partial_size: number; is_directory: boolean; engine_type: EngineType; accuracy_score: number; speed_score: number; supports_translation: boolean; is_recommended: boolean; supported_languages: string[]; supports_language_selection: boolean; is_custom: boolean }
export type ModelLoadStatus = { is_loaded: boolean; current_model: string | null }
export type ModelUnloadTimeout = "never" | "immediately" | "min_2" | "min_5" | "min_10" | "min_15" | "hour_1" | "sec_5"
export type ModelUnloadTimeout = "never" | "immediately" | "min_2" | "min_5" | "min_10" | "min_15" | "hour_1" | "sec_15"
export type OrtAcceleratorSetting = "auto" | "cpu" | "cuda" | "directml" | "rocm"
export type OverlayPosition = "none" | "top" | "bottom"
export type PasteMethod = "ctrl_v" | "direct" | "none" | "shift_insert" | "ctrl_shift_v" | "external_script"

View file

@ -51,8 +51,8 @@ export const ModelUnloadTimeoutSetting: React.FC<ModelUnloadTimeoutProps> = ({
const debugTimeoutOptions = [
...timeoutOptions,
{
value: "sec5" as ModelUnloadTimeout,
label: t("settings.advanced.modelUnload.options.sec5"),
value: "sec15" as ModelUnloadTimeout,
label: t("settings.advanced.modelUnload.options.sec15"),
},
];

View file

@ -309,7 +309,7 @@
"min10": "بعد 10 دقائق",
"min15": "بعد 15 دقيقة",
"hour1": "بعد ساعة واحدة",
"sec5": "بعد 5 ثوانٍ (تصحيح أخطاء)"
"sec15": "بعد 15 ثوانٍ (تصحيح أخطاء)"
}
},
"customWords": {

View file

@ -331,7 +331,7 @@
"min10": "Po 10 minutách",
"min15": "Po 15 minutách",
"hour1": "Po 1 hodině",
"sec5": "Po 5 sekundách (Debug)"
"sec15": "Po 15 sekundách (Debug)"
}
},
"customWords": {

View file

@ -331,7 +331,7 @@
"min10": "Nach 10 Minuten",
"min15": "Nach 15 Minuten",
"hour1": "Nach 1 Stunde",
"sec5": "Nach 5 Sekunden (Debug)"
"sec15": "Nach 15 Sekunden (Debug)"
}
},
"customWords": {

View file

@ -331,7 +331,7 @@
"min10": "After 10 minutes",
"min15": "After 15 minutes",
"hour1": "After 1 hour",
"sec5": "After 5 seconds (Debug)"
"sec15": "After 15 seconds (Debug)"
}
},
"customWords": {

View file

@ -331,7 +331,7 @@
"min10": "Después de 10 minutos",
"min15": "Después de 15 minutos",
"hour1": "Después de 1 hora",
"sec5": "Después de 5 segundos (Depuración)"
"sec15": "Después de 15 segundos (Depuración)"
}
},
"customWords": {

View file

@ -331,7 +331,7 @@
"min10": "Après 10 minutes",
"min15": "Après 15 minutes",
"hour1": "Après 1 heure",
"sec5": "Après 5 secondes (Débogage)"
"sec15": "Après 15 secondes (Débogage)"
}
},
"customWords": {

View file

@ -331,7 +331,7 @@
"min10": "Dopo 10 minuti",
"min15": "Dopo 15 minuti",
"hour1": "Dopo 1 ora",
"sec5": "Dopo 5 secondi (Debug)"
"sec15": "Dopo 15 secondi (Debug)"
}
},
"customWords": {

View file

@ -331,7 +331,7 @@
"min10": "10分後",
"min15": "15分後",
"hour1": "1時間後",
"sec5": "5秒後デバッグ"
"sec15": "15秒後デバッグ"
}
},
"customWords": {

View file

@ -331,7 +331,7 @@
"min10": "10분 후",
"min15": "15분 후",
"hour1": "1시간 후",
"sec5": "5초 후 (디버그)"
"sec15": "15초 후 (디버그)"
}
},
"customWords": {

View file

@ -331,7 +331,7 @@
"min10": "Po 10 minutach",
"min15": "Po 15 minutach",
"hour1": "Po 1 godzinie",
"sec5": "Po 5 sekundach (Debug)"
"sec15": "Po 15 sekundach (Debug)"
}
},
"customWords": {

View file

@ -331,7 +331,7 @@
"min10": "Após 10 minutos",
"min15": "Após 15 minutos",
"hour1": "Após 1 hora",
"sec5": "Após 5 segundos (Depuração)"
"sec15": "Após 15 segundos (Depuração)"
}
},
"customWords": {

View file

@ -331,7 +331,7 @@
"min10": "Через 10 минут",
"min15": "Через 15 минут",
"hour1": "Через 1 час",
"sec5": "Через 5 секунд (отладка)"
"sec15": "Через 15 секунд (отладка)"
}
},
"customWords": {

View file

@ -331,7 +331,7 @@
"min10": "10 dakika sonra",
"min15": "15 dakika sonra",
"hour1": "1 saat sonra",
"sec5": "5 saniye sonra (Debug)"
"sec15": "15 saniye sonra (Debug)"
}
},
"customWords": {

View file

@ -331,7 +331,7 @@
"min10": "Через 10 хвилин",
"min15": "Через 15 хвилин",
"hour1": "Через 1 годину",
"sec5": "Через 5 секунд (Дебаг)"
"sec15": "Через 15 секунд (Дебаг)"
}
},
"customWords": {

View file

@ -331,7 +331,7 @@
"min10": "Sau 10 phút",
"min15": "Sau 15 phút",
"hour1": "Sau 1 giờ",
"sec5": "Sau 5 giây (Gỡ lỗi)"
"sec15": "Sau 15 giây (Gỡ lỗi)"
}
},
"customWords": {

View file

@ -331,7 +331,7 @@
"min10": "10 分鐘後",
"min15": "15 分鐘後",
"hour1": "1 小時後",
"sec5": "5 秒後(偵錯)"
"sec15": "15 秒後(偵錯)"
}
},
"customWords": {

View file

@ -331,7 +331,7 @@
"min10": "10 分钟后",
"min15": "15 分钟后",
"hour1": "1 小时后",
"sec5": "5 秒后(调试)"
"sec15": "15 秒后(调试)"
}
},
"customWords": {