diff --git a/VoiceInk/Services/ModelPrewarmService.swift b/VoiceInk/Services/ModelPrewarmService.swift new file mode 100644 index 0000000..c1a9a58 --- /dev/null +++ b/VoiceInk/Services/ModelPrewarmService.swift @@ -0,0 +1,158 @@ +import Foundation +import SwiftData +import os +import AppKit + +@MainActor +final class ModelPrewarmService: ObservableObject { + private let whisperState: WhisperState + private let modelContext: ModelContext + private let logger = Logger(subsystem: "com.prakashjoshipax.voiceink", category: "ModelPrewarm") + + // Services (initialized lazily) + private var localTranscriptionService: LocalTranscriptionService? + private var parakeetTranscriptionService: ParakeetTranscriptionService? + private let nativeAppleTranscriptionService = NativeAppleTranscriptionService() + private let cloudTranscriptionService = CloudTranscriptionService() + + // Sample audio for prewarming + private let prewarmAudioURL = Bundle.main.url(forResource: "esc", withExtension: "wav") + + // User preference key + private let prewarmEnabledKey = "PrewarmModelOnWake" + + init(whisperState: WhisperState, modelContext: ModelContext) { + self.whisperState = whisperState + self.modelContext = modelContext + setupNotifications() + schedulePrewarmOnAppLaunch() + } + + // MARK: - Notification Setup + + private func setupNotifications() { + let center = NSWorkspace.shared.notificationCenter + + // Trigger on wake from sleep + center.addObserver( + self, + selector: #selector(schedulePrewarm), + name: NSWorkspace.didWakeNotification, + object: nil + ) + + logger.notice("🌅 ModelPrewarmService initialized - listening for wake and app launch") + } + + // MARK: - Trigger Handlers + + /// Trigger on app launch (cold start) + private func schedulePrewarmOnAppLaunch() { + logger.notice("🌅 App launched, scheduling prewarm") + Task { + try? await Task.sleep(for: .seconds(3)) + await performPrewarm() + } + } + + /// Trigger on wake from sleep or screen unlock + @objc private func schedulePrewarm() { + logger.notice("🌅 Mac activity detected (wake/unlock), scheduling prewarm") + Task { + try? await Task.sleep(for: .seconds(3)) + await performPrewarm() + } + } + + // MARK: - Core Prewarming Logic + + private func performPrewarm() async { + guard shouldPrewarm() else { return } + + guard let audioURL = prewarmAudioURL else { + logger.error("❌ Prewarm audio file (esc.wav) not found") + return + } + + guard let currentModel = whisperState.currentTranscriptionModel else { + logger.notice("🌅 No model selected, skipping prewarm") + return + } + + logger.notice("🌅 Prewarming \(currentModel.displayName)") + let startTime = Date() + + do { + // Initialize services lazily + if localTranscriptionService == nil { + localTranscriptionService = LocalTranscriptionService( + modelsDirectory: whisperState.modelsDirectory, + whisperState: whisperState + ) + } + if parakeetTranscriptionService == nil { + parakeetTranscriptionService = ParakeetTranscriptionService() + } + + // Run transcription to trigger model loading and ANE compilation + let transcribedText: String + switch currentModel.provider { + case .local: + transcribedText = try await localTranscriptionService!.transcribe(audioURL: audioURL, model: currentModel) + case .parakeet: + transcribedText = try await parakeetTranscriptionService!.transcribe(audioURL: audioURL, model: currentModel) + case .nativeApple: + transcribedText = try await nativeAppleTranscriptionService.transcribe(audioURL: audioURL, model: currentModel) + default: + transcribedText = try await cloudTranscriptionService.transcribe(audioURL: audioURL, model: currentModel) + } + + let duration = Date().timeIntervalSince(startTime) + + // Save for telemetry + let transcription = Transcription( + text: "[PREWARM] \(transcribedText)", + duration: 1.0, + audioFileURL: audioURL.absoluteString, + transcriptionModelName: currentModel.displayName, + transcriptionDuration: duration + ) + modelContext.insert(transcription) + try? modelContext.save() + + logger.notice("🌅 Prewarm completed in \(String(format: "%.2f", duration))s") + + } catch { + logger.error("❌ Prewarm failed: \(error.localizedDescription)") + } + } + + // MARK: - Validation + + private func shouldPrewarm() -> Bool { + // Check if user has enabled prewarming + let isEnabled = UserDefaults.standard.object(forKey: prewarmEnabledKey) as? Bool ?? true + guard isEnabled else { + logger.notice("🌅 Prewarm disabled by user") + return false + } + + // Only prewarm local models (Parakeet and Whisper need ANE compilation) + guard let model = whisperState.currentTranscriptionModel else { + return false + } + + switch model.provider { + case .local, .parakeet: + return true + default: + logger.notice("🌅 Skipping prewarm - cloud models don't need it") + return false + } + } + + deinit { + NSWorkspace.shared.notificationCenter.removeObserver(self) + logger.notice("🌅 ModelPrewarmService deinitialized") + } +} diff --git a/VoiceInk/Views/ModelSettingsView.swift b/VoiceInk/Views/ModelSettingsView.swift index 5a1a06d..856237b 100644 --- a/VoiceInk/Views/ModelSettingsView.swift +++ b/VoiceInk/Views/ModelSettingsView.swift @@ -6,6 +6,7 @@ struct ModelSettingsView: View { @AppStorage("IsTextFormattingEnabled") private var isTextFormattingEnabled = true @AppStorage("IsVADEnabled") private var isVADEnabled = true @AppStorage("AppendTrailingSpace") private var appendTrailingSpace = true + @AppStorage("PrewarmModelOnWake") private var prewarmModelOnWake = true @State private var customPrompt: String = "" @State private var isEditing: Bool = false @@ -96,13 +97,25 @@ struct ModelSettingsView: View { Text("Voice Activity Detection (VAD)") } .toggleStyle(.switch) - + InfoTip( title: "Voice Activity Detection", message: "Detect speech segments and filter out silence to improve accuracy of local models." ) } + HStack { + Toggle(isOn: $prewarmModelOnWake) { + Text("Prewarm model (Experimental)") + } + .toggleStyle(.switch) + + InfoTip( + title: "Prewarm Model (Experimental)", + message: "Turn this on if transcriptions with local models are taking longer than expected. Runs silent background transcription on app launch and wake to trigger optimization." + ) + } + } .padding() .background(Color(NSColor.controlBackgroundColor)) diff --git a/VoiceInk/Views/Settings/SettingsView.swift b/VoiceInk/Views/Settings/SettingsView.swift index ec5ea58..c1edb4d 100644 --- a/VoiceInk/Views/Settings/SettingsView.swift +++ b/VoiceInk/Views/Settings/SettingsView.swift @@ -381,7 +381,7 @@ struct SettingsView: View { .controlSize(.large) } } - + SettingsSection( icon: "lock.shield", title: "Data & Privacy", diff --git a/VoiceInk/VoiceInk.swift b/VoiceInk/VoiceInk.swift index 8923ead..6eaa3ff 100644 --- a/VoiceInk/VoiceInk.swift +++ b/VoiceInk/VoiceInk.swift @@ -22,12 +22,15 @@ struct VoiceInkApp: App { @AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false @AppStorage("enableAnnouncements") private var enableAnnouncements = true @State private var showMenuBarIcon = true - + // Audio cleanup manager for automatic deletion of old audio files private let audioCleanupManager = AudioCleanupManager.shared - + // Transcription auto-cleanup service for zero data retention private let transcriptionAutoCleanupService = TranscriptionAutoCleanupService.shared + + // Model prewarm service for optimizing model on wake from sleep + @StateObject private var prewarmService: ModelPrewarmService init() { // Configure FluidAudio logging subsystem @@ -101,21 +104,26 @@ struct VoiceInkApp: App { let hotkeyManager = HotkeyManager(whisperState: whisperState) _hotkeyManager = StateObject(wrappedValue: hotkeyManager) - + let menuBarManager = MenuBarManager() _menuBarManager = StateObject(wrappedValue: menuBarManager) - appDelegate.menuBarManager = menuBarManager - + let activeWindowService = ActiveWindowService.shared activeWindowService.configure(with: enhancementService) activeWindowService.configureWhisperState(whisperState) _activeWindowService = StateObject(wrappedValue: activeWindowService) + + let prewarmService = ModelPrewarmService(whisperState: whisperState, modelContext: container.mainContext) + _prewarmService = StateObject(wrappedValue: prewarmService) + + appDelegate.menuBarManager = menuBarManager + // Ensure no lingering recording state from previous runs Task { await whisperState.resetOnLaunch() } - + AppShortcuts.updateAppShortcutParameters() }