Skip to main content
Glama

Whispera

by sapoepsilon
SettingsView.swift78.9 kB
import SwiftUI import AVFoundation import WhisperKit import MarkdownUI struct SettingsView: View { @AppStorage("globalShortcut") private var globalShortcut = "⌘⌥D" @AppStorage("selectedModel") private var selectedModel = "" @AppStorage("autoDownloadModel") private var autoDownloadModel = true @AppStorage("soundFeedback") private var soundFeedback = true @AppStorage("startSound") private var startSound = "Tink" @AppStorage("stopSound") private var stopSound = "Pop" @AppStorage("launchAtStartup") private var launchAtStartup = false @AppStorage("enableTranslation") private var enableTranslation = false @AppStorage("enableStreaming") private var enableStreaming = true @AppStorage("selectedLanguage") private var selectedLanguage = Constants.defaultLanguageName @AppStorage("autoExecuteCommands") private var autoExecuteCommands = false @AppStorage("globalCommandShortcut") private var globalCommandShortcut = "⌘⌥C" @AppStorage("useStreamingTranscription") private var useStreamingTranscription = true @AppStorage("shortcutHapticFeedback") private var shortcutHapticFeedback = false // MARK: - Live Transcription Settings @AppStorage("liveTranscriptionMaxWords") private var liveTranscriptionMaxWords = 5 @AppStorage("liveTranscriptionCornerRadius") private var liveTranscriptionCornerRadius = 10.0 @AppStorage("liveTranscriptionWindowOffset") private var liveTranscriptionWindowOffset = 25.0 @AppStorage("liveTranscriptionShowEllipsis") private var liveTranscriptionShowEllipsis = true @AppStorage("liveTranscriptionMaxWidthPercentage") private var liveTranscriptionMaxWidthPercentage = 0.6 @AppStorage("liveTranscriptionFollowCaret") private var liveTranscriptionFollowCaret = true // MARK: - File Transcription Settings @AppStorage("fileSelectionShortcut") private var fileSelectionShortcut = "⌃F" @AppStorage("autoDeleteDownloadedFiles") private var autoDeleteDownloadedFiles = true @AppStorage("transcriptionOutput") private var transcriptionOutput = "both" @AppStorage("transcriptionFileLocation") private var transcriptionFileLocation = "Desktop" @AppStorage("customTranscriptionPath") private var customTranscriptionPath = "" @AppStorage("youtubeQuality") private var youtubeQuality = "medium" @AppStorage("showTimestamps") private var showTimestamps = true @AppStorage("timestampFormat") private var timestampFormat = "MM:SS" @AppStorage("defaultTranscriptionMode") private var defaultTranscriptionMode = "plain" @AppStorage("maxFileSizeMB") private var maxFileSizeMB = 100 // MARK: - Injected Dependencies @State var permissionManager: PermissionManager @State var updateManager: UpdateManager @State var appLibraryManager: AppLibraryManager @State var whisperKit = WhisperKitTranscriber.shared @State private var availableModels: [String] = [] @State private var isRecordingShortcut = false @State private var isRecordingFileShortcut = false @State private var eventMonitor: Any? @State private var fileShortcutEventMonitor: Any? @State private var errorMessage: String? @State private var showingError = false @State private var showingLLMSettings = false @State private var showingToolsSettings = false @State private var showingSafetySettings = false @State private var showingNoUpdateAlert = false @State private var showingStorageDetails = false @State private var showingClearAllConfirmation = false @State private var confirmationStep = 0 @State private var removingModelId: String? @State private var liveTranscriptionInfoWindow: NSWindow? @State private var releaseNotesWindow: NSWindow? @State private var logsSize: String = "Calculating..." @State private var showingClearLogsConfirmation = false // Extended logging settings @AppStorage("enableExtendedLogging") private var enableExtendedLogging = true @AppStorage("enableDebugLogging") private var enableDebugLogging = false @AppStorage("modelComputeUnits") private var modelComputeUnits = 2 var body: some View { TabView { // MARK: - General Tab ScrollView { VStack(spacing: 16) { // MARK: - App Version Section HStack { VStack(alignment: .leading, spacing: 2) { Text("Whispera") .font(.headline) Text(AppVersion.current.versionString) .font(.caption) .foregroundColor(.secondary) } Spacer() if updateManager.isCheckingForUpdates { ProgressView() .scaleEffect(0.8) } else { Button("Check for Updates") { Task { do { let hasUpdate = try await updateManager.checkForUpdates() if !hasUpdate { showNoUpdateAlert() } } catch { errorMessage = "Failed to check for updates: \(error.localizedDescription)" showingError = true } } } .buttonStyle(.bordered) .disabled(updateManager.isCheckingForUpdates) } } // MARK: - Update Notification Banner if let latestVersion = updateManager.latestVersion, AppVersion(latestVersion) > AppVersion.current { VStack(alignment: .leading, spacing: 8) { HStack { Image(systemName: "arrow.down.circle.fill") .foregroundColor(.blue) Text("Update Available") .font(.headline) .foregroundColor(.blue) Spacer() } Text("Whispera \(latestVersion) is available") .font(.body) if let releaseNotes = updateManager.releaseNotes, !releaseNotes.isEmpty { Text(releaseNotes) .font(.caption) .foregroundColor(.secondary) .lineLimit(3) } HStack { if updateManager.isUpdateDownloaded { Button("Install Now") { Task { do { try await updateManager.installDownloadedUpdate() } catch { errorMessage = "Failed to install update: \(error.localizedDescription)" showingError = true } } } .buttonStyle(.borderedProminent) } else { Button("Download Update") { Task { do { try await updateManager.downloadUpdate() } catch { errorMessage = "Failed to download update: \(error.localizedDescription)" showingError = true } } } .buttonStyle(.borderedProminent) .disabled(updateManager.isDownloadingUpdate) } if updateManager.isDownloadingUpdate { ProgressView(value: updateManager.downloadProgress) .frame(maxWidth: .infinity) } else { Button("View Release Notes") { showReleaseNotes() } .buttonStyle(.bordered) } } } .padding(12) .background(.blue.opacity(0.1), in: RoundedRectangle(cornerRadius: 8)) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(.blue.opacity(0.3), lineWidth: 1) ) } Divider() HStack { Text("Global Shortcut") .font(.headline) Spacer() Button(action: { if isRecordingShortcut { stopRecording() } else { startRecording() } }) { Text(isRecordingShortcut ? "Press keys..." : globalShortcut) .font(.system(.body, design: .monospaced)) .frame(minWidth: 80) } .buttonStyle(.bordered) .foregroundColor(isRecordingShortcut ? .red : .primary) } HStack { Text("Sound Feedback") .font(.headline) Spacer() Toggle("", isOn: $soundFeedback) } if soundFeedback { HStack { Picker("Start Sound", selection: $startSound) { ForEach(getAvailableSounds(), id: \.self) { sound in Text(sound).tag(sound) } } .frame(minWidth: 120) .onChange(of: startSound) { previewSound(startSound) } } HStack { Picker("Stop Sound", selection: $stopSound) { ForEach(getAvailableSounds(), id: \.self) { sound in Text(sound).tag(sound) } } .frame(minWidth: 120) .onChange(of: stopSound) { previewSound(stopSound) } } } HStack { VStack(alignment: .leading, spacing: 2) { Text("Haptic Feedback") .font(.headline) Text("Trackpad vibration when shortcut is triggered") .font(.caption) .foregroundColor(.secondary) } Spacer() Toggle("", isOn: $shortcutHapticFeedback) } VStack(alignment: .leading, spacing: 8) { HStack { if whisperKit.isDownloadingModel || whisperKit.isModelLoading { VStack(alignment: .trailing, spacing: 4) { HStack(spacing: 8) { ProgressView() .scaleEffect(0.6) Text(getModelStatusText()) .font(.caption) .foregroundColor(.orange) } if whisperKit.isDownloadingModel { HStack { ProgressView(value: whisperKit.downloadProgress) .frame(width: 120, height: 4) Text("\(whisperKit.downloadProgress * 100, specifier: "%.1f")%") } } } } else { VStack(alignment: .trailing, spacing: 4) { Picker("Whisper model", selection: Binding( get: { whisperKit.selectedModel ?? selectedModel }, set: { newValue in selectedModel = newValue whisperKit.selectedModel = newValue } )) { ForEach(whisperKit.availableModels, id: \.self) { model in Text(WhisperKitTranscriber.getModelDisplayName(for: model)).tag(model) } } .frame(minWidth: 180) .accessibilityIdentifier("Whisper model") } } } // Show current model status HStack { Text("Status:") .font(.caption) .foregroundColor(.secondary) Text(getCurrentModelStatusText()) .font(.caption) .foregroundColor(getModelStatusColor()) .accessibilityIdentifier("modelStatusText") Text("Current memory usage: \(getMemoryUsage().description) MB") } } Text("Choose your Whisper model: base is fast and accurate for most use cases, small provides better accuracy for complex speech, and tiny is fastest for simple transcriptions.") .font(.caption) .foregroundColor(.secondary) .padding(.leading, 4) HStack { Text("Auto Download") .font(.headline) Spacer() Toggle("", isOn: $autoDownloadModel) } HStack { VStack(alignment: .leading, spacing: 2) { Text("Streaming Transcription") .font(.headline) Text("Process audio in real-time (max 30 minutes) instead of saving to file") .font(.caption) .foregroundColor(.secondary) } Spacer() Toggle("", isOn: $useStreamingTranscription) } HStack { VStack(alignment: .leading, spacing: 2) { Text("Translation Mode") .font(.headline) Text("Translate speech to English instead of transcribing") .font(.caption) .foregroundColor(.secondary) } Spacer() Toggle("", isOn: $enableTranslation) } HStack { VStack(alignment: .leading, spacing: 2) { Text("Live Transcription Mode") .font(.headline) Text("Transcribe speech in real-time with automatic text replacement") .font(.caption) .foregroundColor(.secondary) } GlassBetaElement( onTap: { showLiveTranscriptionInfo() } ) Spacer() Toggle("", isOn: $enableStreaming) } VStack(alignment: .leading, spacing: 8) { HStack { VStack(alignment: .leading, spacing: 2) { Text("Source Language") .font(.headline) Text("Language of the audio to transcribe") .font(.caption) .foregroundColor(.secondary) } Spacer() Picker("Language", selection: $selectedLanguage) { ForEach(Constants.sortedLanguageNames, id: \.self) { language in Text(language.capitalized).tag(language) } } .frame(minWidth: 120) } } Divider() VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 8) { Text("Model Compute Units") .font(.headline) Text("Hardware acceleration for all model components") .font(.caption) .foregroundColor(.secondary) } Picker("Compute Units", selection: $modelComputeUnits) { Text("CPU Only").tag(0) Text("CPU + GPU").tag(1) Text("CPU + Neural Engine").tag(2) Text("All").tag(3) } .pickerStyle(.segmented) HStack { Spacer() Button("Apply Changes") { Task { await refreshModelWithNewComputeOptions() } } .buttonStyle(.bordered) .disabled(whisperKit.isModelLoading || whisperKit.isDownloadingModel) } Text("CPU + Neural Engine is recommended for best performance on Apple Silicon Macs.") .font(.caption) .foregroundColor(.secondary) .padding(.leading, 4) } Divider() HStack { Text("Launch at Startup") .font(.headline) Spacer() Toggle("", isOn: $launchAtStartup) } Divider() HStack { Text("Setup") .font(.headline) Spacer() Button("Show Onboarding Again") { showOnboardingAgain() } .buttonStyle(.bordered) } if permissionManager.needsPermissions { VStack(alignment: .leading, spacing: 8) { HStack { Image(systemName: permissionManager.permissionStatusIcon) .foregroundColor(.orange) Text("Required Permissions") .font(.headline) .foregroundColor(.orange) Spacer() } Text(permissionManager.missingPermissionsDescription) .font(.body) if !permissionManager.microphonePermissionGranted { HStack { Text("• Microphone access required for voice recording") .font(.caption) Spacer() Button("Open Settings") { permissionManager.openMicrophoneSettings() } .buttonStyle(.bordered) .controlSize(.small) } } if !permissionManager.accessibilityPermissionGranted { HStack { Text("• Accessibility access required for global shortcuts") .font(.caption) Spacer() Button("Open Settings") { permissionManager.openAccessibilitySettings() } .buttonStyle(.bordered) .controlSize(.small) } } Button("Open System Settings") { permissionManager.openSystemSettings() } .buttonStyle(.borderedProminent) } .padding(12) .background(.orange.opacity(0.1), in: RoundedRectangle(cornerRadius: 8)) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(.orange.opacity(0.3), lineWidth: 1) ) } } .padding(20) } .tabItem { Label("General", systemImage: "gear") } // MARK: - Storage & Downloads Tab ScrollView { VStack(spacing: 16) { // Storage Summary VStack(alignment: .leading, spacing: 8) { HStack { Text("Storage") .font(.headline) Spacer() if appLibraryManager.isCalculatingStorage { ProgressView() .scaleEffect(0.7) } else { Button("Refresh") { Task { await appLibraryManager.refreshStorageInfo() } } .buttonStyle(.bordered) .controlSize(.small) } } HStack { Image(systemName: "internaldrive") .foregroundColor(.blue) VStack(alignment: .leading, spacing: 2) { Text("WhisperKit Models") .font(.subheadline) .fontWeight(.medium) Text(appLibraryManager.getStorageSummary()) .font(.caption) .foregroundColor(.secondary) } Spacer() Button("Show in Finder") { appLibraryManager.openAppLibraryInFinder() } .buttonStyle(.bordered) .controlSize(.small) } if appLibraryManager.hasModels { HStack { Button("View Details") { showingStorageDetails.toggle() } .buttonStyle(.bordered) .controlSize(.small) Spacer() Button("Clear All Models") { showingClearAllConfirmation = true confirmationStep = 0 } .buttonStyle(.bordered) .controlSize(.small) .foregroundColor(.red) } } } .padding(12) .background(.blue.opacity(0.1), in: RoundedRectangle(cornerRadius: 8)) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(.blue.opacity(0.3), lineWidth: 1) ) Divider() VStack(alignment: .leading, spacing: 8) { HStack { Text("Update Downloads") .font(.headline) Spacer() } HStack { Image(systemName: "arrow.down.circle") .foregroundColor(.green) VStack(alignment: .leading, spacing: 2) { Text("Download Location") .font(.subheadline) .fontWeight(.medium) if let location = updateManager.downloadLocation { Text("Latest: \(URL(fileURLWithPath: location).lastPathComponent)") .font(.caption) .foregroundColor(.secondary) } else { Text("No updates downloaded") .font(.caption) .foregroundColor(.secondary) } } Spacer() Button("Open Downloads") { appLibraryManager.openDownloadsInFinder() } .buttonStyle(.bordered) .controlSize(.small) } } .padding(12) .background(.green.opacity(0.1), in: RoundedRectangle(cornerRadius: 8)) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(.green.opacity(0.3), lineWidth: 1) ) Divider() // Application Logs VStack(alignment: .leading, spacing: 8) { HStack { Text("Application Logs") .font(.headline) Spacer() Toggle("", isOn: $enableExtendedLogging) .labelsHidden() } HStack { Image(systemName: "doc.text") .foregroundColor(.purple) VStack(alignment: .leading, spacing: 2) { Text("Debug Logs") .font(.subheadline) .fontWeight(.medium) Text("Size: \(logsSize)") .font(.caption) .foregroundColor(.secondary) } Spacer() Button("Show in Finder") { appLibraryManager.openLogsInFinder() } .buttonStyle(.bordered) .controlSize(.small) Button("Clear Logs") { showingClearLogsConfirmation = true } .buttonStyle(.bordered) .controlSize(.small) .foregroundColor(.red) } if enableExtendedLogging { VStack(alignment: .leading, spacing: 6) { Divider() HStack { VStack(alignment: .leading, spacing: 2) { Text("Debug Mode") .font(.subheadline) .fontWeight(.medium) Text("Include detailed debug messages in logs") .font(.caption) .foregroundColor(.secondary) } Spacer() Toggle("", isOn: $enableDebugLogging) } .padding(.top, 8) Text("By default, only info, error, and fault messages are logged. Enable debug mode to capture detailed debug information.") .font(.caption) .foregroundColor(.secondary) .padding(.top, 4) } } } .padding(12) .background(.purple.opacity(0.1), in: RoundedRectangle(cornerRadius: 8)) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(.purple.opacity(0.3), lineWidth: 1) ) Spacer() } .padding(20) } .tabItem { Label("Storage & Downloads", systemImage: "internaldrive") } // MARK: - Live Transcription Tab (only shows when enabled) if enableStreaming { ScrollView { VStack(spacing: 16) { // Header HStack { Text("Live Transcription Settings") .font(.headline) Spacer() GlassBetaElement() } Text("Customize how the live transcription window appears and behaves") .font(.caption) .foregroundColor(.secondary) .frame(maxWidth: .infinity, alignment: .leading) Divider() // MARK: - Preview Section VStack(alignment: .leading, spacing: 8) { Text("Preview") .font(.headline) // Preview container HStack { Spacer() LiveTranscriptionPreview( maxWords: liveTranscriptionMaxWords, cornerRadius: liveTranscriptionCornerRadius, showEllipsis: liveTranscriptionShowEllipsis ) Spacer() } .padding(.vertical, 20) .background(Color.gray.opacity(0.1), in: RoundedRectangle(cornerRadius: 8)) } Divider() // MARK: - Settings Section VStack(alignment: .leading, spacing: 16) { // Max words setting VStack(alignment: .leading, spacing: 4) { HStack { Text("Maximum Words to Display") .font(.headline) Spacer() Text("\(liveTranscriptionMaxWords)") .font(.system(.body, design: .monospaced)) .foregroundColor(.secondary) } HStack { Slider(value: Binding( get: { Double(liveTranscriptionMaxWords) }, set: { liveTranscriptionMaxWords = Int($0) NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .now) } ), in: 1...50, step: 1) TextField("Custom", value: $liveTranscriptionMaxWords, format: .number) .textFieldStyle(.roundedBorder) .frame(width: 60) } Text("Number of words to show in the transcription window (1-200+)") .font(.caption) .foregroundColor(.secondary) } // Corner radius setting VStack(alignment: .leading, spacing: 4) { HStack { Text("Window Corner Radius") .font(.headline) Spacer() Text("\(Int(liveTranscriptionCornerRadius))") .font(.system(.body, design: .monospaced)) .foregroundColor(.secondary) } Slider(value: $liveTranscriptionCornerRadius, in: 0...20, step: 1) .onChange(of: liveTranscriptionCornerRadius) { _ in NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .now) } Text("Roundness of the window corners") .font(.caption) .foregroundColor(.secondary) } // Window offset setting VStack(alignment: .leading, spacing: 4) { HStack { Text("Window Position Offset") .font(.headline) Spacer() Text("\(Int(liveTranscriptionWindowOffset)) px") .font(.system(.body, design: .monospaced)) .foregroundColor(.secondary) } Slider(value: $liveTranscriptionWindowOffset, in: 10...50, step: 5) .onChange(of: liveTranscriptionWindowOffset) { _ in NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .now) } Text("Distance from the cursor position") .font(.caption) .foregroundColor(.secondary) } // Max width percentage setting VStack(alignment: .leading, spacing: 4) { HStack { Text("Maximum Window Width") .font(.headline) Spacer() Text("\(Int(liveTranscriptionMaxWidthPercentage * 100))%") .font(.system(.body, design: .monospaced)) .foregroundColor(.secondary) } Slider(value: $liveTranscriptionMaxWidthPercentage, in: 0.3...0.8, step: 0.05) .onChange(of: liveTranscriptionMaxWidthPercentage) { _ in NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .now) } Text("Maximum width as percentage of screen width") .font(.caption) .foregroundColor(.secondary) } // Show ellipsis toggle HStack { VStack(alignment: .leading, spacing: 2) { Text("Show Ellipsis") .font(.headline) Text("Display '...' when text is truncated") .font(.caption) .foregroundColor(.secondary) } Spacer() Toggle("", isOn: $liveTranscriptionShowEllipsis) } // Follow caret toggle HStack { VStack(alignment: .leading, spacing: 2) { Text("Follow Caret Position") .font(.headline) Text("Window follows cursor position while typing") .font(.caption) .foregroundColor(.secondary) } Spacer() Toggle("", isOn: $liveTranscriptionFollowCaret) } } Spacer() } .padding(20) } .tabItem { Label("Live Transcription", systemImage: "waveform") } } // MARK: - File Transcription Tab ScrollView { VStack(alignment: .leading, spacing: 16) { // MARK: - File Selection Section VStack(alignment: .leading, spacing: 12) { Text("File Selection") .font(.headline) HStack { Text("Shortcut:") Spacer() Text(fileSelectionShortcut) .font(.system(.body, design: .monospaced)) .padding(.horizontal, 8) .padding(.vertical, 4) .background(.blue.opacity(0.1), in: RoundedRectangle(cornerRadius: 4)) Button(isRecordingFileShortcut ? "Press keys..." : "Change") { if isRecordingFileShortcut { stopRecordingFileShortcut() } else { startRecordingFileShortcut() } } .buttonStyle(.bordered) .foregroundColor(isRecordingFileShortcut ? .white : .primary) .background(isRecordingFileShortcut ? .blue : Color.clear) } Text("Use this shortcut to open a file selection dialog for transcription.") .font(.caption) .foregroundColor(.secondary) } Divider() // MARK: - Transcription Options Section VStack(alignment: .leading, spacing: 12) { Text("Transcription Options") .font(.headline) HStack { Text("Default mode:") Spacer() Picker("Default mode", selection: $defaultTranscriptionMode) { Text("Plain Text").tag("plain") Text("With Timestamps").tag("timestamps") } .pickerStyle(.menu) .frame(width: 140) } HStack { Text("Show timestamps:") Spacer() Toggle("", isOn: $showTimestamps) } if showTimestamps { HStack { Text("Timestamp format:") Spacer() Picker("Format", selection: $timestampFormat) { Text("MM:SS").tag("MM:SS") Text("HH:MM:SS").tag("HH:MM:SS") Text("Seconds").tag("Seconds") } .pickerStyle(.menu) .frame(width: 100) } } } Divider() // MARK: - Network & YouTube Section VStack(alignment: .leading, spacing: 12) { Text("Network & YouTube") .font(.headline) HStack { Text("Auto-delete downloaded files:") Spacer() Toggle("", isOn: $autoDeleteDownloadedFiles) } HStack { Text("Save transcription to:") Spacer() Picker("Output", selection: $transcriptionOutput) { Text("Clipboard only").tag("clipboard") Text("File only").tag("file") Text("Both").tag("both") } .pickerStyle(MenuPickerStyle()) .frame(maxWidth: 150) } if transcriptionOutput == "file" || transcriptionOutput == "both" { HStack { Text("File location:") Spacer() Picker("Location", selection: $transcriptionFileLocation) { Text("Desktop").tag("Desktop") Text("Documents").tag("Documents") Text("Downloads").tag("Downloads") Text("Custom...").tag("Custom") } .pickerStyle(.menu) .frame(width: 120) if transcriptionFileLocation == "Custom" { Button("Choose...") { chooseCustomTranscriptionLocation() } .buttonStyle(.bordered) .controlSize(.small) } } if transcriptionFileLocation == "Custom" { HStack { Text("Current:") .font(.caption) .foregroundColor(.secondary) Text(getCustomTranscriptionPath()) .font(.caption) .foregroundColor(.secondary) .lineLimit(1) .truncationMode(.middle) Spacer() } } } HStack { Text("YouTube audio quality:") Spacer() Picker("Quality", selection: $youtubeQuality) { Text("Low (128kbps)").tag("low") Text("Medium (256kbps)").tag("medium") Text("High (320kbps)").tag("high") } .pickerStyle(.menu) .frame(width: 150) } HStack { Text("Max file size (MB):") Spacer() TextField("Size", value: $maxFileSizeMB, format: .number) .textFieldStyle(.roundedBorder) .frame(width: 80) } } Divider() // MARK: - Supported Formats Section VStack(alignment: .leading, spacing: 12) { Text("Supported Formats") .font(.headline) VStack(alignment: .leading, spacing: 8) { Text("Audio formats:") .font(.subheadline) .foregroundColor(.secondary) Text("MP3, M4A, WAV, AAC, FLAC, AIFF, AU, CAF") .font(.caption) .foregroundColor(.secondary) Text("Video formats:") .font(.subheadline) .foregroundColor(.secondary) Text("MP4, MOV, AVI, MKV, WMV, FLV, WebM, M4V") .font(.caption) .foregroundColor(.secondary) Text("Network:") .font(.subheadline) .foregroundColor(.secondary) Text("HTTP/HTTPS URLs, YouTube URLs") .font(.caption) .foregroundColor(.secondary) } } Spacer() } .padding(20) } .tabItem { Label("File Transcription", systemImage: "doc.on.doc") } } .frame(width: 450, height: 520) .background(.regularMaterial) .onAppear { loadAvailableModels() checkLaunchAtStartupStatus() updateLogsSize() } .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("WhisperKitModelStateChanged"))) { _ in // Force UI update when model state changes loadAvailableModels() } .onDisappear { stopRecording() stopRecordingFileShortcut() } .onChange(of: selectedModel) { newModel in // Only auto-switch if auto-download is enabled if autoDownloadModel { Task { await switchToModel(newModel) } } } .onChange(of: launchAtStartup) { newValue in setLaunchAtStartup(newValue) } .alert("Error", isPresented: $showingError) { Button("OK") { showingError = false errorMessage = nil } } message: { Text(errorMessage ?? "An unknown error occurred") } .alert("Storage Details", isPresented: $showingStorageDetails) { Button("OK") { } } message: { Text(appLibraryManager.getDetailedStorageInfo().joined(separator: "\n")) } .alert("Clear All Models", isPresented: $showingClearAllConfirmation) { if confirmationStep == 0 { Button("Cancel", role: .cancel) { confirmationStep = 0 } Button("Continue", role: .destructive) { Task { do { try await appLibraryManager.removeAllModels() confirmationStep = 0 } catch { errorMessage = "Failed to clear models: \(error.localizedDescription)" showingError = true confirmationStep = 0 } } } } } message: { if confirmationStep == 0 { Text("This will permanently delete all downloaded WhisperKit models. You'll need to re-download them if you want to use them again.\n\nStorage to be freed: \(appLibraryManager.totalStorageFormatted)") } else { Text("Are you absolutely certain? This action cannot be undone.\n\nAll \(appLibraryManager.modelsCount) models will be permanently deleted.") } } .alert("Clear Application Logs", isPresented: $showingClearLogsConfirmation) { Button("Cancel", role: .cancel) { } Button("Clear Logs", role: .destructive) { Task { do { try await appLibraryManager.clearLogs() updateLogsSize() } catch { errorMessage = "Failed to clear logs: \(error.localizedDescription)" showingError = true } } } } message: { Text("This will permanently delete all application logs. This action cannot be undone.") } } private func showNoUpdateAlert() { let alert = NSAlert() alert.messageText = "No Updates Available" alert.informativeText = "You're running the latest version of Whispera." alert.addButton(withTitle: "OK") alert.runModal() } private func loadAvailableModels() { guard whisperKit.isInitialized else { return } Task { do { let recommendedModels = whisperKit.getRecommendedModels() try await whisperKit.refreshAvailableModels() let remoteModels = whisperKit.availableModels let downloaded = try await whisperKit.getDownloadedModels() // Use Set to automatically handle duplicates var allModelsSet = Set<String>() allModelsSet.insert(recommendedModels.default) allModelsSet.formUnion(recommendedModels.supported) allModelsSet.formUnion(remoteModels) var allModels = Array(allModelsSet) allModels.sort { (lhs, rhs) in let lhsDownloaded = downloaded.contains(lhs) let rhsDownloaded = downloaded.contains(rhs) if lhsDownloaded != rhsDownloaded { return lhsDownloaded && !rhsDownloaded } return WhisperKitTranscriber.getModelPriority(for: lhs) < WhisperKitTranscriber.getModelPriority(for: rhs) } await MainActor.run { self.availableModels = allModels // Sync selectedModel with current loaded model if one exists if let currentModel = whisperKit.currentModel, allModels.contains(currentModel) { selectedModel = currentModel whisperKit.selectedModel = currentModel } else if selectedModel.isEmpty || !allModels.contains(selectedModel) { // Set default selection if none set or invalid if let baseModel = allModels.first(where: { $0.contains("base.en") }) { selectedModel = baseModel whisperKit.selectedModel = baseModel } else if let firstModel = allModels.first { selectedModel = firstModel whisperKit.selectedModel = firstModel } } } } catch { print("Failed to load models: \(error)") await MainActor.run { errorMessage = "Failed to load available models: \(error.localizedDescription)" showingError = true } } } } private func startRecording() { isRecordingShortcut = true eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown]) { event in if self.isRecordingShortcut { let shortcut = self.formatKeyEvent(event) if !shortcut.isEmpty { self.globalShortcut = shortcut self.stopRecording() } return nil } return event } } private func stopRecording() { isRecordingShortcut = false if let monitor = eventMonitor { NSEvent.removeMonitor(monitor) eventMonitor = nil } } private func startRecordingFileShortcut() { isRecordingFileShortcut = true fileShortcutEventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown]) { event in if self.isRecordingFileShortcut { let shortcut = self.formatKeyEvent(event) if !shortcut.isEmpty { self.fileSelectionShortcut = shortcut self.stopRecordingFileShortcut() } return nil } return event } } private func stopRecordingFileShortcut() { isRecordingFileShortcut = false if let monitor = fileShortcutEventMonitor { NSEvent.removeMonitor(monitor) fileShortcutEventMonitor = nil } } private func formatKeyEvent(_ event: NSEvent) -> String { var parts: [String] = [] let flags = event.modifierFlags if flags.contains(.command) { parts.append("⌘") } if flags.contains(.option) { parts.append("⌥") } if flags.contains(.control) { parts.append("⌃") } if flags.contains(.shift) { parts.append("⇧") } // Handle special keys switch event.keyCode { // Function keys case 122: parts.append("F1") case 120: parts.append("F2") case 99: parts.append("F3") case 118: parts.append("F4") case 96: parts.append("F5") case 97: parts.append("F6") case 98: parts.append("F7") case 100: parts.append("F8") case 101: parts.append("F9") case 109: parts.append("F10") case 103: parts.append("F11") case 111: parts.append("F12") case 105: parts.append("F13") case 107: parts.append("F14") case 113: parts.append("F15") // Special keys case 49: parts.append("Space") case 36: parts.append("Return") case 48: parts.append("Tab") case 51: parts.append("Delete") case 53: parts.append("Escape") case 126: parts.append("↑") case 125: parts.append("↓") case 123: parts.append("←") case 124: parts.append("→") case 63: parts.append("🌐") // Globe/Fn key // Regular characters default: if let characters = event.charactersIgnoringModifiers?.uppercased() { parts.append(characters) } } // Allow function keys and Globe key without modifiers, but require modifiers for regular keys let requiresModifier = event.keyCode != 63 && !(event.keyCode >= 96 && event.keyCode <= 122) if requiresModifier && flags.intersection([.command, .option, .control, .shift]).isEmpty { return "" } return parts.joined() } private func switchToModel(_ modelName: String) async { do { try await whisperKit.switchModel(to: modelName) print("✅ Successfully switched to model: \(modelName)") } catch { print("❌ Failed to switch to model \(modelName): \(error)") await MainActor.run { errorMessage = "Failed to switch to model \(modelName): \(error.localizedDescription)" showingError = true } } } private func getAvailableSounds() -> [String] { return [ "None", "Basso", "Blow", "Bottle", "Frog", "Funk", "Glass", "Hero", "Morse", "Ping", "Pop", "Purr", "Sosumi", "Submarine", "Tink" ] } private func setLaunchAtStartup(_ enabled: Bool) { guard let bundleIdentifier = Bundle.main.bundleIdentifier else { print("❌ Could not get bundle identifier") return } let launchAgentURL = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent("Library/LaunchAgents") .appendingPathComponent("\(bundleIdentifier).plist") if enabled { // Create launch agent plist let plistContent = """ <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>Label</key> <string>\(bundleIdentifier)</string> <key>ProgramArguments</key> <array> <string>\(Bundle.main.executablePath ?? "")</string> </array> <key>RunAtLoad</key> <true/> <key>KeepAlive</key> <false/> </dict> </plist> """ do { // Create LaunchAgents directory if it doesn't exist try FileManager.default.createDirectory( at: launchAgentURL.deletingLastPathComponent(), withIntermediateDirectories: true ) // Write the plist file try plistContent.write(to: launchAgentURL, atomically: true, encoding: .utf8) print("✅ Launch at startup enabled") } catch { print("❌ Failed to enable launch at startup: \(error)") } } else { // Remove launch agent plist do { try FileManager.default.removeItem(at: launchAgentURL) print("✅ Launch at startup disabled") } catch { print("❌ Failed to disable launch at startup: \(error)") } } } private func previewSound(_ soundName: String) { guard soundName != "None" else { return } NSSound(named: soundName)?.play() } private func checkLaunchAtStartupStatus() { guard let bundleIdentifier = Bundle.main.bundleIdentifier else { return } let launchAgentURL = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent("Library/LaunchAgents") .appendingPathComponent("\(bundleIdentifier).plist") launchAtStartup = FileManager.default.fileExists(atPath: launchAgentURL.path) } private func showOnboardingAgain() { // Reset the onboarding completion flag UserDefaults.standard.set(false, forKey: "hasCompletedOnboarding") // Post notification to show onboarding NotificationCenter.default.post(name: NSNotification.Name("ShowOnboarding"), object: nil) } private func showLLMSettings() { showingLLMSettings = true } private func showToolsSettings() { showingToolsSettings = true } private func showSafetySettings() { showingSafetySettings = true } private func showLiveTranscriptionInfo() { // Close existing window if open liveTranscriptionInfoWindow?.close() let contentView = LiveTranscriptionInfoView(onClose: { self.liveTranscriptionInfoWindow?.close() self.liveTranscriptionInfoWindow = nil }) let hostingView = NSHostingView(rootView: contentView) let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 500, height: 600), styleMask: [.titled, .closable, .miniaturizable], backing: .buffered, defer: false ) window.title = "Live Transcription Information" window.contentView = hostingView window.center() window.makeKeyAndOrderFront(nil) window.isReleasedWhenClosed = false // Store reference liveTranscriptionInfoWindow = window } private func showReleaseNotes() { guard let latestVersion = updateManager.latestVersion, let releaseNotes = updateManager.releaseNotes else { return } // Close existing window if open releaseNotesWindow?.close() let contentView = ReleaseNotesView( version: latestVersion, releaseNotes: releaseNotes, onClose: { self.releaseNotesWindow?.close() self.releaseNotesWindow = nil } ) let hostingView = NSHostingView(rootView: contentView) let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 600, height: 700), styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false ) window.title = "Release Notes - Whispera \(latestVersion)" window.contentView = hostingView window.center() window.makeKeyAndOrderFront(nil) window.isReleasedWhenClosed = false // Store reference releaseNotesWindow = window } private func getModelStatusText() -> String { if whisperKit.isDownloadingModel { return "Downloading \(whisperKit.downloadingModelName ?? "model")..." } else if whisperKit.isModelLoading { return "Loading \(selectedModel)..." } return "" } private func getCurrentModelStatusText() -> String { return whisperKit.modelState // if whisperKit.isDownloadingModel { // return "Downloading..." // } else if whisperKit.isModelLoading { // return "Loading..." // } else if whisperKit.isModelLoaded { // if let currentModel = whisperKit.currentModel { // let cleanName = currentModel.replacingOccurrences(of: "openai_whisper-", with: "") // // Check if the currently selected model differs from the loaded model // if let selectedModel = whisperKit.selectedModel, selectedModel != currentModel { // return "Loaded: \(cleanName) (different model selected)" // } // return "Loaded: \(cleanName)" // } else { // return "Model loaded" // } // } else if whisperKit.isInitialized { // return !whisperKit.isCurrentModelLoaded() ? "Different model selected" : "No model loaded" // } else { // return "Initializing..." // } } private func getModelStatusColor() -> Color { if whisperKit.isDownloadingModel || whisperKit.isModelLoading { return .orange } else if whisperKit.isModelLoaded { // Check if selected model matches current model if let selectedModel = whisperKit.selectedModel, let currentModel = whisperKit.currentModel { return selectedModel == currentModel ? .green : .orange } return whisperKit.isCurrentModelLoaded() ? .green : .orange } else { return .secondary } } private func getMemoryUsage() -> Int { // Simple memory usage calculation let task = mach_task_self_ var info = mach_task_basic_info() var count = mach_msg_type_number_t(MemoryLayout<mach_task_basic_info>.size)/4 let kerr: kern_return_t = withUnsafeMutablePointer(to: &info) { $0.withMemoryRebound(to: integer_t.self, capacity: 1) { task_info(task, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count) } } if kerr == KERN_SUCCESS { return Int(info.resident_size) / 1024 / 1024 // Convert to MB } else { return 0 } } private func updateLogsSize() { Task { let (_, formatted) = await appLibraryManager.getLogsSize() await MainActor.run { logsSize = formatted.isEmpty ? "No logs" : formatted } } } private func refreshModelWithNewComputeOptions() async { guard whisperKit.currentModel != nil else { return } do { try await whisperKit.reloadCurrentModelIfNeeded() } catch { await MainActor.run { errorMessage = "Failed to refresh model with new compute options: \(error.localizedDescription)" showingError = true } } } private func chooseCustomTranscriptionLocation() { let panel = NSOpenPanel() panel.canChooseFiles = false panel.canChooseDirectories = true panel.allowsMultipleSelection = false panel.prompt = "Choose Transcription Folder" panel.message = "Select where transcription files should be saved" if panel.runModal() == .OK { if let url = panel.url { customTranscriptionPath = url.path } } } private func getCustomTranscriptionPath() -> String { if customTranscriptionPath.isEmpty { return "No folder selected" } return customTranscriptionPath } } // MARK: - Live Transcription Preview Component struct LiveTranscriptionPreview: View { let maxWords: Int let cornerRadius: CGFloat let showEllipsis: Bool private let sampleText = "The quick brown fox jumps over the lazy dog and runs through the forest" private var displayWords: [(text: String, isLast: Bool)] { let words = sampleText.split(separator: " ").map(String.init) guard !words.isEmpty else { return [] } let wordsToShow = words.suffix(maxWords) return wordsToShow.enumerated().map { index, word in (text: word, isLast: index == wordsToShow.count - 1) } } var body: some View { VStack(spacing: 0) { HStack(spacing: 4) { // Show ellipsis if configured and there are more words if showEllipsis && sampleText.split(separator: " ").count > maxWords { Text("...") .font(.system(.body, design: .rounded)) .foregroundColor(Color.secondary.opacity(0.6)) .padding(.trailing, 2) } ForEach(Array(displayWords.enumerated()), id: \.offset) { _, wordInfo in Text(wordInfo.text) .font(.system(wordInfo.isLast ? .title3 : .body, design: .rounded)) .foregroundColor(wordInfo.isLast ? Color.blue : Color.primary.opacity(0.8)) .fontWeight(wordInfo.isLast ? .semibold : .regular) } } .padding(.horizontal, 14) .padding(.vertical, 10) } .background( RoundedRectangle(cornerRadius: cornerRadius) .fill(.ultraThinMaterial) .overlay( RoundedRectangle(cornerRadius: cornerRadius) .fill( LinearGradient( colors: [ Color.blue.opacity(0.05), Color.blue.opacity(0.02) ], startPoint: .topLeading, endPoint: .bottomTrailing ) ) ) ) .overlay( RoundedRectangle(cornerRadius: cornerRadius) .strokeBorder( LinearGradient( colors: [ Color.blue.opacity(0.3), Color.blue.opacity(0.1) ], startPoint: .topLeading, endPoint: .bottomTrailing ), lineWidth: 1 ) ) .shadow(color: Color.blue.opacity(0.1), radius: 8, x: 0, y: 2) .shadow(color: Color.black.opacity(0.05), radius: 4, x: 0, y: 1) } } // MARK: - Live Transcription Info View struct LiveTranscriptionInfoView: View { let onClose: () -> Void var body: some View { VStack(spacing: 0) { // Header HStack { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 8) { Image(systemName: "waveform") .font(.title2) .foregroundColor(.blue) Text("Live Transcription Mode") .font(.title2) .fontWeight(.semibold) GlassBetaElement() } Text("Real-time speech to text") .font(.subheadline) .foregroundColor(.secondary) } Spacer() Button { onClose() } label: { Image(systemName: "xmark.circle.fill") .font(.title2) .foregroundColor(.secondary) } .buttonStyle(.plain) } .padding(20) Divider() ScrollView { VStack(alignment: .leading, spacing: 20) { // Current Limitations VStack(alignment: .leading, spacing: 12) { HStack(spacing: 8) { Image(systemName: "exclamationmark.triangle.fill") .foregroundColor(.orange) Text("Current Limitations") .font(.headline) } VStack(alignment: .leading, spacing: 8) { Label { VStack(alignment: .leading, spacing: 2) { Text("Text Preview Positioning") .font(.subheadline) .fontWeight(.medium) Text("May not position correctly in non-native apps (Chrome, VSCode, Electron apps). Falls back to last mouse click position.") .font(.caption) .foregroundColor(.secondary) } } icon: { Image(systemName: "cursor.rays") .foregroundColor(.orange) } } .padding(12) .background(Color.orange.opacity(0.1)) .cornerRadius(8) } // Keyboard Shortcut Conflicts VStack(alignment: .leading, spacing: 12) { HStack(spacing: 8) { Image(systemName: "keyboard.badge.ellipsis") .foregroundColor(.purple) Text("Keyboard Shortcut Conflicts") .font(.headline) } VStack(alignment: .leading, spacing: 8) { Text("Your global shortcut may conflict with app-specific shortcuts:") .font(.subheadline) VStack(alignment: .leading, spacing: 6) { Label("⌃A navigates to specific locations in Xcode", systemImage: "chevron.left.slash.chevron.right") .font(.caption) Label("⌘Space opens Spotlight search", systemImage: "magnifyingglass") .font(.caption) Label("⌥⌘D opens Dock preferences", systemImage: "dock.rectangle") .font(.caption) } .padding(.leading, 8) Text("Tip: Choose less common combinations like ⌃⌥W or ⌘⇧R") .font(.caption) .foregroundColor(.secondary) .padding(.top, 4) } .padding(12) .background(Color.purple.opacity(0.1)) .cornerRadius(8) } // Double-tap shortcuts note VStack(alignment: .leading, spacing: 12) { HStack(spacing: 8) { Image(systemName: "hand.tap.fill") .foregroundColor(.blue) Text("Alternative: Double-tap Shortcuts") .font(.headline) } VStack(alignment: .leading, spacing: 8) { Text("In a future update, you'll be able to use double-tap shortcuts (like double ⌘ or double Globe 🌐) to avoid conflicts with other apps.") .font(.subheadline) .foregroundColor(.secondary) HStack(spacing: 4) { Text("Vote for this feature with 👍 if you'd like it implemented:") .font(.caption) .foregroundColor(.secondary) Button { if let url = URL(string: "https://github.com/sapoepsilon/Whispera/issues/16") { NSWorkspace.shared.open(url) } } label: { Text("Issue #16") .font(.caption) .foregroundColor(.blue) .underline() } .buttonStyle(.plain) } } .padding(12) .background(Color.blue.opacity(0.1)) .cornerRadius(8) } // Development status HStack(spacing: 6) { Image(systemName: "hammer.fill") .font(.caption) .foregroundColor(.secondary) Text("This feature is in active development and improvements are coming soon.") .font(.caption) .foregroundColor(.secondary) } .padding(.top, 8) } .padding(20) } } .frame(width: 500, height: 600) .background(Color(NSColor.windowBackgroundColor)) } } struct ReleaseNotesView: View { let version: String let releaseNotes: String let onClose: () -> Void var body: some View { VStack(spacing: 0) { HStack { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 8) { Image(systemName: "arrow.down.circle.fill") .font(.title2) .foregroundColor(.blue) Text("Whispera \(version)") .font(.title2) .fontWeight(.semibold) } Text("Release Notes") .font(.subheadline) .foregroundColor(.secondary) } Spacer() Button { onClose() } label: { Image(systemName: "xmark.circle.fill") .font(.title2) .foregroundColor(.secondary) } .buttonStyle(.plain) } .padding(20) Divider() ScrollView { VStack(alignment: .leading, spacing: 16) { Markdown(releaseNotes) .font(.system(size: 14)) } .padding(20) .frame(maxWidth: .infinity, alignment: .leading) } } .frame(width: 600, height: 700) .background(Color(NSColor.windowBackgroundColor)) } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/sapoepsilon/Whispera'

If you have feedback or need assistance with the MCP directory API, please join our Discord server