-
-
Notifications
You must be signed in to change notification settings - Fork 802
feat: unload idle models and show load progress in the notch overlay #945
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -188,6 +188,8 @@ final class ASRService: ObservableObject { | |
| @Published private(set) var isCancellingModelPreparation: Bool = false | ||
| @Published var modelsExistOnDisk: Bool = false | ||
| @Published var downloadProgress: Double? = nil | ||
| /// Seconds the last cached-model load took. The notch loading bar paces itself on this. | ||
| static let lastModelLoadSecondsKey = "LastSpeechModelLoadSeconds" | ||
| @Published var modelPreparationPhase: ModelPreparationPhase? = nil | ||
| @Published var downloadingModelId: String? = nil // Tracks which model is currently being downloaded | ||
| @Published private(set) var isCancellingModelDownload: Bool = false | ||
|
|
@@ -603,6 +605,14 @@ final class ASRService: ObservableObject { | |
| } | ||
| } | ||
|
|
||
| /// Release the loaded speech model to reclaim memory while idle. The next | ||
| /// `ensureAsrReady` reloads it; recording itself never waits on the model. | ||
| func unloadForIdle() { | ||
| guard self.isAsrReady, !self.isRunningOrStarting else { return } | ||
| DebugLogger.shared.info("ASRService: unloading speech model after idle", source: "ASRService") | ||
| self.resetTranscriptionProvider() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For any Nemotron speech model, this does not release the loaded model: Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| /// Call this when the transcription provider setting changes to reset state | ||
| func resetTranscriptionProvider() { | ||
| let newModel = SettingsStore.shared.selectedSpeechModel | ||
|
|
@@ -1727,6 +1737,7 @@ final class ASRService: ObservableObject { | |
| DebugLogger.shared.warning("⚠️ START() blocked - already running (started: \(self.isRunning), starting: \(self.isStarting))", source: "ASRService") | ||
| return .alreadyActive | ||
| } | ||
| IdleModelUnloader.shared.cancel() | ||
| guard self.isTerminating == false else { | ||
| DebugLogger.shared.warning("START() blocked - app is terminating", source: "ASRService") | ||
| return .failed | ||
|
|
@@ -2019,6 +2030,20 @@ final class ASRService: ObservableObject { | |
| DebugLogger.shared.debug("ℹ️ No device to monitor", source: "ASRService") | ||
| } | ||
|
|
||
| // Load the model in the background while audio buffers. Without this the | ||
| // hotkey path only loads at stop(), so the user waits after releasing. | ||
| // ensureAsrReady() de-duplicates against any load already in flight. | ||
| if !self.isAsrReady, !forDictionaryTraining { | ||
| DebugLogger.shared.info("Model not ready at start; loading in background while recording", source: "ASRService") | ||
| Task { [weak self] in | ||
| do { | ||
| try await self?.ensureAsrReady() | ||
| } catch { | ||
| DebugLogger.shared.error("Background model load failed: \(error)", source: "ASRService") | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Only start streaming for models that support it (large Whisper models are too slow) | ||
| let model = SettingsStore.shared.selectedSpeechModel | ||
| if model.supportsStreaming, !forDictionaryTraining { | ||
|
|
@@ -2346,6 +2371,7 @@ final class ASRService: ObservableObject { | |
| defer { | ||
| self.applyPendingParakeetVocabularyReloadIfNeeded() | ||
| self.isDictionaryTrainingCaptureActive = false | ||
| IdleModelUnloader.shared.recordActivity() | ||
| } | ||
|
|
||
| await self.cancelAudioRouteRecoveryAndWait() | ||
|
|
@@ -2773,6 +2799,7 @@ final class ASRService: ObservableObject { | |
| defer { | ||
| self.applyPendingParakeetVocabularyReloadIfNeeded() | ||
| self.isDictionaryTrainingCaptureActive = false | ||
| IdleModelUnloader.shared.recordActivity() | ||
| } | ||
|
|
||
| await self.cancelAudioRouteRecoveryAndWait() | ||
|
|
@@ -4339,6 +4366,9 @@ final class ASRService: ObservableObject { | |
| guard self.ensureReadyOperationID == operationID else { throw CancellationError() } | ||
| let downloadDuration = Date().timeIntervalSince(downloadStartTime) | ||
| DebugLogger.shared.info("✓ Provider preparation completed in \(String(format: "%.1f", downloadDuration)) seconds", source: "ASRService") | ||
| if modelsAlreadyCached { | ||
| UserDefaults.standard.set(downloadDuration, forKey: Self.lastModelLoadSecondsKey) | ||
| } | ||
|
|
||
| self.isDownloadingModel = false | ||
| // Keep isLoadingModel true until first transcription completes (for large models that need warm-up) | ||
|
|
@@ -4360,6 +4390,16 @@ final class ASRService: ObservableObject { | |
| self.isAsrReady = true | ||
| self.isCancellingModelPreparation = false | ||
| self.refreshWordBoostStatus() | ||
| // start() skips the preview loop when the model isn't ready yet. If the | ||
| // load finished while audio was buffering, start it now; the first chunk | ||
| // transcribes the whole buffered prefix, then continues incrementally. | ||
| if self.isRunning, | ||
| !self.isDictionaryTrainingCaptureActive, | ||
| SettingsStore.shared.selectedSpeechModel.supportsStreaming | ||
| { | ||
| DebugLogger.shared.info("Model became ready mid-recording; starting streaming preview", source: "ASRService") | ||
| self.startStreamingTranscription() | ||
| } | ||
| self.finishModelDownloadAnalytics(operationID: operationID, outcome: .succeeded) | ||
| } catch is CancellationError { | ||
| self.finishModelDownloadAnalytics(operationID: operationID, outcome: .cancelled) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| import Foundation | ||
|
|
||
| /// Frees on-device models after a period without dictation so the app stops | ||
| /// pinning gigabytes while idle. Recording never waits on this: the models | ||
| /// reload in the background on the next hotkey press. | ||
| @MainActor | ||
| final class IdleModelUnloader { | ||
| static let shared = IdleModelUnloader() | ||
|
|
||
| private var countdown: Task<Void, Never>? | ||
|
|
||
| private init() {} | ||
|
|
||
| // MARK: - Public | ||
|
|
||
| /// Restart the idle countdown. Call whenever a model was just used. | ||
| func recordActivity() { | ||
| let minutes = SettingsStore.shared.modelIdleUnloadMinutes | ||
| guard minutes > 0 else { | ||
| self.cancel() | ||
| return | ||
| } | ||
| self.schedule(after: .seconds(minutes * 60)) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| /// Release both models immediately (settings button). Same busy guard as the timer. | ||
| func unloadNow() async { | ||
| await self.fire() | ||
| } | ||
|
|
||
| /// Stop the countdown while a session is active. | ||
| func cancel() { | ||
| self.countdown?.cancel() | ||
| self.countdown = nil | ||
| } | ||
|
|
||
| // MARK: - Private | ||
|
|
||
| private func schedule(after delay: Duration) { | ||
| self.countdown?.cancel() | ||
| self.countdown = Task { [weak self] in | ||
| guard (try? await Task.sleep(for: delay)) != nil else { return } | ||
| await self?.fire() | ||
| } | ||
| } | ||
|
|
||
| private func fire() async { | ||
| let asr = AppServices.shared.asr | ||
| let overlay = NotchContentState.shared | ||
| if asr.isRunningOrStarting || overlay.isProcessing || overlay.isCommandProcessing { | ||
| // Busy windows last seconds, not minutes, so a fixed short retry is enough. | ||
| DebugLogger.shared.debug("Idle unload deferred: session active", source: "IdleModelUnloader") | ||
| self.schedule(after: .seconds(60)) | ||
| return | ||
| } | ||
| DebugLogger.shared.info("Idle unload: releasing models", source: "IdleModelUnloader") | ||
| self.countdown = nil | ||
| await PrivateAIIntegrationService.shared.unloadCachedRuntime(reason: "idle") | ||
| asr.unloadForIdle() | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a user selects a non-default idle-unload interval and later restores a settings backup,
modelIdleUnloadMinutesis absent from both the backup payload and restore mapping, causing the selection to silently revert to the 10-minute default and potentially unload models despite the user's previous preference.Knowledge Base Used: Settings and onboarding
Prompt To Fix With AI