-
-
Notifications
You must be signed in to change notification settings - Fork 502
fix(asr): unload idle speech model to release wired memory (#548) #619
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
edd0454
9063f86
fd0821f
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 |
|---|---|---|
|
|
@@ -85,6 +85,22 @@ final class ASRService: ObservableObject { | |
| failureCount >= 3 | ||
| } | ||
|
|
||
| nonisolated static func canUnloadIdleSpeechModel( | ||
| isAsrReady: Bool, | ||
| isRunning: Bool, | ||
| isStarting: Bool, | ||
| hasActiveModelPreparation: Bool, | ||
| hasActiveModelDownload: Bool, | ||
| activeSpeechModelUseCount: Int | ||
| ) -> Bool { | ||
| isAsrReady | ||
| && !isRunning | ||
| && !isStarting | ||
| && !hasActiveModelPreparation | ||
| && !hasActiveModelDownload | ||
| && activeSpeechModelUseCount == 0 | ||
| } | ||
|
|
||
| @Published var isRunning: Bool = false | ||
| @Published var finalText: String = "" | ||
| @Published var partialTranscription: String = "" | ||
|
|
@@ -205,6 +221,8 @@ final class ASRService: ObservableObject { | |
| await self.providerResetDrain?.task.value | ||
| await self.transcriptionExecutor.cancelAndAwaitPending() | ||
|
|
||
| self.modelIdleUnloadTask?.cancel() | ||
| self.modelIdleUnloadTask = nil | ||
| self.fluidAudioProvider = nil | ||
| self.parakeetRealtimeProvider = nil | ||
| self.externalCoreMLProvider = nil | ||
|
|
@@ -557,9 +575,12 @@ final class ASRService: ObservableObject { | |
| self.wordBoostStatusText = "Word boost: off" | ||
|
|
||
| // Reset cached providers to force re-initialization with new settings | ||
| self.modelIdleUnloadTask?.cancel() | ||
| self.modelIdleUnloadTask = nil | ||
| self.fluidAudioProvider = nil | ||
| self.parakeetRealtimeProvider = nil | ||
| self.externalCoreMLProvider = nil | ||
| self.nemotronProviders.removeAll() | ||
| self.whisperProvider = nil | ||
| self.appleSpeechProvider = nil | ||
| self._appleSpeechAnalyzerProvider = nil | ||
|
|
@@ -579,6 +600,85 @@ final class ASRService: ObservableObject { | |
| } | ||
| } | ||
|
|
||
| // MARK: - Idle Speech Model Unload | ||
|
|
||
| /// Marks the loaded speech model as in use so an idle unload cannot fire | ||
| /// mid-operation. Every call must be balanced with `endSpeechModelUse()`. | ||
| func beginSpeechModelUse() { | ||
| self.activeSpeechModelUseCount += 1 | ||
| self.modelIdleUnloadTask?.cancel() | ||
| self.modelIdleUnloadTask = nil | ||
| } | ||
|
|
||
| /// Balances `beginSpeechModelUse()` and re-arms the idle unload countdown | ||
| /// once the model is no longer in use. | ||
| func endSpeechModelUse() { | ||
| self.activeSpeechModelUseCount = max(0, self.activeSpeechModelUseCount - 1) | ||
| if self.activeSpeechModelUseCount == 0 { | ||
| self.scheduleModelIdleUnloadIfEnabled(reason: "model_use_ended") | ||
| } | ||
| } | ||
|
|
||
| private func scheduleModelIdleUnloadIfEnabled(reason: String) { | ||
| self.modelIdleUnloadTask?.cancel() | ||
| self.modelIdleUnloadTask = nil | ||
|
|
||
| let minutes = SettingsStore.shared.speechModelIdleUnloadMinutes | ||
| guard minutes > 0, | ||
| self.isAsrReady, | ||
| SettingsStore.shared.selectedSpeechModel.holdsModelInProcessMemory | ||
| else { return } | ||
|
|
||
| DebugLogger.shared.debug( | ||
| "Speech model idle unload armed for \(minutes)min (\(reason))", | ||
| source: "ASRService" | ||
| ) | ||
| let delay = UInt64(minutes) * 60 * 1_000_000_000 | ||
| self.modelIdleUnloadTask = Task { [weak self] in | ||
| do { | ||
| try await Task.sleep(nanoseconds: delay) | ||
| } catch { | ||
| return | ||
| } | ||
| self?.unloadIdleSpeechModel() | ||
| } | ||
| } | ||
|
|
||
| /// Releases every cached in-process speech model once the idle window has | ||
| /// elapsed with no dictation. Disk caches are untouched; the existing | ||
| /// `ensureAsrReady()` paths reload the model on next use, exactly like the | ||
| /// first dictation after app launch. | ||
| private func unloadIdleSpeechModel() { | ||
| self.modelIdleUnloadTask = nil | ||
| guard Self.canUnloadIdleSpeechModel( | ||
| isAsrReady: self.isAsrReady, | ||
| isRunning: self.isRunning, | ||
| isStarting: self.isStarting, | ||
| hasActiveModelPreparation: self.ensureReadyTask != nil, | ||
| hasActiveModelDownload: self.modelDownloadTask != nil, | ||
| activeSpeechModelUseCount: self.activeSpeechModelUseCount | ||
| ) else { | ||
| self.scheduleModelIdleUnloadIfEnabled(reason: "unload_deferred") | ||
| return | ||
| } | ||
|
|
||
| DebugLogger.shared.info( | ||
| "Unloading idle speech model to reclaim memory; it reloads on next dictation", | ||
| source: "ASRService" | ||
| ) | ||
| self.fluidAudioProvider = nil | ||
| self.parakeetRealtimeProvider = nil | ||
| self.externalCoreMLProvider = nil | ||
| self.nemotronProviders.removeAll() | ||
| self.whisperProvider = nil | ||
| self.isAsrReady = false | ||
|
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 the idle timer fires, this leaves Useful? React with 👍 / 👎.
Contributor
Author
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. Good catch — fixed in 9063f86. 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 the idle timer unloads a model, this flips Useful? React with 👍 / 👎. |
||
| self.hasCompletedFirstTranscription = false | ||
| self.isLoadingModel = false | ||
| self.modelPreparationPhase = nil | ||
| self.lastBoostHitTerm = nil | ||
| self.refreshWordBoostStatus() | ||
| } | ||
|
|
||
| // CRITICAL FIX (launch-time crash mitigation): | ||
| // Combine's default ObservableObject.objectWillChange implementation uses Swift reflection to walk *stored* | ||
| // properties. If we store an AVFoundation ObjC class type (like AVAudioEngine) directly, the reflection | ||
|
|
@@ -996,6 +1096,14 @@ final class ASRService: ObservableObject { | |
| private let audioRouteRecoveryDelayNanoseconds: UInt64 = 1_000_000_000 | ||
| private var audioEngineStandbyTask: Task<Void, Never>? | ||
| private let audioEngineStandbyNanoseconds: UInt64 = 8_000_000_000 | ||
|
|
||
| /// Countdown that releases the loaded speech model after the configured | ||
| /// idle window, so multi-GB model weights do not stay resident forever. | ||
| private var modelIdleUnloadTask: Task<Void, Never>? | ||
| /// Number of in-flight operations (final transcription, Local API calls, | ||
| /// file transcription) that must block an idle unload while they run. | ||
| private var activeSpeechModelUseCount = 0 | ||
| private var idleUnloadPolicyObserver: NSObjectProtocol? | ||
| private var isEngineTapInstalled = false | ||
| private var isRecoveringAudioRoute = false | ||
|
|
||
|
|
@@ -1079,6 +1187,15 @@ final class ASRService: ObservableObject { | |
| self?.handleParakeetVocabularyDidChange() | ||
| } | ||
| } | ||
| self.idleUnloadPolicyObserver = NotificationCenter.default.addObserver( | ||
| forName: .speechModelIdleUnloadPolicyDidChange, | ||
| object: nil, | ||
| queue: .main | ||
| ) { [weak self] _ in | ||
| Task { @MainActor [weak self] in | ||
| self?.scheduleModelIdleUnloadIfEnabled(reason: "policy_changed") | ||
| } | ||
| } | ||
| } | ||
|
|
||
| deinit { | ||
|
|
@@ -1089,6 +1206,9 @@ final class ASRService: ObservableObject { | |
| if let observer = self.engineConfigurationChangeObserver { | ||
| NotificationCenter.default.removeObserver(observer) | ||
| } | ||
| if let observer = self.idleUnloadPolicyObserver { | ||
| NotificationCenter.default.removeObserver(observer) | ||
| } | ||
| } | ||
|
|
||
| @MainActor | ||
|
|
@@ -1370,9 +1490,13 @@ final class ASRService: ObservableObject { | |
| // Only start streaming for models that support it (large Whisper models are too slow) | ||
| let model = SettingsStore.shared.selectedSpeechModel | ||
| if model.supportsStreaming, !forDictionaryTraining { | ||
| DebugLogger.shared.debug("📡 Starting streaming transcription...", source: "ASRService") | ||
| self.benchmarkLog("streaming_timer_start intervalMs=\(Int((self.streamingChunkDurationSeconds * 1000).rounded())) minSamples=\(self.minimumStreamingPreviewSamples)") | ||
| self.startStreamingTranscription() | ||
| if self.isAsrReady { | ||
| DebugLogger.shared.debug("📡 Starting streaming transcription...", source: "ASRService") | ||
| self.benchmarkLog("streaming_timer_start intervalMs=\(Int((self.streamingChunkDurationSeconds * 1000).rounded())) minSamples=\(self.minimumStreamingPreviewSamples)") | ||
| self.startStreamingTranscription() | ||
| } else { | ||
| self.attachStreamingTranscriptionWhenModelLoads() | ||
| } | ||
| } else if forDictionaryTraining { | ||
| DebugLogger.shared.debug("⏸️ Skipping streaming for dictionary training sample", source: "ASRService") | ||
| } else { | ||
|
|
@@ -1470,9 +1594,11 @@ final class ASRService: ObservableObject { | |
| return "" | ||
| } | ||
| let useDictionaryTrainingPath = forDictionaryTraining || self.isDictionaryTrainingCaptureActive | ||
| self.beginSpeechModelUse() | ||
| defer { | ||
| self.applyPendingParakeetVocabularyReloadIfNeeded() | ||
| self.isDictionaryTrainingCaptureActive = false | ||
| self.endSpeechModelUse() | ||
| } | ||
|
|
||
| self.audioRouteRecoveryTask?.cancel() | ||
|
|
@@ -1727,6 +1853,8 @@ final class ASRService: ObservableObject { | |
| } | ||
|
|
||
| func transcribeSamplesForAPI(_ inputSamples: [Float]) async throws -> ASRTranscriptionResult { | ||
| self.beginSpeechModelUse() | ||
| defer { self.endSpeechModelUse() } | ||
| var samples = inputSamples | ||
| guard !samples.isEmpty else { | ||
| return ASRTranscriptionResult(text: "", confidence: 0) | ||
|
|
@@ -1764,6 +1892,8 @@ final class ASRService: ObservableObject { | |
| } | ||
|
|
||
| func transcribeFileForAPI(_ fileURL: URL) async throws -> (result: ASRTranscriptionResult, sampleCount: Int) { | ||
| self.beginSpeechModelUse() | ||
| defer { self.endSpeechModelUse() } | ||
| guard FileManager.default.isReadableFile(atPath: fileURL.path) else { | ||
| throw NSError( | ||
| domain: "ASRService", | ||
|
|
@@ -1809,9 +1939,11 @@ final class ASRService: ObservableObject { | |
|
|
||
| func stopWithoutTranscription() async { | ||
| guard self.isRunning else { return } | ||
| self.beginSpeechModelUse() | ||
| defer { | ||
| self.applyPendingParakeetVocabularyReloadIfNeeded() | ||
| self.isDictionaryTrainingCaptureActive = false | ||
| self.endSpeechModelUse() | ||
| } | ||
|
|
||
| self.audioRouteRecoveryTask?.cancel() | ||
|
|
@@ -3003,6 +3135,7 @@ final class ASRService: ObservableObject { | |
| self.isAsrReady = true | ||
| self.isCancellingModelPreparation = false | ||
| self.refreshWordBoostStatus() | ||
| self.scheduleModelIdleUnloadIfEnabled(reason: "model_ready") | ||
| } catch is CancellationError { | ||
| DebugLogger.shared.info("ASR initialization cancelled", source: "ASRService") | ||
| if provider.shouldClearCacheAfterCancellation, | ||
|
|
@@ -3229,6 +3362,33 @@ final class ASRService: ObservableObject { | |
|
|
||
| // MARK: - Timer-based Streaming Transcription (No VAD) | ||
|
|
||
| /// The model can be absent when capture starts (idle unload, or app launch | ||
| /// before the startup auto-load finishes). Reload it during capture and | ||
| /// attach the live streaming preview to the same session once it is ready, | ||
| /// instead of losing streaming for the whole dictation. | ||
| private func attachStreamingTranscriptionWhenModelLoads() { | ||
| let sessionID = self.benchmarkSessionID | ||
| DebugLogger.shared.debug("📡 Streaming deferred - loading model during capture...", source: "ASRService") | ||
| Task { [weak self] in | ||
| guard let self else { return } | ||
| do { | ||
| try await self.ensureAsrReady() | ||
| } catch { | ||
| DebugLogger.shared.debug( | ||
| "Streaming attach abandoned - model load failed: \(error.localizedDescription)", | ||
| source: "ASRService" | ||
| ) | ||
| return | ||
| } | ||
| guard self.isRunning, | ||
| self.benchmarkSessionID == sessionID, | ||
| SettingsStore.shared.selectedSpeechModel.supportsStreaming | ||
| else { return } | ||
| self.benchmarkLog("streaming_timer_start intervalMs=\(Int((self.streamingChunkDurationSeconds * 1000).rounded())) minSamples=\(self.minimumStreamingPreviewSamples) deferred=true") | ||
| self.startStreamingTranscription() | ||
| } | ||
| } | ||
|
|
||
| private func startStreamingTranscription() { | ||
| self.streamingTask?.cancel() | ||
| guard self.isAsrReady else { return } | ||
|
|
@@ -4195,3 +4355,7 @@ private final nonisolated class AudioCapturePipeline: @unchecked Sendable { | |
| return mono | ||
| } | ||
| } | ||
|
|
||
| extension Notification.Name { | ||
| static let speechModelIdleUnloadPolicyDidChange = Notification.Name("SpeechModelIdleUnloadPolicyDidChange") | ||
| } | ||
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.
If a restored backup or
UserDefaultsentry contains a very large positivespeechModelIdleUnloadMinutesvalue, it reaches this calculation because the setting getter only clamps the lower bound, and the chainedUInt64multiplications can overflow and trap when the idle timer is scheduled after model load or a policy change. Clamp to a sane maximum or use overflow-safe duration construction before arming the task.Useful? React with 👍 / 👎.
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.
Fixed in fd0821f — reads and writes are now clamped to 0…1440 minutes (
clampedSpeechModelIdleUnloadMinutes), so the nanosecond multiplication is bounded well inside UInt64 range, and the unit test coversInt.maxboth via the setter and written directly to defaults.