From 6a376dd5add32f27c14c946defe072fc6c56bce1 Mon Sep 17 00:00:00 2001 From: Beingpax Date: Sat, 22 Mar 2025 13:52:43 +0545 Subject: [PATCH] Added Assistant Mode in Prompt + Cleaner Code seperation --- VoiceInk/Models/PredefinedPrompts.swift | 71 ++-- VoiceInk/Models/PromptTemplates.swift | 51 +++ .../Whisper/WhisperState+ModelManager.swift | 160 +++++++++ VoiceInk/Whisper/WhisperState+UI.swift | 120 +++++++ VoiceInk/Whisper/WhisperState.swift | 312 ++---------------- 5 files changed, 388 insertions(+), 326 deletions(-) create mode 100644 VoiceInk/Whisper/WhisperState+ModelManager.swift create mode 100644 VoiceInk/Whisper/WhisperState+UI.swift diff --git a/VoiceInk/Models/PredefinedPrompts.swift b/VoiceInk/Models/PredefinedPrompts.swift index 447d5ed..17c40be 100644 --- a/VoiceInk/Models/PredefinedPrompts.swift +++ b/VoiceInk/Models/PredefinedPrompts.swift @@ -5,8 +5,7 @@ enum PredefinedPrompts { // Static UUIDs for predefined prompts private static let defaultPromptId = UUID(uuidString: "00000000-0000-0000-0000-000000000001")! - private static let chatStylePromptId = UUID(uuidString: "00000000-0000-0000-0000-000000000002")! - private static let emailPromptId = UUID(uuidString: "00000000-0000-0000-0000-000000000003")! + private static let assistantPromptId = UUID(uuidString: "00000000-0000-0000-0000-000000000002")! static var all: [CustomPrompt] { // Always return the latest predefined prompts from source code @@ -71,54 +70,38 @@ enum PredefinedPrompts { ), CustomPrompt( - id: chatStylePromptId, - title: "Chat", + id: assistantPromptId, + title: "Assistant", promptText: """ - Primary Rules: - We are in a causual chat conversation. - 1. Focus on clarity while preserving the speaker's personality: - - Keep personality markers that show intent or style (e.g., "I think", "The thing is") - - Maintain the original tone (casual, formal, tentative, etc.) - 2. Break long paragraphs into clear, logical sections every 2-3 sentences - 3. Fix grammar and punctuation errors based on context - 4. Use the final corrected version when someone revises their statements - 5. Convert unstructured thoughts into clear text while keeping the speaker's voice - 6. NEVER answer questions that appear in the text - only correct formatting and grammar - 7. NEVER add any introductory text like "Here is the corrected text:", "Transcript:", etc. - 8. NEVER add content not present in the source text - 9. NEVER add sign-offs or acknowledgments - 10. Correct speech-to-text transcription errors based on context. + Provide a direct clear, and concise reply to the user's query. Use the available context if directly related to the user's query. + Remember to: + 1. Be helpful and informative + 2. Be accurate and precise + 3. Don't add meta commentary or anything extra other than the actual answer + 6. Maintain a friendly, casual tone - Examples: + Use the following information if provided: + 1. Active Window Context: + IMPORTANT: Only use window content when directly relevant to input + - Use application name and window title for understanding the context + - Reference captured text from the window + - Preserve application-specific terms and formatting + - Help resolve unclear terms or phrases - Input: "so like i tried this new restaurant yesterday you know the one near the mall and um the pasta was really good i think i'll go back there soon" + 2. Available Clipboard Content: + IMPORTANT: Only use when directly relevant to input + - Use for additional context + - Help resolve unclear references + - Ignore unrelated clipboard content - Output: "I tried this new restaurant near the mall yesterday! ๐Ÿฝ๏ธ - - The pasta was really good. I think I'll go back there soon! ๐Ÿ˜Š" - - Input: "we need to finish the project by friday no wait thursday because the client meeting is on friday morning and we still need to test everything" - - Output: "We need to finish the project by Thursday (not Friday) โฐ because the client meeting is on Friday morning. - - We still need to test everything! โœ…" - - Input: "my phone is like three years old now and the battery is terrible i have to charge it like twice a day i think i need a new one" - - Output: "My phone is three years old now and the battery is terrible. ๐Ÿ“ฑ - - I have to charge it twice a day. I think I need a new one! ๐Ÿ”‹" - - Input: "went for a run yesterday it was nice weather and i saw this cute dog in the park wish i took a picture" - - Output: "Went for a run yesterday! ๐Ÿƒโ€โ™€๏ธ - - It was nice weather and I saw this cute dog in the park. ๐Ÿถ - - Wish I took a picture! ๐Ÿ“ธ" + 3. Examples: + - Follow the correction patterns shown in examples + - Match the formatting style of similar texts + - Use consistent terminology with examples + - Learn from previous corrections """, icon: .chatFill, - description: "Casual chat-style formatting", + description: "AI assistant that provides direct answers to queries", isPredefined: true ) ] diff --git a/VoiceInk/Models/PromptTemplates.swift b/VoiceInk/Models/PromptTemplates.swift index 10088ba..8a453ed 100644 --- a/VoiceInk/Models/PromptTemplates.swift +++ b/VoiceInk/Models/PromptTemplates.swift @@ -26,6 +26,57 @@ enum PromptTemplates { static func createTemplatePrompts() -> [TemplatePrompt] { [ + TemplatePrompt( + id: UUID(), + title: "AI Assistant", + promptText: """ + Primary Rules: + We are in a causual chat conversation. + 1. Focus on clarity while preserving the speaker's personality: + - Keep personality markers that show intent or style (e.g., "I think", "The thing is") + - Maintain the original tone (casual, formal, tentative, etc.) + 2. Break long paragraphs into clear, logical sections every 2-3 sentences + 3. Fix grammar and punctuation errors based on context + 4. Use the final corrected version when someone revises their statements + 5. Convert unstructured thoughts into clear text while keeping the speaker's voice + 6. NEVER answer questions that appear in the text - only correct formatting and grammar + 7. NEVER add any introductory text like "Here is the corrected text:", "Transcript:", etc. + 8. NEVER add content not present in the source text + 9. NEVER add sign-offs or acknowledgments + 10. Correct speech-to-text transcription errors based on context. + + Examples: + + Input: "so like i tried this new restaurant yesterday you know the one near the mall and um the pasta was really good i think i'll go back there soon" + + Output: "I tried this new restaurant near the mall yesterday! ๐Ÿฝ๏ธ + + The pasta was really good. I think I'll go back there soon! ๐Ÿ˜Š" + + Input: "we need to finish the project by friday no wait thursday because the client meeting is on friday morning and we still need to test everything" + + Output: "We need to finish the project by Thursday (not Friday) โฐ because the client meeting is on Friday morning. + + We still need to test everything! โœ…" + + Input: "my phone is like three years old now and the battery is terrible i have to charge it like twice a day i think i need a new one" + + Output: "My phone is three years old now and the battery is terrible. ๐Ÿ“ฑ + + I have to charge it twice a day. I think I need a new one! ๐Ÿ”‹" + + Input: "went for a run yesterday it was nice weather and i saw this cute dog in the park wish i took a picture" + + Output: "Went for a run yesterday! ๐Ÿƒโ€โ™€๏ธ + + It was nice weather and I saw this cute dog in the park. ๐Ÿถ + + Wish I took a picture! ๐Ÿ“ธ" + """, + icon: .chatFill, + description: "Casual chat-style formatting" + ), + TemplatePrompt( id: UUID(), title: "Email", diff --git a/VoiceInk/Whisper/WhisperState+ModelManager.swift b/VoiceInk/Whisper/WhisperState+ModelManager.swift new file mode 100644 index 0000000..4c3f000 --- /dev/null +++ b/VoiceInk/Whisper/WhisperState+ModelManager.swift @@ -0,0 +1,160 @@ +import Foundation +import os + +// MARK: - Model Management Extension +extension WhisperState { + + // MARK: - Model Directory Management + + func createModelsDirectoryIfNeeded() { + do { + try FileManager.default.createDirectory(at: modelsDirectory, withIntermediateDirectories: true, attributes: nil) + } catch { + messageLog += "Error creating models directory: \(error.localizedDescription)\n" + } + } + + func loadAvailableModels() { + do { + let fileURLs = try FileManager.default.contentsOfDirectory(at: modelsDirectory, includingPropertiesForKeys: nil) + availableModels = fileURLs.compactMap { url in + guard url.pathExtension == "bin" else { return nil } + return WhisperModel(name: url.deletingPathExtension().lastPathComponent, url: url) + } + } catch { + messageLog += "Error loading available models: \(error.localizedDescription)\n" + } + } + + // MARK: - Model Loading + + func loadModel(_ model: WhisperModel) async throws { + guard whisperContext == nil else { return } + + logger.notice("๐Ÿ”„ Loading Whisper model: \(model.name)") + isModelLoading = true + defer { isModelLoading = false } + + do { + whisperContext = try await WhisperContext.createContext(path: model.url.path) + isModelLoaded = true + currentModel = model + logger.notice("โœ… Successfully loaded model: \(model.name)") + } catch { + logger.error("โŒ Failed to load model: \(model.name) - \(error.localizedDescription)") + throw WhisperStateError.modelLoadFailed + } + } + + func setDefaultModel(_ model: WhisperModel) async { + do { + currentModel = model + UserDefaults.standard.set(model.name, forKey: "CurrentModel") + canTranscribe = true + } catch { + currentError = error as? WhisperStateError ?? .unknownError + canTranscribe = false + } + } + + // MARK: - Model Download & Management + + func downloadModel(_ model: PredefinedModel) async { + guard let url = URL(string: model.downloadURL) else { return } + + logger.notice("๐Ÿ”ฝ Downloading model: \(model.name)") + do { + let (data, response) = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<(Data, URLResponse), Error>) in + let task = URLSession.shared.dataTask(with: url) { data, response, error in + if let error = error { + continuation.resume(throwing: error) + return + } + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode), + let data = data else { + continuation.resume(throwing: URLError(.badServerResponse)) + return + } + continuation.resume(returning: (data, httpResponse)) + } + + task.resume() + + let observation = task.progress.observe(\.fractionCompleted) { progress, _ in + DispatchQueue.main.async { + self.downloadProgress[model.name] = progress.fractionCompleted + } + } + + Task { + await withTaskCancellationHandler { + observation.invalidate() + } operation: { + await withCheckedContinuation { (_: CheckedContinuation) in } + } + } + } + + let destinationURL = modelsDirectory.appendingPathComponent(model.filename) + try data.write(to: destinationURL) + + availableModels.append(WhisperModel(name: model.name, url: destinationURL)) + self.downloadProgress.removeValue(forKey: model.name) + logger.notice("โœ… Successfully downloaded model: \(model.name)") + } catch { + logger.error("โŒ Failed to download model: \(model.name) - \(error.localizedDescription)") + currentError = .modelDownloadFailed + self.downloadProgress.removeValue(forKey: model.name) + } + } + + func deleteModel(_ model: WhisperModel) async { + do { + try FileManager.default.removeItem(at: model.url) + availableModels.removeAll { $0.id == model.id } + if currentModel?.id == model.id { + currentModel = nil + canTranscribe = false + } + } catch { + messageLog += "Error deleting model: \(error.localizedDescription)\n" + currentError = .modelDeletionFailed + } + } + + func unloadModel() { + Task { + await whisperContext?.releaseResources() + whisperContext = nil + isModelLoaded = false + + if let recordedFile = recordedFile { + try? FileManager.default.removeItem(at: recordedFile) + self.recordedFile = nil + } + } + } + + func clearDownloadedModels() async { + for model in availableModels { + do { + try FileManager.default.removeItem(at: model.url) + } catch { + messageLog += "Error deleting model: \(error.localizedDescription)\n" + } + } + availableModels.removeAll() + } + + // MARK: - Resource Management + + func cleanupModelResources() async { + if !isRecording && !isProcessing { + logger.notice("๐Ÿงน Cleaning up Whisper resources") + await whisperContext?.releaseResources() + whisperContext = nil + isModelLoaded = false + } + } +} \ No newline at end of file diff --git a/VoiceInk/Whisper/WhisperState+UI.swift b/VoiceInk/Whisper/WhisperState+UI.swift new file mode 100644 index 0000000..3955964 --- /dev/null +++ b/VoiceInk/Whisper/WhisperState+UI.swift @@ -0,0 +1,120 @@ +import Foundation +import SwiftUI +import os + +// MARK: - UI Management Extension +extension WhisperState { + + // MARK: - Recorder Panel Management + + func showRecorderPanel() { + logger.notice("๐Ÿ“ฑ Showing \(self.recorderType) recorder") + if recorderType == "notch" { + if notchWindowManager == nil { + notchWindowManager = NotchWindowManager(whisperState: self, recorder: recorder) + logger.info("Created new notch window manager") + } + notchWindowManager?.show() + } else { + if miniWindowManager == nil { + miniWindowManager = MiniWindowManager(whisperState: self, recorder: recorder) + logger.info("Created new mini window manager") + } + miniWindowManager?.show() + } + } + + func hideRecorderPanel() { + if isRecording { + Task { + await toggleRecord() + } + } + } + + // MARK: - Mini Recorder Management + + func toggleMiniRecorder() async { + if isMiniRecorderVisible { + await dismissMiniRecorder() + } else { + Task { + await toggleRecord() + + SoundManager.shared.playStartSound() + + await MainActor.run { + showRecorderPanel() + isMiniRecorderVisible = true + } + } + } + } + + func dismissMiniRecorder() async { + logger.notice("๐Ÿ“ฑ Dismissing \(self.recorderType) recorder") + shouldCancelRecording = true + if isRecording { + await recorder.stopRecording() + } + + if recorderType == "notch" { + notchWindowManager?.hide() + } else { + miniWindowManager?.hide() + } + + await MainActor.run { + isRecording = false + isVisualizerActive = false + isProcessing = false + isTranscribing = false + canTranscribe = true + isMiniRecorderVisible = false + shouldCancelRecording = false + } + + try? await Task.sleep(nanoseconds: 150_000_000) + await cleanupModelResources() + } + + func cancelRecording() async { + shouldCancelRecording = true + SoundManager.shared.playEscSound() + if isRecording { + await recorder.stopRecording() + } + await dismissMiniRecorder() + } + + // MARK: - Notification Handling + + func setupNotifications() { + NotificationCenter.default.addObserver(self, selector: #selector(handleToggleMiniRecorder), name: .toggleMiniRecorder, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(handleLicenseStatusChanged), name: .licenseStatusChanged, object: nil) + } + + @objc public func handleToggleMiniRecorder() { + if isMiniRecorderVisible { + Task { + await toggleRecord() + } + } else { + Task { + await toggleRecord() + + SoundManager.shared.playStartSound() + + await MainActor.run { + showRecorderPanel() + isMiniRecorderVisible = true + } + } + } + } + + @objc func handleLicenseStatusChanged() { + // This will refresh the license state when it changes elsewhere in the app + self.licenseViewModel = LicenseViewModel() + } +} \ No newline at end of file diff --git a/VoiceInk/Whisper/WhisperState.swift b/VoiceInk/Whisper/WhisperState.swift index 643907d..97ee70b 100644 --- a/VoiceInk/Whisper/WhisperState.swift +++ b/VoiceInk/Whisper/WhisperState.swift @@ -32,9 +32,21 @@ class WhisperState: NSObject, ObservableObject, AVAudioRecorderDelegate { } } - private var whisperContext: WhisperContext? - private let recorder = Recorder() - private var recordedFile: URL? = nil + @Published var isVisualizerActive = false + + @Published var isMiniRecorderVisible = false { + didSet { + if isMiniRecorderVisible { + showRecorderPanel() + } else { + hideRecorderPanel() + } + } + } + + var whisperContext: WhisperContext? + let recorder = Recorder() + var recordedFile: URL? = nil let whisperPrompt = WhisperPrompt() let modelContext: ModelContext @@ -61,11 +73,14 @@ class WhisperState: NSObject, ObservableObject, AVAudioRecorderDelegate { let modelsDirectory: URL let recordingsDirectory: URL let enhancementService: AIEnhancementService? - private var licenseViewModel: LicenseViewModel - private let logger = Logger(subsystem: "com.prakashjoshipax.voiceink", category: "WhisperState") + var licenseViewModel: LicenseViewModel + let logger = Logger(subsystem: "com.prakashjoshipax.voiceink", category: "WhisperState") private var transcriptionStartTime: Date? - private var notchWindowManager: NotchWindowManager? - private var miniWindowManager: MiniWindowManager? + var notchWindowManager: NotchWindowManager? + var miniWindowManager: MiniWindowManager? + + // For model progress tracking + @Published var downloadProgress: [String: Double] = [:] init(modelContext: ModelContext, enhancementService: AIEnhancementService? = nil) { self.modelContext = modelContext @@ -91,14 +106,6 @@ class WhisperState: NSObject, ObservableObject, AVAudioRecorderDelegate { } } - private func createModelsDirectoryIfNeeded() { - do { - try FileManager.default.createDirectory(at: modelsDirectory, withIntermediateDirectories: true, attributes: nil) - } catch { - messageLog += "Error creating models directory: \(error.localizedDescription)\n" - } - } - private func createRecordingsDirectoryIfNeeded() { do { try FileManager.default.createDirectory(at: recordingsDirectory, withIntermediateDirectories: true, attributes: nil) @@ -107,47 +114,6 @@ class WhisperState: NSObject, ObservableObject, AVAudioRecorderDelegate { } } - private func loadAvailableModels() { - do { - let fileURLs = try FileManager.default.contentsOfDirectory(at: modelsDirectory, includingPropertiesForKeys: nil) - availableModels = fileURLs.compactMap { url in - guard url.pathExtension == "bin" else { return nil } - return WhisperModel(name: url.deletingPathExtension().lastPathComponent, url: url) - } - } catch { - messageLog += "Error loading available models: \(error.localizedDescription)\n" - } - } - - private func loadModel(_ model: WhisperModel) async throws { - guard whisperContext == nil else { return } - - logger.notice("๐Ÿ”„ Loading Whisper model: \(model.name)") - isModelLoading = true - defer { isModelLoading = false } - - do { - whisperContext = try await WhisperContext.createContext(path: model.url.path) - isModelLoaded = true - currentModel = model - logger.notice("โœ… Successfully loaded model: \(model.name)") - } catch { - logger.error("โŒ Failed to load model: \(model.name) - \(error.localizedDescription)") - throw WhisperStateError.modelLoadFailed - } - } - - func setDefaultModel(_ model: WhisperModel) async { - do { - currentModel = model - UserDefaults.standard.set(model.name, forKey: "CurrentModel") - canTranscribe = true - } catch { - currentError = error as? WhisperStateError ?? .unknownError - canTranscribe = false - } - } - func toggleRecord() async { if isRecording { logger.notice("๐Ÿ›‘ Stopping recording") @@ -257,58 +223,6 @@ class WhisperState: NSObject, ObservableObject, AVAudioRecorderDelegate { private func onDidFinishRecording(success: Bool) { isRecording = false } - - @Published var downloadProgress: [String: Double] = [:] - - func downloadModel(_ model: PredefinedModel) async { - guard let url = URL(string: model.downloadURL) else { return } - - logger.notice("๐Ÿ”ฝ Downloading model: \(model.name)") - do { - let (data, response) = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<(Data, URLResponse), Error>) in - let task = URLSession.shared.dataTask(with: url) { data, response, error in - if let error = error { - continuation.resume(throwing: error) - return - } - guard let httpResponse = response as? HTTPURLResponse, - (200...299).contains(httpResponse.statusCode), - let data = data else { - continuation.resume(throwing: URLError(.badServerResponse)) - return - } - continuation.resume(returning: (data, httpResponse)) - } - - task.resume() - - let observation = task.progress.observe(\.fractionCompleted) { progress, _ in - DispatchQueue.main.async { - self.downloadProgress[model.name] = progress.fractionCompleted - } - } - - Task { - await withTaskCancellationHandler { - observation.invalidate() - } operation: { - await withCheckedContinuation { (_: CheckedContinuation) in } - } - } - } - - let destinationURL = modelsDirectory.appendingPathComponent(model.filename) - try data.write(to: destinationURL) - - availableModels.append(WhisperModel(name: model.name, url: destinationURL)) - self.downloadProgress.removeValue(forKey: model.name) - logger.notice("โœ… Successfully downloaded model: \(model.name)") - } catch { - logger.error("โŒ Failed to download model: \(model.name) - \(error.localizedDescription)") - currentError = .modelDownloadFailed - self.downloadProgress.removeValue(forKey: model.name) - } - } private func transcribeAudio(_ url: URL, duration: TimeInterval) async { if shouldCancelRecording { return } @@ -328,7 +242,7 @@ class WhisperState: NSObject, ObservableObject, AVAudioRecorderDelegate { logger.error("โŒ Failed to load model: \(currentModel.name) - \(error.localizedDescription)") messageLog += "Failed to load transcription model. Please try again.\n" currentError = .modelLoadFailed - await cleanupResources() + await cleanupModelResources() return } } @@ -350,7 +264,7 @@ class WhisperState: NSObject, ObservableObject, AVAudioRecorderDelegate { let permanentURLString = permanentURL.absoluteString if shouldCancelRecording { - await cleanupResources() + await cleanupModelResources() return } @@ -358,7 +272,7 @@ class WhisperState: NSObject, ObservableObject, AVAudioRecorderDelegate { let data = try readAudioSamples(url) if shouldCancelRecording { - await cleanupResources() + await cleanupModelResources() return } @@ -367,14 +281,14 @@ class WhisperState: NSObject, ObservableObject, AVAudioRecorderDelegate { await whisperContext.setPrompt(whisperPrompt.transcriptionPrompt) if shouldCancelRecording { - await cleanupResources() + await cleanupModelResources() return } await whisperContext.fullTranscribe(samples: data) if shouldCancelRecording { - await cleanupResources() + await cleanupModelResources() return } @@ -393,7 +307,7 @@ class WhisperState: NSObject, ObservableObject, AVAudioRecorderDelegate { enhancementService.isConfigured { do { if shouldCancelRecording { - await cleanupResources() + await cleanupModelResources() return } @@ -461,14 +375,14 @@ class WhisperState: NSObject, ObservableObject, AVAudioRecorderDelegate { } } - await cleanupResources() + await cleanupModelResources() await dismissMiniRecorder() } catch { messageLog += "\(error.localizedDescription)\n" currentError = .transcriptionFailed - await cleanupResources() + await cleanupModelResources() await dismissMiniRecorder() } } @@ -488,174 +402,8 @@ class WhisperState: NSObject, ObservableObject, AVAudioRecorderDelegate { return floats } - func deleteModel(_ model: WhisperModel) async { - do { - try FileManager.default.removeItem(at: model.url) - availableModels.removeAll { $0.id == model.id } - if currentModel?.id == model.id { - currentModel = nil - canTranscribe = false - } - } catch { - messageLog += "Error deleting model: \(error.localizedDescription)\n" - currentError = .modelDeletionFailed - } - } - - @Published var isVisualizerActive = false - - @Published var isMiniRecorderVisible = false { - didSet { - if isMiniRecorderVisible { - showRecorderPanel() - } else { - hideRecorderPanel() - } - } - } - - private func setupNotifications() { - NotificationCenter.default.addObserver(self, selector: #selector(handleToggleMiniRecorder), name: .toggleMiniRecorder, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(handleLicenseStatusChanged), name: .licenseStatusChanged, object: nil) - } - - @objc public func handleToggleMiniRecorder() { - if isMiniRecorderVisible { - Task { - await toggleRecord() - } - } else { - Task { - await toggleRecord() - - SoundManager.shared.playStartSound() - - await MainActor.run { - showRecorderPanel() - isMiniRecorderVisible = true - } - } - } - } - - @objc private func handleLicenseStatusChanged() { - // This will refresh the license state when it changes elsewhere in the app - self.licenseViewModel = LicenseViewModel() - } - - private func showRecorderPanel() { - logger.notice("๐Ÿ“ฑ Showing \(self.recorderType) recorder") - if recorderType == "notch" { - if notchWindowManager == nil { - notchWindowManager = NotchWindowManager(whisperState: self, recorder: recorder) - logger.info("Created new notch window manager") - } - notchWindowManager?.show() - } else { - if miniWindowManager == nil { - miniWindowManager = MiniWindowManager(whisperState: self, recorder: recorder) - logger.info("Created new mini window manager") - } - miniWindowManager?.show() - } - } - - private func hideRecorderPanel() { - if isRecording { - Task { - await toggleRecord() - } - } - } - - func toggleMiniRecorder() async { - if isMiniRecorderVisible { - await dismissMiniRecorder() - } else { - Task { - await toggleRecord() - - SoundManager.shared.playStartSound() - - await MainActor.run { - showRecorderPanel() - isMiniRecorderVisible = true - } - } - } - } - - private func cleanupResources() async { - if !isRecording && !isProcessing { - logger.notice("๐Ÿงน Cleaning up Whisper resources") - await whisperContext?.releaseResources() - whisperContext = nil - isModelLoaded = false - } - } - - func dismissMiniRecorder() async { - logger.notice("๐Ÿ“ฑ Dismissing \(self.recorderType) recorder") - shouldCancelRecording = true - if isRecording { - await recorder.stopRecording() - } - - if recorderType == "notch" { - notchWindowManager?.hide() - } else { - miniWindowManager?.hide() - } - - await MainActor.run { - isRecording = false - isVisualizerActive = false - isProcessing = false - isTranscribing = false - canTranscribe = true - isMiniRecorderVisible = false - shouldCancelRecording = false - } - - try? await Task.sleep(nanoseconds: 150_000_000) - await cleanupResources() - } - - func cancelRecording() async { - shouldCancelRecording = true - SoundManager.shared.playEscSound() - if isRecording { - await recorder.stopRecording() - } - await dismissMiniRecorder() - } - @Published var currentError: WhisperStateError? - func unloadModel() { - Task { - await whisperContext?.releaseResources() - whisperContext = nil - isModelLoaded = false - - if let recordedFile = recordedFile { - try? FileManager.default.removeItem(at: recordedFile) - self.recordedFile = nil - } - } - } - - private func clearDownloadedModels() async { - for model in availableModels { - do { - try FileManager.default.removeItem(at: model.url) - } catch { - messageLog += "Error deleting model: \(error.localizedDescription)\n" - } - } - availableModels.removeAll() - } - func getEnhancementService() -> AIEnhancementService? { return enhancementService }