diff --git a/Fluid.xcodeproj/project.pbxproj b/Fluid.xcodeproj/project.pbxproj index a2cb0a9f..d75665fd 100644 --- a/Fluid.xcodeproj/project.pbxproj +++ b/Fluid.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ 7CDB0A2E2F3C4D5600FB7CAD /* AudioFixtureLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDB0A2A2F3C4D5600FB7CAD /* AudioFixtureLoader.swift */; }; 86CAA2D4EF18433096185602 /* LLMClientRequestBodyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */; }; 272BFB5CB271489892CAE50C /* TemperatureSupportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980330F3CE464336ADCE3E23 /* TemperatureSupportTests.swift */; }; + 80465DE1A77810B54487CD0E /* SpeechModelIdleUnloadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CE1E8B230CB0C06897E51E9 /* SpeechModelIdleUnloadTests.swift */; }; 7CDB0A2F2F3C4D5600FB7CAD /* dictation_fixture.wav in Resources */ = {isa = PBXBuildFile; fileRef = 7CDB0A2B2F3C4D5600FB7CAD /* dictation_fixture.wav */; }; 7CDB0A302F3C4D5600FB7CAD /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7CDB0A2C2F3C4D5600FB7CAD /* XCTest.framework */; }; 7CE006BD2E80EBE600DDCCD6 /* AppUpdater in Frameworks */ = {isa = PBXBuildFile; productRef = 7CE006BC2E80EBE600DDCCD6 /* AppUpdater */; }; @@ -38,6 +39,7 @@ 7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictationE2ETests.swift; sourceTree = ""; }; 343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LLMClientRequestBodyTests.swift; sourceTree = ""; }; 980330F3CE464336ADCE3E23 /* TemperatureSupportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureSupportTests.swift; sourceTree = ""; }; + 4CE1E8B230CB0C06897E51E9 /* SpeechModelIdleUnloadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeechModelIdleUnloadTests.swift; sourceTree = ""; }; 7CDB0A2A2F3C4D5600FB7CAD /* AudioFixtureLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioFixtureLoader.swift; sourceTree = ""; }; 7CDB0A2B2F3C4D5600FB7CAD /* dictation_fixture.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = dictation_fixture.wav; sourceTree = ""; }; 7CDB0A2C2F3C4D5600FB7CAD /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/MacOSX.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; }; @@ -109,6 +111,7 @@ 7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */, 7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */, 343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */, + 4CE1E8B230CB0C06897E51E9 /* SpeechModelIdleUnloadTests.swift */, 980330F3CE464336ADCE3E23 /* TemperatureSupportTests.swift */, ); path = FluidDictationIntegrationTests; @@ -265,6 +268,7 @@ 7CDB0A2D2F3C4D5600FB7CAD /* DictationE2ETests.swift in Sources */, 7C91B0012F42AA0100C0DEF0 /* HotkeyShortcutTests.swift in Sources */, 86CAA2D4EF18433096185602 /* LLMClientRequestBodyTests.swift in Sources */, + 80465DE1A77810B54487CD0E /* SpeechModelIdleUnloadTests.swift in Sources */, 272BFB5CB271489892CAE50C /* TemperatureSupportTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Sources/Fluid/Persistence/BackupService.swift b/Sources/Fluid/Persistence/BackupService.swift index 10823fd4..1e5a293e 100644 --- a/Sources/Fluid/Persistence/BackupService.swift +++ b/Sources/Fluid/Persistence/BackupService.swift @@ -75,6 +75,7 @@ struct SettingsBackupPayload: Codable, Equatable { let weekendsDontBreakStreak: Bool let fillerWords: [String] let removeFillerWordsEnabled: Bool + let speechModelIdleUnloadMinutes: Int? let autoConvertPunctuationEnabled: Bool? let literalDictationFormattingEnabled: Bool? let punctuationDictionaryPrefix: String? diff --git a/Sources/Fluid/Persistence/SettingsStore.swift b/Sources/Fluid/Persistence/SettingsStore.swift index 4cb7c599..ec3f1251 100644 --- a/Sources/Fluid/Persistence/SettingsStore.swift +++ b/Sources/Fluid/Persistence/SettingsStore.swift @@ -2995,6 +2995,7 @@ final class SettingsStore: ObservableObject { weekendsDontBreakStreak: self.weekendsDontBreakStreak, fillerWords: self.fillerWords, removeFillerWordsEnabled: self.removeFillerWordsEnabled, + speechModelIdleUnloadMinutes: self.speechModelIdleUnloadMinutes, autoConvertPunctuationEnabled: self.autoConvertPunctuationEnabled, literalDictationFormattingEnabled: self.literalDictationFormattingEnabled, punctuationDictionaryPrefix: self.punctuationDictionaryPrefix, @@ -3114,6 +3115,9 @@ final class SettingsStore: ObservableObject { self.weekendsDontBreakStreak = payload.weekendsDontBreakStreak self.fillerWords = payload.fillerWords self.removeFillerWordsEnabled = payload.removeFillerWordsEnabled + if let speechModelIdleUnloadMinutes = payload.speechModelIdleUnloadMinutes { + self.speechModelIdleUnloadMinutes = speechModelIdleUnloadMinutes + } if let autoConvertPunctuationEnabled = payload.autoConvertPunctuationEnabled { self.autoConvertPunctuationEnabled = autoConvertPunctuationEnabled } @@ -4377,6 +4381,20 @@ final class SettingsStore: ObservableObject { } } + /// Whether transcription runs on weights loaded into FluidVoice's own + /// process memory. Apple engines run in system services, so there is + /// no in-process model to unload when idle. + var holdsModelInProcessMemory: Bool { + switch self { + case .appleSpeech, .appleSpeechAnalyzer: + return false + case .parakeetTDT, .parakeetTDTv2, .parakeetRealtime, .qwen3Asr, .cohereTranscribeSixBit, + .nemotronOffline, .nemotronStreaming, .nemotronStreaming320, + .whisperTiny, .whisperBase, .whisperSmall, .whisperMedium, .whisperLargeTurbo, .whisperLarge: + return true + } + } + /// Warning text for models with high memory requirements, nil if no warning needed var memoryWarning: String? { switch self { @@ -4927,6 +4945,7 @@ private extension SettingsStore { /// Unified Speech Model (replaces above two) static let selectedSpeechModel = "SelectedSpeechModel" + static let speechModelIdleUnloadMinutes = "SpeechModelIdleUnloadMinutes" static let selectedCohereLanguage = "SelectedCohereLanguage" static let selectedNemotronLanguage = "SelectedNemotronLanguage" static let selectedAppleSpeechLocaleIdentifier = "SelectedAppleSpeechLocaleIdentifier" @@ -5185,6 +5204,31 @@ extension SettingsStore { } } + /// Minutes of dictation inactivity after which the loaded speech model is + /// unloaded to reclaim memory. 0 keeps the model loaded until quit. + /// Clamped to at most 24 hours so stored or restored values can never + /// overflow the nanosecond arithmetic that arms the unload timer. + var speechModelIdleUnloadMinutes: Int { + get { + guard let value = self.defaults.object(forKey: Keys.speechModelIdleUnloadMinutes) as? Int else { + return Self.defaultSpeechModelIdleUnloadMinutes + } + return Self.clampedSpeechModelIdleUnloadMinutes(value) + } + set { + objectWillChange.send() + self.defaults.set(Self.clampedSpeechModelIdleUnloadMinutes(newValue), forKey: Keys.speechModelIdleUnloadMinutes) + NotificationCenter.default.post(name: .speechModelIdleUnloadPolicyDidChange, object: nil) + } + } + + static let defaultSpeechModelIdleUnloadMinutes = 30 + static let maxSpeechModelIdleUnloadMinutes = 24 * 60 + + static func clampedSpeechModelIdleUnloadMinutes(_ minutes: Int) -> Int { + min(max(0, minutes), Self.maxSpeechModelIdleUnloadMinutes) + } + var selectedCohereLanguage: CohereLanguage { get { if let rawValue = self.defaults.string(forKey: Keys.selectedCohereLanguage), diff --git a/Sources/Fluid/Services/ASRService.swift b/Sources/Fluid/Services/ASRService.swift index 5366c12b..eb032484 100644 --- a/Sources/Fluid/Services/ASRService.swift +++ b/Sources/Fluid/Services/ASRService.swift @@ -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 + 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? 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? + /// 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") +} diff --git a/Sources/Fluid/Services/MeetingTranscriptionService.swift b/Sources/Fluid/Services/MeetingTranscriptionService.swift index be7391e4..b15e50ce 100644 --- a/Sources/Fluid/Services/MeetingTranscriptionService.swift +++ b/Sources/Fluid/Services/MeetingTranscriptionService.swift @@ -163,10 +163,14 @@ final class MeetingTranscriptionService: ObservableObject { error = nil self.progress = 0.0 let startTime = Date() + // Long file jobs reuse the ASR provider outside ASRService's own + // flows, so hold the model in memory until this run finishes. + self.asrService.beginSpeechModelUse() defer { isTranscribing = false progress = 0.0 + self.asrService.endSpeechModelUse() } do { diff --git a/Sources/Fluid/UI/AISettings/VoiceEngineSettingsViewModel.swift b/Sources/Fluid/UI/AISettings/VoiceEngineSettingsViewModel.swift index bd561f02..1248223b 100644 --- a/Sources/Fluid/UI/AISettings/VoiceEngineSettingsViewModel.swift +++ b/Sources/Fluid/UI/AISettings/VoiceEngineSettingsViewModel.swift @@ -45,6 +45,7 @@ final class VoiceEngineSettingsViewModel: ObservableObject { } @Published var removeFillerWordsEnabled: Bool + @Published var speechModelIdleUnloadMinutes: Int init(settings: SettingsStore, appServices: AppServices) { self.settings = settings @@ -52,6 +53,7 @@ final class VoiceEngineSettingsViewModel: ObservableObject { self.previewSpeechModel = settings.selectedSpeechModel self.selectedSpeechProvider = settings.selectedSpeechModel.provider self.removeFillerWordsEnabled = settings.removeFillerWordsEnabled + self.speechModelIdleUnloadMinutes = settings.speechModelIdleUnloadMinutes appServices.objectWillChange .sink { [weak self] _ in Task { @MainActor in @@ -65,6 +67,7 @@ final class VoiceEngineSettingsViewModel: ObservableObject { self.previewSpeechModel = self.settings.selectedSpeechModel self.selectedSpeechProvider = self.settings.selectedSpeechModel.provider self.removeFillerWordsEnabled = self.settings.removeFillerWordsEnabled + self.speechModelIdleUnloadMinutes = self.settings.speechModelIdleUnloadMinutes Task { await self.asr.checkIfModelsExistAsync() diff --git a/Sources/Fluid/UI/AISettingsView+SpeechRecognition.swift b/Sources/Fluid/UI/AISettingsView+SpeechRecognition.swift index 131ffc5d..68a61e7f 100644 --- a/Sources/Fluid/UI/AISettingsView+SpeechRecognition.swift +++ b/Sources/Fluid/UI/AISettingsView+SpeechRecognition.swift @@ -153,6 +153,15 @@ extension VoiceEngineSettingsView { // Filler Words Section self.fillerWordsSection + + // Apple engines run in system services and hold no + // in-process model, so there is nothing to unload. + if self.settings.selectedSpeechModel.holdsModelInProcessMemory { + Divider().padding(.vertical, 4) + + // Model Memory Section + self.modelMemorySection + } } } } @@ -748,6 +757,61 @@ extension VoiceEngineSettingsView { } } + // MARK: - Model Memory Section + + private static let idleUnloadMinuteOptions = [0, 5, 15, 30, 60] + + var modelMemorySection: some View { + HStack(alignment: .center) { + VStack(alignment: .leading, spacing: 2) { + Text("Unload Model When Inactive") + .font(self.theme.typography.bodyStrong) + .foregroundStyle(self.voiceEngineTitleText) + Text("Free memory by unloading the voice model after a period without dictation. It reloads automatically on the next dictation.") + .font(self.theme.typography.bodySmall) + .foregroundStyle(self.voiceEngineSecondaryText) + } + Spacer() + Menu { + ForEach(Self.idleUnloadMinuteOptions, id: \.self) { minutes in + Button(Self.idleUnloadOptionLabel(minutes)) { + self.viewModel.speechModelIdleUnloadMinutes = minutes + } + } + } label: { + Text(Self.idleUnloadOptionLabel(self.viewModel.speechModelIdleUnloadMinutes)) + .font(self.theme.typography.bodySmallStrong) + .foregroundStyle(self.voiceEngineTitleText) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 9) + .fill(self.theme.palette.cardBackground.opacity(0.8)) + .overlay( + RoundedRectangle(cornerRadius: 9) + .stroke(self.theme.palette.cardBorder.opacity(0.5), lineWidth: 1) + ) + ) + } + .onChange(of: self.viewModel.speechModelIdleUnloadMinutes) { _, newValue in + self.settings.speechModelIdleUnloadMinutes = newValue + } + } + } + + private static func idleUnloadOptionLabel(_ minutes: Int) -> String { + switch minutes { + case 0: + return "Never" + case 1: + return "After 1 minute" + case 60: + return "After 1 hour" + default: + return "After \(minutes) minutes" + } + } + // MARK: - Speech Model Logo View private func speechModelLogoView(for model: SettingsStore.SpeechModel) -> some View { diff --git a/Tests/FluidDictationIntegrationTests/SpeechModelIdleUnloadTests.swift b/Tests/FluidDictationIntegrationTests/SpeechModelIdleUnloadTests.swift new file mode 100644 index 00000000..6ab590c4 --- /dev/null +++ b/Tests/FluidDictationIntegrationTests/SpeechModelIdleUnloadTests.swift @@ -0,0 +1,132 @@ +@testable import FluidVoice_Debug +import Foundation +import XCTest + +final class SpeechModelIdleUnloadTests: XCTestCase { + private let idleUnloadMinutesKey = "SpeechModelIdleUnloadMinutes" + + func testCanUnloadOnlyWhenServiceIsFullyIdle() { + XCTAssertTrue(ASRService.canUnloadIdleSpeechModel( + isAsrReady: true, + isRunning: false, + isStarting: false, + hasActiveModelPreparation: false, + hasActiveModelDownload: false, + activeSpeechModelUseCount: 0 + )) + } + + func testCannotUnloadWithoutLoadedModel() { + XCTAssertFalse(ASRService.canUnloadIdleSpeechModel( + isAsrReady: false, + isRunning: false, + isStarting: false, + hasActiveModelPreparation: false, + hasActiveModelDownload: false, + activeSpeechModelUseCount: 0 + )) + } + + func testCannotUnloadWhileRecordingOrStarting() { + XCTAssertFalse(ASRService.canUnloadIdleSpeechModel( + isAsrReady: true, + isRunning: true, + isStarting: false, + hasActiveModelPreparation: false, + hasActiveModelDownload: false, + activeSpeechModelUseCount: 0 + )) + XCTAssertFalse(ASRService.canUnloadIdleSpeechModel( + isAsrReady: true, + isRunning: false, + isStarting: true, + hasActiveModelPreparation: false, + hasActiveModelDownload: false, + activeSpeechModelUseCount: 0 + )) + } + + func testCannotUnloadDuringModelPreparationOrDownload() { + XCTAssertFalse(ASRService.canUnloadIdleSpeechModel( + isAsrReady: true, + isRunning: false, + isStarting: false, + hasActiveModelPreparation: true, + hasActiveModelDownload: false, + activeSpeechModelUseCount: 0 + )) + XCTAssertFalse(ASRService.canUnloadIdleSpeechModel( + isAsrReady: true, + isRunning: false, + isStarting: false, + hasActiveModelPreparation: false, + hasActiveModelDownload: true, + activeSpeechModelUseCount: 0 + )) + } + + func testCannotUnloadWhileTranscriptionWorkHoldsTheModel() { + // Final transcription in stop(), Local API requests, and long file + // transcription jobs all hold a use token that must block unload. + XCTAssertFalse(ASRService.canUnloadIdleSpeechModel( + isAsrReady: true, + isRunning: false, + isStarting: false, + hasActiveModelPreparation: false, + hasActiveModelDownload: false, + activeSpeechModelUseCount: 1 + )) + } + + func testOnlyAppleModelsHoldNoInProcessMemory() { + for model in SettingsStore.SpeechModel.allCases { + let isAppleManaged = model == .appleSpeech || model == .appleSpeechAnalyzer + XCTAssertEqual( + model.holdsModelInProcessMemory, + !isAppleManaged, + "Unexpected in-process memory classification for \(model.rawValue)" + ) + } + } + + func testIdleUnloadMinutesDefaultAndClamping() { + let defaults = UserDefaults.standard + let original = defaults.object(forKey: self.idleUnloadMinutesKey) + defer { + if let original { + defaults.set(original, forKey: self.idleUnloadMinutesKey) + } else { + defaults.removeObject(forKey: self.idleUnloadMinutesKey) + } + } + + defaults.removeObject(forKey: self.idleUnloadMinutesKey) + XCTAssertEqual( + SettingsStore.shared.speechModelIdleUnloadMinutes, + SettingsStore.defaultSpeechModelIdleUnloadMinutes + ) + + SettingsStore.shared.speechModelIdleUnloadMinutes = 15 + XCTAssertEqual(SettingsStore.shared.speechModelIdleUnloadMinutes, 15) + + SettingsStore.shared.speechModelIdleUnloadMinutes = 0 + XCTAssertEqual(SettingsStore.shared.speechModelIdleUnloadMinutes, 0) + + SettingsStore.shared.speechModelIdleUnloadMinutes = -5 + XCTAssertEqual(SettingsStore.shared.speechModelIdleUnloadMinutes, 0) + + // Oversized stored values (e.g. from a hand-edited or corrupt backup) + // must clamp instead of overflowing the timer's nanosecond math. + SettingsStore.shared.speechModelIdleUnloadMinutes = Int.max + XCTAssertEqual( + SettingsStore.shared.speechModelIdleUnloadMinutes, + SettingsStore.maxSpeechModelIdleUnloadMinutes + ) + + defaults.set(Int.max, forKey: self.idleUnloadMinutesKey) + XCTAssertEqual( + SettingsStore.shared.speechModelIdleUnloadMinutes, + SettingsStore.maxSpeechModelIdleUnloadMinutes + ) + } +}