import Cocoa @preconcurrency import AVFoundation @preconcurrency import Speech import Carbon.HIToolbox import ServiceManagement import ApplicationServices import Darwin // MARK: - User Defaults Keys struct Defaults { static let language = "whisperLanguage" static let modelPath = "whisperModelPath" static let playSounds = "playSounds" static let replacements = "customReplacements" static let livePreview = "livePreview" } // MARK: - Whisper Models struct WhisperModels { struct Model { let name: String let filename: String let size: String let url: String let pros: String let cons: String } static let available: [Model] = [ Model(name: "Tiny", filename: "ggml-tiny.bin", size: "75 MB", url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.bin", pros: "Very fast (~1 sec), small download", cons: "Lower accuracy, struggles with accents"), Model(name: "Base", filename: "ggml-base.bin", size: "142 MB", url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin", pros: "Fast (~2 sec), good for clear speech", cons: "May miss some words in noisy audio"), Model(name: "Small q5_1", filename: "ggml-small-q5_1.bin", size: "181 MB", url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small-q5_1.bin", pros: "Small accuracy at under half the download, lower RAM", cons: "Slightly less accurate than full Small"), Model(name: "Small", filename: "ggml-small.bin", size: "466 MB", url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin", pros: "Good balance of speed and accuracy", cons: "Slower on Intel Macs"), Model(name: "Medium q5_0", filename: "ggml-medium-q5_0.bin", size: "514 MB", url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium-q5_0.bin", pros: "Medium accuracy at a third of the download, lower RAM", cons: "Slightly less accurate than full Medium"), Model(name: "Large v3 Turbo q5_0", filename: "ggml-large-v3-turbo-q5_0.bin", size: "547 MB", url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo-q5_0.bin", pros: "Turbo quality at a third of the download, much lower RAM", cons: "Small accuracy loss vs. full Turbo"), Model(name: "Large v3 Turbo q8_0", filename: "ggml-large-v3-turbo-q8_0.bin", size: "833 MB", url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo-q8_0.bin", pros: "Near-identical to full Turbo at about half the download", cons: "Larger than q5_0"), Model(name: "Large v3 q5_0", filename: "ggml-large-v3-q5_0.bin", size: "1.0 GB", url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-q5_0.bin", pros: "Large v3 accuracy at a third of the size", cons: "Slower than Turbo, small accuracy loss vs. full Large v3"), Model(name: "Medium", filename: "ggml-medium.bin", size: "1.5 GB", url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.bin", pros: "Good accuracy, handles accents well", cons: "Larger download, slower on older Macs"), Model(name: "Large v3 Turbo (Recommended)", filename: "ggml-large-v3-turbo.bin", size: "1.6 GB", url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo.bin", pros: "Best speed/accuracy ratio, fast like Medium, accurate like Large", cons: "Slightly larger than Medium"), Model(name: "Large v3", filename: "ggml-large-v3.bin", size: "3.1 GB", url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3.bin", pros: "Maximum accuracy for difficult audio", cons: "Very large, slow, minimal improvement over Turbo") ] static var modelsDirectory: String { return NSHomeDirectory() + "/.whisper-models" } static func installedModels() -> [(path: String, name: String, size: String)] { var result: [(path: String, name: String, size: String)] = [] let fm = FileManager.default let modelsDir = modelsDirectory guard let files = try? fm.contentsOfDirectory(atPath: modelsDir) else { return result } for file in files where file.hasSuffix(".bin") { let path = (modelsDir as NSString).appendingPathComponent(file) // Get file size var sizeStr = "" if let attrs = try? fm.attributesOfItem(atPath: path), let size = attrs[.size] as? Int64 { if size > 1_000_000_000 { sizeStr = String(format: "%.1f GB", Double(size) / 1_000_000_000) } else { sizeStr = String(format: "%.0f MB", Double(size) / 1_000_000) } } // Get friendly name (catalog name if known, e.g. "Large v3 Turbo q5_0") var name: String if let known = available.first(where: { $0.filename == file }) { name = known.name.replacingOccurrences(of: " (Recommended)", with: "") } else { name = file.replacingOccurrences(of: "ggml-", with: "").replacingOccurrences(of: ".bin", with: "") name = name.capitalized } result.append((path: path, name: name, size: sizeStr)) } return result.sorted { $0.name < $1.name } } } // MARK: - Supported Languages (Whisper) struct SupportedLanguages { static let autoDetect = "auto" static let codes: [String: String] = [ "auto": "Auto-detect", "hu": "Magyar", "en": "English", "de": "Deutsch", "fr": "Français", "es": "Español", "it": "Italiano", "pt": "Português", "nl": "Nederlands", "pl": "Polski", "ru": "Русский", "uk": "Українська", "cs": "Čeština", "sk": "Slovenčina", "ro": "Română", "hr": "Hrvatski", "sr": "Srpski", "sl": "Slovenščina", "ja": "日本語", "zh": "中文", "ko": "한국어", "ar": "العربية", "tr": "Türkçe", "vi": "Tiếng Việt", "th": "ไทย", "el": "Ελληνικά", "he": "עברית", "hi": "हिन्दी", "sv": "Svenska", "da": "Dansk", "fi": "Suomi", "no": "Norsk" ] static func isValid(_ code: String) -> Bool { return code == autoDetect || codes.keys.contains(code.lowercased()) } static var sortedCodes: [(code: String, name: String)] { // Put Auto-detect first, then sort the rest alphabetically var result = [(code: "auto", name: "Auto-detect")] result += codes.filter { $0.key != "auto" }.sorted { $0.value < $1.value }.map { (code: $0.key, name: $0.value) } return result } } // MARK: - Whisper Server (warm, preloaded-model backend) // Keeps a whisper-server child process alive with the model already loaded, so // each dictation is a fast local HTTP call (~0.5s) instead of a multi-second // model + Metal reload on every utterance (what plain whisper-cli does). final class WhisperServer { private let serverPath: String private let modelPath: String private let threads: Int private var process: Process? private var stopping = false private(set) var port: Int = 0 private(set) var isReady = false // Shared with background request threads private let lock = NSLock() private var lastUsed = Date() private var warmingUp = false // Still loading the model (process up, port not answering yet) var isStarting: Bool { !isReady && !stopping && (process?.isRunning ?? false) } var isWarmingUp: Bool { lock.lock(); defer { lock.unlock() } return warmingUp } // Idle for a while: macOS may have compressed or swapped the model out var needsWarmUp: Bool { lock.lock(); defer { lock.unlock() } return Date().timeIntervalSince(lastUsed) > 120 } private func markUsed() { lock.lock(); lastUsed = Date(); lock.unlock() } init(serverPath: String, modelPath: String, threads: Int) { self.serverPath = serverPath self.modelPath = modelPath self.threads = threads } func start(onReady: @escaping (Bool) -> Void) { port = WhisperServer.findFreePort() let task = Process() task.executableURL = URL(fileURLWithPath: serverPath) task.arguments = [ "-m", modelPath, "--host", "127.0.0.1", "--port", String(port), "-t", String(threads), "-nt", // no timestamps -> plain text response "-sns", // suppress non-speech tokens -> fewer hallucinations ] task.standardOutput = FileHandle.nullDevice task.standardError = FileHandle.nullDevice task.terminationHandler = { [weak self] _ in self?.isReady = false } do { try task.run() process = task } catch { NSLog("WhisperServer: failed to launch: \(error)") onReady(false) return } // Model load can take ~10s (incl. Metal init); poll the port off-thread. DispatchQueue.global(qos: .userInitiated).async { [weak self] in guard let self = self else { return } let deadline = Date().addingTimeInterval(120) while Date() < deadline { if self.stopping || !(self.process?.isRunning ?? false) { break } if WhisperServer.canConnect(port: self.port) { self.markUsed() self.isReady = true onReady(true) return } Thread.sleep(forTimeInterval: 0.3) } onReady(false) } } func stop() { stopping = true isReady = false process?.terminationHandler = nil process?.terminate() process = nil } // Block (off the main thread) while the server is still loading its model. func waitUntilReady(timeout: TimeInterval) -> Bool { let deadline = Date().addingTimeInterval(timeout) while isStarting && Date() < deadline { Thread.sleep(forTimeInterval: 0.1) } return isReady } // Send a tiny silent clip so the model is paged back into memory while the // user is still talking; the real request then finds it warm. func warmUp(language: String) { lock.lock() if warmingUp { lock.unlock(); return } warmingUp = true lock.unlock() DispatchQueue.global(qos: .userInitiated).async { let start = Date() _ = self.request(wavData: WhisperServer.silentWAV, language: language, prompt: nil) NSLog("WhisperServer: warm-up took %.2fs", Date().timeIntervalSince(start)) self.lock.lock(); self.warmingUp = false; self.lock.unlock() } } // 0.5 s of 16 kHz mono 16-bit silence static let silentWAV: Data = { let dataSize = UInt32(8000 * 2) var d = Data() func u32(_ v: UInt32) { withUnsafeBytes(of: v.littleEndian) { d.append(contentsOf: $0) } } func u16(_ v: UInt16) { withUnsafeBytes(of: v.littleEndian) { d.append(contentsOf: $0) } } d.append(contentsOf: Array("RIFF".utf8)); u32(36 + dataSize) d.append(contentsOf: Array("WAVEfmt ".utf8)); u32(16) u16(1); u16(1); u32(16000); u32(32000); u16(2); u16(16) // PCM, mono, 16 kHz, 16-bit d.append(contentsOf: Array("data".utf8)); u32(dataSize) d.append(Data(count: Int(dataSize))) return d }() // Synchronous transcription via the warm server. Call OFF the main thread. func transcribe(wavPath: String, language: String, prompt: String?) -> String? { guard let wavData = try? Data(contentsOf: URL(fileURLWithPath: wavPath)) else { return nil } return request(wavData: wavData, language: language, prompt: prompt) } private func request(wavData: Data, language: String, prompt: String?) -> String? { guard isReady, let url = URL(string: "http://127.0.0.1:\(port)/inference") else { return nil } defer { markUsed() } let boundary = "----WhisperDictate\(UInt32.random(in: 0 ... .max))" var req = URLRequest(url: url) req.httpMethod = "POST" req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") req.timeoutInterval = 120 var body = Data() let nl = "\r\n" func raw(_ s: String) { body.append(s.data(using: .utf8)!) } // audio file part raw("--\(boundary)\(nl)") raw("Content-Disposition: form-data; name=\"file\"; filename=\"audio.wav\"\(nl)") raw("Content-Type: audio/wav\(nl)\(nl)") body.append(wavData) raw(nl) // text fields func field(_ name: String, _ value: String) { raw("--\(boundary)\(nl)") raw("Content-Disposition: form-data; name=\"\(name)\"\(nl)\(nl)") raw("\(value)\(nl)") } field("response_format", "json") field("temperature", "0") if language.lowercased() != SupportedLanguages.autoDetect { field("language", language.lowercased()) } if let prompt = prompt, !prompt.isEmpty { field("prompt", prompt) } raw("--\(boundary)--\(nl)") req.httpBody = body let sem = DispatchSemaphore(value: 0) var result: String? URLSession.shared.dataTask(with: req) { data, _, error in defer { sem.signal() } if let error = error { NSLog("WhisperServer: request error: \(error)"); return } guard let data = data, let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let text = obj["text"] as? String else { return } result = text.trimmingCharacters(in: .whitespacesAndNewlines) }.resume() sem.wait() return result } // MARK: socket helpers (find a free loopback port + readiness probe) static func findFreePort() -> Int { let fd = socket(AF_INET, SOCK_STREAM, 0) if fd < 0 { return 8123 } defer { close(fd) } var addr = sockaddr_in() addr.sin_family = sa_family_t(AF_INET) addr.sin_addr.s_addr = inet_addr("127.0.0.1") addr.sin_port = 0 let bound = withUnsafePointer(to: &addr) { $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { Darwin.bind(fd, $0, socklen_t(MemoryLayout.size)) } } guard bound == 0 else { return 8123 } var len = socklen_t(MemoryLayout.size) _ = withUnsafeMutablePointer(to: &addr) { $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { getsockname(fd, $0, &len) } } return Int(UInt16(bigEndian: addr.sin_port)) } static func canConnect(port: Int) -> Bool { let fd = socket(AF_INET, SOCK_STREAM, 0) if fd < 0 { return false } defer { close(fd) } var addr = sockaddr_in() addr.sin_family = sa_family_t(AF_INET) addr.sin_port = in_port_t(UInt16(port)).bigEndian addr.sin_addr.s_addr = inet_addr("127.0.0.1") let r = withUnsafePointer(to: &addr) { $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { Darwin.connect(fd, $0, socklen_t(MemoryLayout.size)) } } return r == 0 } } // MARK: - Recording HUD (live mic level + silence warning) // A small floating, non-interactive panel shown while recording. Doubles as a // diagnostic: if the input stays silent it flags "no input" — exactly the // failure that silently produced empty recordings + whisper hallucinations. // MARK: - Live Preview (on-device Apple dictation model) // Streams a rough live transcript into the recording HUD using the system's // on-device dictation model (SpeechAnalyzer + DictationTranscriber, macOS 26). // It is only a preview: the pasted text still comes from Whisper. It captures // the microphone with its own AVAudioEngine so the AVAudioRecorder path used // for Whisper stays untouched; if anything here fails there is simply no preview. final class LivePreview { // Called on the main thread with the transcript so far var onText: ((String) -> Void)? private var session = 0 private var engine: AVAudioEngine? private var analyzer: SpeechAnalyzer? private var input: AsyncStream.Continuation? private var resultsTask: Task? // App language code -> supported dictation locale ("hu" -> hu-HU); nil if unsupported static func locale(for languageCode: String) async -> Locale? { let requested = languageCode == SupportedLanguages.autoDetect ? Locale.current : Locale(identifier: languageCode) return await DictationTranscriber.supportedLocale(equivalentTo: requested) } private static func makeTranscriber(locale: Locale) -> DictationTranscriber { DictationTranscriber(locale: locale, contentHints: [], transcriptionOptions: [.punctuation], reportingOptions: [.volatileResults, .frequentFinalization], attributeOptions: []) } // Download the on-device model for this language in the background if it's missing static func installModelIfNeeded(languageCode: String) { Task { guard let locale = await locale(for: languageCode) else { return } do { if let request = try await AssetInventory.assetInstallationRequest(supporting: [makeTranscriber(locale: locale)]) { NSLog("Live preview: installing dictation model for \(locale.identifier)") try await request.downloadAndInstall() NSLog("Live preview: dictation model installed for \(locale.identifier)") } } catch { NSLog("Live preview: model install failed: \(error)") } } } func start(languageCode: String, contextualStrings: [String]) { stop() let current = session Task { @MainActor in do { guard let locale = await LivePreview.locale(for: languageCode) else { NSLog("Live preview: no on-device dictation model for '\(languageCode)'") return } let transcriber = LivePreview.makeTranscriber(locale: locale) // Even when the model is already on disk the app may first have to register it; // that takes a fraction of a second. A real download just means no preview this time. _ = try? await AssetInventory.reserve(locale: locale) if await AssetInventory.status(forModules: [transcriber]) != .installed, let request = try await AssetInventory.assetInstallationRequest(supporting: [transcriber]) { NSLog("Live preview: preparing dictation model for \(locale.identifier)") try await request.downloadAndInstall() } guard current == self.session, let analyzerFormat = await SpeechAnalyzer.bestAvailableAudioFormat(compatibleWith: [transcriber]) else { return } // Start the microphone first; the stream buffers audio until the analyzer is running, // so the first words aren't lost while the model spins up. let (stream, continuation) = AsyncStream.makeStream() let engine = AVAudioEngine() let micFormat = engine.inputNode.outputFormat(forBus: 0) guard micFormat.sampleRate > 0, current == self.session, let monoFormat = AVAudioFormat(standardFormatWithSampleRate: micFormat.sampleRate, channels: 1), let converter = AVAudioConverter(from: monoFormat, to: analyzerFormat) else { return } NSLog("Live preview: mic %d ch @ %.0f Hz -> %@", micFormat.channelCount, micFormat.sampleRate, analyzerFormat.description) engine.inputNode.installTap(onBus: 0, bufferSize: 4096, format: micFormat, block: LivePreview.tapBlock(converter: converter, format: analyzerFormat, continuation: continuation)) try engine.start() self.engine = engine self.input = continuation let analyzer = SpeechAnalyzer(modules: [transcriber]) self.analyzer = analyzer if !contextualStrings.isEmpty { let context = AnalysisContext() context.contextualStrings[.general] = contextualStrings try await analyzer.setContext(context) } self.resultsTask = Task { @MainActor [weak self] in // Finalized segments accumulate; the volatile tail is replaced on every update var finalized = "" do { for try await result in transcriber.results { let text = String(result.text.characters) let shown = LivePreview.join(finalized, text) if result.isFinal { finalized = shown } guard let self = self, current == self.session else { return } self.onText?(shown) } } catch { NSLog("Live preview: results ended: \(error)") } } guard current == self.session else { return } // stop() already tore everything down try await analyzer.start(inputSequence: stream) } catch { NSLog("Live preview failed: \(error)") if current == self.session { self.stop() } } } } func stop() { session += 1 if let engine = engine { engine.inputNode.removeTap(onBus: 0) engine.stop() } engine = nil input?.finish() input = nil resultsTask?.cancel() resultsTask = nil if let analyzer = analyzer { Task { await analyzer.cancelAndFinishNow() } } analyzer = nil } // Segments don't always carry a separating space static func join(_ head: String, _ tail: String) -> String { guard let last = head.last, let first = tail.first, !last.isWhitespace, !first.isWhitespace else { return head + tail } return head + " " + tail } // Runs on the audio thread: take the voice channel as mono, convert it to the // analyzer's format. Built in a static context so it carries no actor assumptions. private static func tapBlock(converter: AVAudioConverter, format: AVAudioFormat, continuation: AsyncStream.Continuation) -> AVAudioNodeTapBlock { let downmixer = VoiceChannelPicker() return { micBuffer, _ in guard let buffer = downmixer.mono(from: micBuffer) else { return } let ratio = format.sampleRate / buffer.format.sampleRate let capacity = AVAudioFrameCount(Double(buffer.frameLength) * ratio) + 64 guard let out = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: capacity) else { return } var consumed = false var error: NSError? converter.convert(to: out, error: &error) { _, status in if consumed { status.pointee = .noDataNow return nil } consumed = true status.pointee = .haveData return buffer } if error == nil, out.frameLength > 0 { continuation.yield(AnalyzerInput(buffer: out)) } } } } // Picks the input channel that carries the voice and returns it as a mono buffer. // AVAudioConverter keeps only channel 1 when downmixing stereo, and outputs pure // silence for layout-less multi-channel inputs (e.g. a 4-input USB interface), // so the live preview model would hear nothing. The loudest channel is chosen on // smoothed energy, and only replaced by one that is clearly (6 dB) louder. final class VoiceChannelPicker { private var energy: [Float] = [] private var active = 0 private var monoFormat: AVAudioFormat? func mono(from buffer: AVAudioPCMBuffer) -> AVAudioPCMBuffer? { let channels = Int(buffer.format.channelCount) let frames = Int(buffer.frameLength) guard frames > 0, let data = buffer.floatChannelData else { return nil } if channels == 1 { return buffer } if energy.count != channels { energy = Array(repeating: 0, count: channels) active = 0 } for c in 0.. energy[active] * 4 { active = loudest } if monoFormat?.sampleRate != buffer.format.sampleRate { monoFormat = AVAudioFormat(standardFormatWithSampleRate: buffer.format.sampleRate, channels: 1) } guard let format = monoFormat, let out = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: AVAudioFrameCount(frames)), let dest = out.floatChannelData else { return nil } dest[0].update(from: data[active], count: frames) out.frameLength = AVAudioFrameCount(frames) return out } } final class MeterView: NSView { enum Mode { case recording, transcribing } enum Notice { case loadingModel, modelLoaded } private var level: CGFloat = 0 private var silent = false var mode = Mode.recording { didSet { needsDisplay = true } } var notice: Notice? { didSet { needsDisplay = true } } var previewText: String? { didSet { if previewText != oldValue { cachedPreview = nil; needsDisplay = true } } } var hasPreview: Bool { previewBlock() != nil } private static let maxPreviewLines = 3 private static let previewFont = NSFont.systemFont(ofSize: 13) private var cachedPreview: (width: CGFloat, text: NSAttributedString, height: CGFloat)? private var noticeHeight: CGFloat { switch notice { case .loadingModel?: return 35 case .modelLoaded?: return 19 case nil: return 0 } } // Height for the current mode, notice and live preview text var preferredHeight: CGFloat { let base: CGFloat = mode == .recording ? 72 : 44 let preview = previewBlock().map { 6 + $0.height } ?? 0 return base + noticeHeight + preview } // The tail of the live transcript that fits in a few lines; older words scroll off the top private func previewBlock() -> (text: NSAttributedString, height: CGFloat)? { guard let raw = previewText, !raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil } let width = bounds.width - 32 if let cached = cachedPreview, cached.width == width { return (cached.text, cached.height) } let attrs: [NSAttributedString.Key: Any] = [ .font: MeterView.previewFont, .foregroundColor: NSColor(calibratedWhite: 1, alpha: 0.92) ] func measure(_ string: NSAttributedString) -> CGFloat { ceil(string.boundingRect(with: NSSize(width: width, height: 10_000), options: [.usesLineFragmentOrigin, .usesFontLeading]).height) } let lineHeight = measure(NSAttributedString(string: "Ag", attributes: attrs)) let maxHeight = lineHeight * CGFloat(MeterView.maxPreviewLines) + 1 let words = raw.split(whereSeparator: { $0.isWhitespace }) var dropped = 0 var text = NSAttributedString(string: words.joined(separator: " "), attributes: attrs) var height = measure(text) while height > maxHeight && dropped < words.count - 1 { dropped += 1 text = NSAttributedString(string: "…" + words[dropped...].joined(separator: " "), attributes: attrs) height = measure(text) } cachedPreview = (width, text, height) return (text, height) } func set(level: CGFloat, silent: Bool) { self.level = max(0, min(1, level)) self.silent = silent needsDisplay = true } func reset() { level = 0; silent = false; mode = .recording; notice = nil; previewText = nil; needsDisplay = true } override func draw(_ dirtyRect: NSRect) { let bg = NSBezierPath(roundedRect: bounds, xRadius: 12, yRadius: 12) NSColor(calibratedWhite: 0.10, alpha: 0.93).setFill() bg.fill() let warn = mode == .recording && silent let label = mode == .transcribing ? "⏳ Transcribing…" : warn ? "🎤 No audio input?" : "🎤 Recording…" var textY = bounds.height - 26 label.draw(at: NSPoint(x: 16, y: textY), withAttributes: [ .font: NSFont.systemFont(ofSize: 12, weight: .medium), .foregroundColor: warn ? NSColor.systemRed : NSColor.white ]) switch notice { case .loadingModel?: textY -= 19 "Loading model into memory…".draw(at: NSPoint(x: 16, y: textY), withAttributes: [ .font: NSFont.systemFont(ofSize: 11, weight: .semibold), .foregroundColor: NSColor.systemYellow ]) textY -= 16 "The first transcription after a pause is slower.".draw(at: NSPoint(x: 16, y: textY), withAttributes: [ .font: NSFont.systemFont(ofSize: 10.5), .foregroundColor: NSColor(calibratedWhite: 1, alpha: 0.65) ]) case .modelLoaded?: textY -= 19 "✓ Model loaded".draw(at: NSPoint(x: 16, y: textY), withAttributes: [ .font: NSFont.systemFont(ofSize: 11, weight: .semibold), .foregroundColor: NSColor.systemGreen ]) case nil: break } if let preview = previewBlock() { let top = textY - 6 preview.text.draw(with: NSRect(x: 16, y: top - preview.height, width: bounds.width - 32, height: preview.height), options: [.usesLineFragmentOrigin, .usesFontLeading]) } guard mode == .recording else { return } let segs = 20 let margin: CGFloat = 16, gap: CGFloat = 3, segH: CGFloat = 14, y: CGFloat = 14 let segW = (bounds.width - margin * 2 - gap * CGFloat(segs - 1)) / CGFloat(segs) let lit = Int((level * CGFloat(segs)).rounded()) for i in 0.. 0.85 ? .systemRed : frac > 0.6 ? .systemYellow : .systemGreen).setFill() } else { NSColor(calibratedWhite: 1, alpha: 0.12).setFill() } path.fill() } } } final class RecordingHUD { private var window: NSWindow? private let meter = MeterView(frame: NSRect(x: 0, y: 0, width: 420, height: 72)) func show() { meter.reset() makeWindowIfNeeded() layout() window?.orderFrontRegardless() } func update(level: CGFloat, silent: Bool) { meter.set(level: level, silent: silent) } func setNotice(_ notice: MeterView.Notice?) { meter.notice = notice layout() } func setPreview(_ text: String?) { meter.previewText = text layout() } var hasPreview: Bool { meter.hasPreview } // Stay up after recording while a slow (model-loading) transcription runs func showTranscribing(notice: MeterView.Notice?) { makeWindowIfNeeded() meter.mode = .transcribing meter.notice = notice layout() window?.orderFrontRegardless() } func hide() { window?.orderOut(nil) } // Size to the content, anchored above the bottom-center of the screen private func layout() { guard let window = window, let screen = NSScreen.main else { return } let vf = screen.visibleFrame let size = NSSize(width: meter.frame.width, height: meter.preferredHeight) window.setFrame(NSRect(x: vf.midX - size.width / 2, y: vf.minY + 120, width: size.width, height: size.height), display: true) } private func makeWindowIfNeeded() { if window == nil { let w = NSPanel(contentRect: NSRect(x: 0, y: 0, width: 420, height: 72), styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false) w.isOpaque = false w.backgroundColor = .clear w.hasShadow = true w.level = .statusBar w.ignoresMouseEvents = true w.isFloatingPanel = true w.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary, .ignoresCycle] w.contentView = meter window = w } } } // MARK: - Model Download Progress Window // Standalone window with a real progress bar, size, speed and time remaining, // followed by the "loading model" and "ready" steps. Closing it only hides it: // the download keeps running and stays visible in the menu bar. final class DownloadProgressWindow: NSObject { var onCancel: (() -> Void)? var onRetry: (() -> Void)? private enum Phase { case downloading, loading, ready, failed } private var phase = Phase.downloading private var window: NSWindow? private let titleLabel = NSTextField(labelWithString: "") // Two indicators instead of toggling isIndeterminate on one: after an // indeterminate phase, NSProgressIndicator stops drawing determinate values. private let bar = NSProgressIndicator() private let busyBar = NSProgressIndicator() private let detailLabel = NSTextField(labelWithString: "") private let hintLabel = NSTextField(labelWithString: "Closing this window won't stop the download.") private let primaryButton = NSButton(title: "", target: nil, action: nil) private let secondaryButton = NSButton(title: "", target: nil, action: nil) // Smoothed throughput for the speed / time-remaining estimate private var sampleTime: Date? private var sampleBytes: Int64 = 0 private var bytesPerSecond: Double = 0 private var lastRender = Date.distantPast private let byteFormatter: ByteCountFormatter = { let f = ByteCountFormatter() f.countStyle = .memory // binary units, matching the sizes in the Download menu return f }() private let remainingFormatter: DateComponentsFormatter = { let f = DateComponentsFormatter() var calendar = Calendar.current calendar.locale = Locale(identifier: "en_US") // UI is English; avoid "About 6 perc remaining" f.calendar = calendar f.unitsStyle = .full f.allowedUnits = [.hour, .minute, .second] f.maximumUnitCount = 1 f.includesApproximationPhrase = true f.includesTimeRemainingPhrase = true return f }() var isVisible: Bool { window?.isVisible ?? false } func show() { let w = window ?? makeWindow() window = w if !w.isVisible { w.center() } w.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) } func hide() { window?.orderOut(nil) } func startDownloading(modelName: String) { phase = .downloading sampleTime = nil sampleBytes = 0 bytesPerSecond = 0 lastRender = .distantPast titleLabel.stringValue = "Downloading \(modelName)" setBar(indeterminate: true) setDetail("Connecting…") hintLabel.isHidden = false configureButtons(primary: "Cancel Download", secondary: "Hide", primaryIsDefault: false) } func updateProgress(written: Int64, expected: Int64) { guard phase == .downloading else { return } let now = Date() if let t = sampleTime { let dt = now.timeIntervalSince(t) if dt >= 0.5 { let instant = Double(written - sampleBytes) / dt bytesPerSecond = bytesPerSecond == 0 ? instant : bytesPerSecond * 0.8 + instant * 0.2 sampleTime = now sampleBytes = written } } else { sampleTime = now sampleBytes = written } // Redraw a few times per second, not on every chunk guard now.timeIntervalSince(lastRender) >= 0.25 || written == expected else { return } lastRender = now var parts: [String] = [] if expected > 0 { let fraction = Double(written) / Double(expected) setBar(indeterminate: false, value: fraction) parts.append("\(byteFormatter.string(fromByteCount: written)) of \(byteFormatter.string(fromByteCount: expected)) (\(Int(fraction * 100))%)") } else { parts.append(byteFormatter.string(fromByteCount: written)) } if bytesPerSecond > 0 { parts.append("\(byteFormatter.string(fromByteCount: Int64(bytesPerSecond)))/s") if expected > 0, let eta = remainingFormatter.string(from: Double(expected - written) / bytesPerSecond) { parts.append(eta) } } setDetail(parts.joined(separator: " · ")) } func showLoading(modelName: String) { phase = .loading titleLabel.stringValue = "Loading \(modelName)…" setBar(indeterminate: true) setDetail("Download complete. Loading the model into memory…") hintLabel.isHidden = true configureButtons(primary: "Hide", secondary: nil, primaryIsDefault: false) } func showReady(modelName: String) { phase = .ready titleLabel.stringValue = "✓ \(modelName) is ready" setBar(indeterminate: false, value: 1) setDetail("It is now the active model. Press ⌃⌥D to dictate.") hintLabel.isHidden = true configureButtons(primary: "Done", secondary: nil, primaryIsDefault: true) } func showFailed(modelName: String, message: String) { phase = .failed titleLabel.stringValue = "⚠️ Could not download \(modelName)" busyBar.stopAnimation(nil) busyBar.isHidden = true bar.isHidden = true setDetail(message) hintLabel.isHidden = true configureButtons(primary: "Retry", secondary: "Close", primaryIsDefault: true) } // MARK: private private func makeWindow() -> NSWindow { let w = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 440, height: 170), styleMask: [.titled, .closable], backing: .buffered, defer: false) w.title = "Model Download" w.isReleasedWhenClosed = false // reused; the close button just hides it let v = NSView(frame: NSRect(x: 0, y: 0, width: 440, height: 170)) titleLabel.frame = NSRect(x: 20, y: 130, width: 400, height: 20) titleLabel.font = .boldSystemFont(ofSize: 13) titleLabel.lineBreakMode = .byTruncatingTail v.addSubview(titleLabel) bar.frame = NSRect(x: 20, y: 104, width: 400, height: 20) bar.style = .bar bar.isIndeterminate = false bar.minValue = 0 bar.maxValue = 1 v.addSubview(bar) busyBar.frame = bar.frame busyBar.style = .bar busyBar.isIndeterminate = true v.addSubview(busyBar) detailLabel.frame = NSRect(x: 20, y: 80, width: 400, height: 17) detailLabel.font = .monospacedDigitSystemFont(ofSize: 11, weight: .regular) detailLabel.textColor = .secondaryLabelColor detailLabel.lineBreakMode = .byTruncatingTail v.addSubview(detailLabel) hintLabel.frame = NSRect(x: 20, y: 60, width: 400, height: 16) hintLabel.font = .systemFont(ofSize: 10) hintLabel.textColor = .tertiaryLabelColor v.addSubview(hintLabel) primaryButton.frame = NSRect(x: 286, y: 14, width: 140, height: 32) primaryButton.bezelStyle = .rounded primaryButton.target = self primaryButton.action = #selector(primaryClicked) v.addSubview(primaryButton) secondaryButton.frame = NSRect(x: 174, y: 14, width: 100, height: 32) secondaryButton.bezelStyle = .rounded secondaryButton.target = self secondaryButton.action = #selector(secondaryClicked) v.addSubview(secondaryButton) w.contentView = v return w } private func setBar(indeterminate: Bool, value: Double = 0) { bar.isHidden = indeterminate busyBar.isHidden = !indeterminate if indeterminate { busyBar.startAnimation(nil) } else { busyBar.stopAnimation(nil) bar.doubleValue = value } } private func setDetail(_ text: String) { detailLabel.stringValue = text detailLabel.toolTip = text // full text if truncated (e.g. long error messages) } private func configureButtons(primary: String, secondary: String?, primaryIsDefault: Bool) { primaryButton.title = primary primaryButton.keyEquivalent = primaryIsDefault ? "\r" : "" secondaryButton.title = secondary ?? "" secondaryButton.isHidden = secondary == nil secondaryButton.keyEquivalent = secondary == nil ? "" : "\u{1b}" // Esc } @objc private func primaryClicked() { switch phase { case .downloading: onCancel?() case .loading, .ready: hide() case .failed: onRetry?() } } @objc private func secondaryClicked() { hide() } } // MARK: - App Delegate class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate, NSTableViewDataSource, NSTableViewDelegate, NSTextFieldDelegate, NSWindowDelegate { var statusItem: NSStatusItem! var audioRecorder: AVAudioRecorder? var isRecording = false var isTranscribing = false // Model load state of the current dictation (see prepareModelForDictation) var modelLoading = false var loadNoticeShown = false var dictationGeneration = 0 var settingsWindowController: NSWindowController? var whisperServer: WhisperServer? let recordingHUD = RecordingHUD() let livePreview = LivePreview() var meterTimer: Timer? var silenceStart: Date? weak var dictTable: NSTableView? weak var settingsModelPopup: NSPopUpButton? weak var settingsNoModelLabel: NSTextField? weak var settingsDownloadButton: NSButton? // Use private temp directory with unique filename var audioFilePath: String { let tempDir = NSTemporaryDirectory() return (tempDir as NSString).appendingPathComponent("whisper-dictate-\(ProcessInfo.processInfo.processIdentifier).wav") } var language: String { get { UserDefaults.standard.string(forKey: Defaults.language) ?? "hu" } set { UserDefaults.standard.set(newValue, forKey: Defaults.language) } } var modelPath: String { get { UserDefaults.standard.string(forKey: Defaults.modelPath) ?? NSHomeDirectory() + "/.whisper-models/ggml-medium.bin" } set { UserDefaults.standard.set(newValue, forKey: Defaults.modelPath) } } var playSounds: Bool { get { UserDefaults.standard.object(forKey: Defaults.playSounds) as? Bool ?? true } set { UserDefaults.standard.set(newValue, forKey: Defaults.playSounds) } } var livePreviewEnabled: Bool { get { UserDefaults.standard.object(forKey: Defaults.livePreview) as? Bool ?? true } set { UserDefaults.standard.set(newValue, forKey: Defaults.livePreview) } } // Custom dictionary: ordered list of ["from": recognised, "to": replacement]. var replacements: [[String: String]] { get { (UserDefaults.standard.array(forKey: Defaults.replacements) as? [[String: String]]) ?? [] } set { UserDefaults.standard.set(newValue, forKey: Defaults.replacements) } } // Whisper initial prompt built from the desired ("to") terms, to bias // recognition toward the user's names/jargon. Returns nil if empty. func customPrompt() -> String? { let terms = dictionaryTerms() return terms.isEmpty ? nil : terms.joined(separator: ", ") } // The desired ("to") dictionary terms, also given to the live preview model func dictionaryTerms() -> [String] { replacements.compactMap { $0["to"] } .map { $0.replacingOccurrences(of: "\\n", with: " ") } .map { $0.trimmingCharacters(in: .whitespaces) } .filter { !$0.isEmpty } } // Apply the dictionary find/replace pairs to a transcript. "\n" in a // replacement becomes a real newline (e.g. spoken-command → line break). func applyReplacements(_ text: String) -> String { var result = text for pair in replacements { guard let from = pair["from"], !from.isEmpty else { continue } let to = (pair["to"] ?? "").replacingOccurrences(of: "\\n", with: "\n") result = result.replacingOccurrences(of: from, with: to, options: [.caseInsensitive]) } return result } func applicationDidFinishLaunching(_ notification: Notification) { setupStatusItem() registerHotkey() requestMicrophonePermission() // Start warming the model server BEFORE the (blocking) accessibility // prompt, so the multi-second model load overlaps with the user // granting permissions instead of running after it. if !hasAnyModel() { showFirstRunWizard() } else { checkModelExists() startWhisperServer() } checkAccessibilityPermission() if livePreviewEnabled { LivePreview.installModelIfNeeded(languageCode: language) } NSLog("WhisperDictate started. Press ⌃⌥D to toggle recording.") } func hasAnyModel() -> Bool { let validation = isValidModelPath(modelPath) return validation.valid } // MARK: - First Run Wizard func showFirstRunWizard() { let alert = NSAlert() alert.messageText = "Welcome to WhisperDictate!" alert.informativeText = "To get started, download a Whisper speech recognition model.\n\n" + "Quantized models (q5/q8) are much smaller and use far less RAM, with a small accuracy trade-off. " + "Hover over a model for details." alert.alertStyle = .informational // One popup instead of one button per model (too many models for buttons) let popup = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 320, height: 26), pullsDown: false) for model in WhisperModels.available { popup.addItem(withTitle: "\(model.name) (\(model.size))") popup.lastItem?.toolTip = "✓ \(model.pros)\n✗ \(model.cons)" } if let recommended = WhisperModels.available.firstIndex(where: { $0.name.contains("Recommended") }) { popup.selectItem(at: recommended) } alert.accessoryView = popup alert.addButton(withTitle: "Download") alert.addButton(withTitle: "Cancel") NSApp.activate(ignoringOtherApps: true) if alert.runModal() == .alertFirstButtonReturn { downloadModel(WhisperModels.available[popup.indexOfSelectedItem]) } else { updateStatus("⚠️ No model selected") } } var downloadTask: URLSessionDownloadTask? var downloadSession: URLSession? var currentDownloadModel: WhisperModels.Model? var currentDownloadDestination: String? var downloadPercent = 0 let downloadWindow = DownloadProgressWindow() func downloadModel(_ model: WhisperModels.Model) { // One download at a time: asking again just brings the progress back up if downloadTask != nil { downloadWindow.show() return } guard let url = URL(string: model.url) else { updateStatus("⚠️ Invalid URL") return } // Create models directory let modelsDir = WhisperModels.modelsDirectory try? FileManager.default.createDirectory(atPath: modelsDir, withIntermediateDirectories: true) // Store for delegate callbacks. An existing file is only replaced once the // new one has fully arrived, so a cancelled/failed download loses nothing. currentDownloadModel = model currentDownloadDestination = (modelsDir as NSString).appendingPathComponent(model.filename) downloadPercent = 0 downloadWindow.onCancel = { [weak self] in self?.cancelDownload() } downloadWindow.onRetry = { [weak self] in self?.downloadModel(model) } downloadWindow.startDownloading(modelName: model.name) downloadWindow.show() if downloadSession == nil { downloadSession = URLSession(configuration: .default, delegate: self, delegateQueue: .main) } downloadTask = downloadSession?.downloadTask(with: url) downloadTask?.resume() updateDownloadIndicators() NSLog("Starting download of \(model.name)") } @objc func showDownloadWindow() { downloadWindow.show() } func cancelDownload() { guard let task = downloadTask else { return } NSLog("Download cancelled: \(currentDownloadModel?.name ?? "")") clearDownloadState() // first, so the cancellation callback is ignored task.cancel() downloadWindow.hide() updateStatus("Download cancelled") } func failDownload(_ message: String) { let name = currentDownloadModel?.name ?? "model" clearDownloadState() updateStatus("⚠️ Download failed") if playSounds { NSSound(named: "Basso")?.play() } downloadWindow.showFailed(modelName: name, message: message) downloadWindow.show() } func clearDownloadState() { downloadTask = nil currentDownloadModel = nil currentDownloadDestination = nil updateDownloadIndicators() } // Menu bar icon when idle: download progress while a model downloads, else the mic var idleIcon: String { downloadTask != nil ? "⬇️ \(downloadPercent)%" : "🎤" } // Sync the menu bar icon, the menu's download line and the Settings button // with the download state, without overriding the recording/transcribing icon. func updateDownloadIndicators() { if let title = statusItem.button?.title, title == "🎤" || title.hasPrefix("⬇️") { statusItem.button?.title = idleIcon } if let item = statusItem.menu?.item(withTag: 101) { item.isHidden = currentDownloadModel == nil if let model = currentDownloadModel { item.title = "⬇️ Downloading \(model.name): \(downloadPercent)% — Show Progress…" } } settingsDownloadButton?.title = downloadTask != nil ? "⬇️ \(downloadPercent)%" : "Download..." } // MARK: - URLSessionDownloadDelegate func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { guard downloadTask === self.downloadTask else { return } downloadWindow.updateProgress(written: totalBytesWritten, expected: totalBytesExpectedToWrite) let percent = totalBytesExpectedToWrite > 0 ? Int((Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)) * 100) : 0 if percent != downloadPercent { downloadPercent = percent updateDownloadIndicators() } } func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { guard downloadTask === self.downloadTask, let model = currentDownloadModel, let destinationPath = currentDownloadDestination else { return } // Don't save an HTML error page as a model if let http = downloadTask.response as? HTTPURLResponse, !(200...299).contains(http.statusCode) { NSLog("Download failed: HTTP \(http.statusCode)") failDownload("The server returned HTTP \(http.statusCode).") return } // The temp file is deleted when this callback returns, so move it now let destinationURL = URL(fileURLWithPath: destinationPath) do { if FileManager.default.fileExists(atPath: destinationPath) { _ = try FileManager.default.replaceItemAt(destinationURL, withItemAt: location) } else { try FileManager.default.moveItem(at: location, to: destinationURL) } } catch { NSLog("Save failed: \(error)") failDownload("Could not save the model: \(error.localizedDescription)") return } NSLog("Model downloaded: \(model.name)") clearDownloadState() modelPath = destinationPath refreshSettingsModels() downloadWindow.showLoading(modelName: model.name) startWhisperServer { [weak self] in guard let self = self else { return } if self.playSounds { NSSound(named: "Glass")?.play() } self.downloadWindow.showReady(modelName: model.name) } } func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { // Success is handled in didFinishDownloadingTo (which clears downloadTask first) guard task === downloadTask, let error = error else { return } NSLog("Download failed: \(error)") failDownload(error.localizedDescription) } func applicationWillTerminate(_ notification: Notification) { whisperServer?.stop() cleanupAudioFile() } func cleanupAudioFile() { try? FileManager.default.removeItem(atPath: audioFilePath) } // MARK: - Status Item func setupStatusItem() { statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) statusItem.button?.title = "🎤" let menu = NSMenu() menu.addItem(NSMenuItem(title: "Toggle Recording (⌃⌥D)", action: #selector(toggleRecording), keyEquivalent: "")) menu.addItem(NSMenuItem.separator()) let statusMenuItem = NSMenuItem(title: "Status: Ready", action: nil, keyEquivalent: "") statusMenuItem.tag = 100 menu.addItem(statusMenuItem) let downloadMenuItem = NSMenuItem(title: "", action: #selector(showDownloadWindow), keyEquivalent: "") downloadMenuItem.target = self downloadMenuItem.tag = 101 downloadMenuItem.isHidden = true menu.addItem(downloadMenuItem) menu.addItem(NSMenuItem.separator()) menu.addItem(NSMenuItem(title: "Settings...", action: #selector(showSettings), keyEquivalent: ",")) menu.addItem(NSMenuItem.separator()) menu.addItem(NSMenuItem(title: "Quit WhisperDictate", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")) statusItem.menu = menu } // MARK: - Settings Window @objc func showSettings() { // Always create a fresh window to avoid zombie pointer issues let window = createSettingsWindow() settingsWindowController = NSWindowController(window: window) settingsWindowController?.showWindow(nil) NSApp.activate(ignoringOtherApps: true) } func createSettingsWindow() -> NSWindow { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 450, height: 580), styleMask: [.titled, .closable], backing: .buffered, defer: false ) window.title = "WhisperDictate Settings" window.center() window.delegate = self let contentView = NSView(frame: window.contentView!.bounds) var y: CGFloat = 538 let labelWidth: CGFloat = 120 let controlX: CGFloat = 140 let controlWidth: CGFloat = 280 // Language let langLabel = NSTextField(labelWithString: "Language:") langLabel.frame = NSRect(x: 20, y: y, width: labelWidth, height: 24) contentView.addSubview(langLabel) let langPopup = NSPopUpButton(frame: NSRect(x: controlX, y: y, width: 180, height: 24), pullsDown: false) langPopup.tag = 1 for lang in SupportedLanguages.sortedCodes { langPopup.addItem(withTitle: "\(lang.name) (\(lang.code))") langPopup.lastItem?.representedObject = lang.code } // Select current language if let index = SupportedLanguages.sortedCodes.firstIndex(where: { $0.code == language }) { langPopup.selectItem(at: index) } langPopup.target = self langPopup.action = #selector(languageChanged(_:)) contentView.addSubview(langPopup) y -= 40 // Model Selection let modelLabel = NSTextField(labelWithString: "Model:") modelLabel.frame = NSRect(x: 20, y: y, width: labelWidth, height: 24) contentView.addSubview(modelLabel) let noModelLabel = NSTextField(labelWithString: "No models - click Download") noModelLabel.frame = NSRect(x: controlX, y: y, width: 200, height: 24) noModelLabel.textColor = .secondaryLabelColor contentView.addSubview(noModelLabel) settingsNoModelLabel = noModelLabel let modelPopup = NSPopUpButton(frame: NSRect(x: controlX, y: y, width: 200, height: 24), pullsDown: false) modelPopup.tag = 2 modelPopup.target = self modelPopup.action = #selector(modelSelected(_:)) contentView.addSubview(modelPopup) settingsModelPopup = modelPopup refreshSettingsModels() let downloadBtn = NSButton(title: "Download...", target: self, action: #selector(downloadNewModel)) downloadBtn.frame = NSRect(x: controlX + 210, y: y, width: 80, height: 24) contentView.addSubview(downloadBtn) settingsDownloadButton = downloadBtn updateDownloadIndicators() y -= 40 // Hotkey (display only) let hotkeyLabel = NSTextField(labelWithString: "Hotkey:") hotkeyLabel.frame = NSRect(x: 20, y: y, width: labelWidth, height: 24) contentView.addSubview(hotkeyLabel) let hotkeyDisplay = NSTextField(labelWithString: "⌃⌥D (Control + Option + D)") hotkeyDisplay.frame = NSRect(x: controlX, y: y, width: controlWidth, height: 24) contentView.addSubview(hotkeyDisplay) y -= 40 // Play sounds let soundCheck = NSButton(checkboxWithTitle: "Play sound feedback", target: self, action: #selector(playSoundsChanged(_:))) soundCheck.frame = NSRect(x: controlX, y: y, width: controlWidth, height: 24) soundCheck.state = playSounds ? .on : .off contentView.addSubview(soundCheck) y -= 40 // Launch at login let loginCheck = NSButton(checkboxWithTitle: "Launch at login", target: self, action: #selector(launchAtLoginChanged(_:))) loginCheck.frame = NSRect(x: controlX, y: y, width: controlWidth, height: 24) loginCheck.state = isLaunchAtLoginEnabled() ? .on : .off contentView.addSubview(loginCheck) y -= 40 // Live preview let previewCheck = NSButton(checkboxWithTitle: "Live preview while recording", target: self, action: #selector(livePreviewChanged(_:))) previewCheck.frame = NSRect(x: controlX, y: y, width: controlWidth, height: 24) previewCheck.state = livePreviewEnabled ? .on : .off previewCheck.toolTip = "Shows a rough live transcript in the recording overlay using macOS's on-device dictation model. The pasted text still comes from Whisper." contentView.addSubview(previewCheck) // Custom dictionary (recognised -> replacement) let dictLabel = NSTextField(labelWithString: "Dictionary (heard → replacement):") dictLabel.frame = NSRect(x: 20, y: 304, width: 300, height: 20) contentView.addSubview(dictLabel) let scroll = NSScrollView(frame: NSRect(x: 20, y: 104, width: 410, height: 192)) scroll.hasVerticalScroller = true scroll.borderType = .bezelBorder let table = NSTableView(frame: scroll.bounds) table.usesAlternatingRowBackgroundColors = true table.rowHeight = 22 let colFrom = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("from")) colFrom.title = "Heard"; colFrom.width = 195; colFrom.isEditable = true let colTo = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("to")) colTo.title = "Replacement"; colTo.width = 195 table.addTableColumn(colFrom) table.addTableColumn(colTo) table.dataSource = self table.delegate = self scroll.documentView = table contentView.addSubview(scroll) dictTable = table let addBtn = NSButton(title: "+", target: self, action: #selector(addReplacement)) addBtn.frame = NSRect(x: 20, y: 70, width: 34, height: 26) addBtn.bezelStyle = .rounded contentView.addSubview(addBtn) let delBtn = NSButton(title: "–", target: self, action: #selector(removeReplacement)) delBtn.frame = NSRect(x: 56, y: 70, width: 34, height: 26) delBtn.bezelStyle = .rounded contentView.addSubview(delBtn) let dictHint = NSTextField(labelWithString: "The Replacement column also improves recognition. \\n = newline.") dictHint.frame = NSRect(x: 100, y: 74, width: 330, height: 18) dictHint.font = NSFont.systemFont(ofSize: 10) dictHint.textColor = .tertiaryLabelColor contentView.addSubview(dictHint) // Models directory hint let hintLabel = NSTextField(labelWithString: "Models stored in: ~/.whisper-models/") hintLabel.frame = NSRect(x: 20, y: 15, width: 410, height: 20) hintLabel.font = NSFont.systemFont(ofSize: 10) hintLabel.textColor = .tertiaryLabelColor contentView.addSubview(hintLabel) window.contentView = contentView return window } // (Re)fill the Settings model popup, e.g. after a download finished func refreshSettingsModels() { guard let popup = settingsModelPopup else { return } let installedModels = WhisperModels.installedModels() popup.removeAllItems() for (index, model) in installedModels.enumerated() { popup.addItem(withTitle: "\(model.name) (\(model.size))") // Use tag instead of representedObject to avoid memory issues popup.lastItem?.tag = index if model.path == modelPath { popup.selectItem(at: index) } } popup.isHidden = installedModels.isEmpty settingsNoModelLabel?.isHidden = !installedModels.isEmpty } @objc func languageChanged(_ sender: NSPopUpButton) { if let code = sender.selectedItem?.representedObject as? String { language = code if livePreviewEnabled { LivePreview.installModelIfNeeded(languageCode: code) } NSLog("Language changed to: \(language)") } } @objc func modelPathChanged(_ sender: NSTextField) { let newPath = sender.stringValue let validation = isValidModelPath(newPath) if validation.valid { modelPath = newPath } checkModelExists() } @objc func modelSelected(_ sender: NSPopUpButton) { let index = sender.selectedItem?.tag ?? 0 let installedModels = WhisperModels.installedModels() if index >= 0 && index < installedModels.count { let path = installedModels[index].path modelPath = path checkModelExists() startWhisperServer() NSLog("Model changed to: \(path)") } } @objc func downloadNewModel() { // A download is already running: show its progress instead of the menu if downloadTask != nil { downloadWindow.show() return } // Create simple popup menu for model selection let menu = NSMenu(title: "Select Model") let installedPaths = Set(WhisperModels.installedModels().map { $0.path }) for (index, model) in WhisperModels.available.enumerated() { let item = NSMenuItem(title: "\(model.name) (\(model.size))", action: #selector(downloadModelAtIndex(_:)), keyEquivalent: "") item.target = self item.tag = index // Checkmark = already downloaded let path = (WhisperModels.modelsDirectory as NSString).appendingPathComponent(model.filename) item.state = installedPaths.contains(path) ? .on : .off // Add subtitle with pros/cons item.toolTip = "✓ \(model.pros)\n✗ \(model.cons)" menu.addItem(item) } menu.addItem(NSMenuItem.separator()) menu.addItem(NSMenuItem(title: "Cancel", action: nil, keyEquivalent: "")) // Show menu at mouse location if let event = NSApp.currentEvent { NSMenu.popUpContextMenu(menu, with: event, for: settingsWindowController?.window?.contentView ?? statusItem.button!) } } @objc func downloadModelAtIndex(_ sender: NSMenuItem) { let index = sender.tag guard index >= 0 && index < WhisperModels.available.count else { return } downloadModel(WhisperModels.available[index]) } @objc func browseModel() { let panel = NSOpenPanel() panel.allowsMultipleSelection = false panel.canChooseDirectories = false panel.message = "Select Whisper model file (.bin)" if panel.runModal() == .OK, let url = panel.url { modelPath = url.path if let contentView = settingsWindowController?.window?.contentView { for subview in contentView.subviews { if let textField = subview as? NSTextField, textField.tag == 2 { textField.stringValue = modelPath } } } checkModelExists() } } @objc func livePreviewChanged(_ sender: NSButton) { livePreviewEnabled = sender.state == .on if livePreviewEnabled { LivePreview.installModelIfNeeded(languageCode: language) } } @objc func playSoundsChanged(_ sender: NSButton) { playSounds = sender.state == .on } @objc func launchAtLoginChanged(_ sender: NSButton) { setLaunchAtLogin(sender.state == .on) } // MARK: - Custom dictionary table (view-based, editable text fields) func numberOfRows(in tableView: NSTableView) -> Int { replacements.count } func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { guard let column = tableColumn, row < replacements.count else { return nil } let id = column.identifier let field: NSTextField if let reused = tableView.makeView(withIdentifier: id, owner: self) as? NSTextField { field = reused } else { field = NSTextField() field.identifier = id field.isBordered = false field.drawsBackground = false field.isEditable = true field.isSelectable = true field.font = NSFont.systemFont(ofSize: 12) field.lineBreakMode = .byTruncatingTail field.delegate = self field.placeholderString = id.rawValue == "from" ? "heard word" : "replacement (\\n = newline)" } field.stringValue = replacements[row][id.rawValue] ?? "" return field } // Commit an edited cell back into the model (fires on Enter, Tab, focus loss). func controlTextDidEndEditing(_ obj: Notification) { guard let field = obj.object as? NSTextField, let table = dictTable else { return } let row = table.row(for: field), col = table.column(for: field) guard row >= 0, row < replacements.count, col >= 0, col < table.tableColumns.count else { return } let id = table.tableColumns[col].identifier.rawValue var rows = replacements rows[row][id] = field.stringValue replacements = rows } @objc func addReplacement() { pruneEmptyReplacements() var rows = replacements rows.append(["from": "", "to": ""]) replacements = rows dictTable?.reloadData() dictTable?.editColumn(0, row: rows.count - 1, with: nil, select: true) } @objc func removeReplacement() { guard let table = dictTable, table.selectedRow >= 0, table.selectedRow < replacements.count else { return } var rows = replacements rows.remove(at: table.selectedRow) replacements = rows table.reloadData() } // Drop rows where both columns are blank so empty rows aren't persisted. func pruneEmptyReplacements() { let cleaned = replacements.filter { !(($0["from"] ?? "").trimmingCharacters(in: .whitespaces).isEmpty && ($0["to"] ?? "").trimmingCharacters(in: .whitespaces).isEmpty) } if cleaned.count != replacements.count { replacements = cleaned } } func windowWillClose(_ notification: Notification) { pruneEmptyReplacements() } // MARK: - Launch at Login func isLaunchAtLoginEnabled() -> Bool { if #available(macOS 13.0, *) { return SMAppService.mainApp.status == .enabled } return false } func setLaunchAtLogin(_ enabled: Bool) { if #available(macOS 13.0, *) { do { if enabled { try SMAppService.mainApp.register() } else { try SMAppService.mainApp.unregister() } } catch { NSLog("Failed to set launch at login: \(error)") } } } // MARK: - Model Validation func isValidModelPath(_ path: String) -> (valid: Bool, error: String?) { // Check extension if !path.lowercased().hasSuffix(".bin") { return (false, "Model must be a .bin file") } // Check for path traversal attempts let normalized = (path as NSString).standardizingPath if normalized.contains("..") { return (false, "Invalid path") } // Check file exists if !FileManager.default.fileExists(atPath: normalized) { return (false, "Model not found") } return (true, nil) } func checkModelExists() { let validation = isValidModelPath(modelPath) if !validation.valid { updateStatus("⚠️ \(validation.error ?? "Invalid model")") } else { updateStatus("Ready") } } func updateStatus(_ status: String) { if let menu = statusItem.menu { for item in menu.items { if item.tag == 100 { item.title = "Status: \(status)" } } } } // MARK: - Permissions func requestMicrophonePermission() { AVCaptureDevice.requestAccess(for: .audio) { granted in if !granted { DispatchQueue.main.async { self.showPermissionAlert( title: "Microphone Access Required", message: "WhisperDictate needs microphone access to record your voice.", settingsURL: "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone" ) } } } } func checkAccessibilityPermission() { let trusted = AXIsProcessTrusted() if !trusted { showPermissionAlert( title: "Accessibility Access Required", message: "WhisperDictate needs accessibility access to paste transcribed text into other apps.", settingsURL: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" ) } } func showPermissionAlert(title: String, message: String, settingsURL: String) { let alert = NSAlert() alert.messageText = title alert.informativeText = message alert.alertStyle = .warning alert.addButton(withTitle: "Open Settings") alert.addButton(withTitle: "Later") if alert.runModal() == .alertFirstButtonReturn { if let url = URL(string: settingsURL) { NSWorkspace.shared.open(url) } } } // MARK: - Hotkey Registration func registerHotkey() { var hotKeyRef: EventHotKeyRef? var gMyHotKeyID = EventHotKeyID() gMyHotKeyID.signature = OSType(0x57485044) // "WHPD" gMyHotKeyID.id = 1 let modifiers: UInt32 = UInt32(controlKey | optionKey) let keyCode: UInt32 = 2 // D key RegisterEventHotKey(keyCode, modifiers, gMyHotKeyID, GetApplicationEventTarget(), 0, &hotKeyRef) var eventType = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)) InstallEventHandler(GetApplicationEventTarget(), { (_, event, _) -> OSStatus in let appDelegate = NSApplication.shared.delegate as! AppDelegate DispatchQueue.main.async { appDelegate.toggleRecording() } return noErr }, 1, &eventType, nil, nil) } // MARK: - Recording @objc func toggleRecording() { if isRecording { stopRecordingAndTranscribe() } else { startRecording() } } func startRecording() { let audioURL = URL(fileURLWithPath: audioFilePath) try? FileManager.default.removeItem(at: audioURL) let settings: [String: Any] = [ AVFormatIDKey: Int(kAudioFormatLinearPCM), AVSampleRateKey: 16000, AVNumberOfChannelsKey: 1, AVLinearPCMBitDepthKey: 16, AVLinearPCMIsFloatKey: false, AVLinearPCMIsBigEndianKey: false ] do { audioRecorder = try AVAudioRecorder(url: audioURL, settings: settings) audioRecorder?.isMeteringEnabled = true audioRecorder?.record() isRecording = true statusItem.button?.title = "🔴" updateStatus("Recording...") if playSounds { NSSound(named: "Tink")?.play() } NSLog("Recording started") startMeter() prepareModelForDictation() startLivePreview() } catch { NSLog("Recording failed: \(error)") if playSounds { NSSound(named: "Basso")?.play() } } } // MARK: - Live mic meter (HUD) func startMeter() { silenceStart = nil recordingHUD.show() meterTimer?.invalidate() meterTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in self?.updateMeter() } } func stopMeter(hideHUD: Bool = true) { meterTimer?.invalidate() meterTimer = nil silenceStart = nil if hideHUD { recordingHUD.hide() } } func updateMeter() { guard isRecording, let recorder = audioRecorder else { return } recorder.updateMeters() let avg = recorder.averagePower(forChannel: 0) // dBFS, ~-160...0 let peak = recorder.peakPower(forChannel: 0) let level = max(0, min(1, CGFloat((avg + 50) / 50))) // Flag "no input" if the peak stays at silence for over ~1.2s. if peak < -50 { if silenceStart == nil { silenceStart = Date() } } else { silenceStart = nil } let silent = silenceStart.map { Date().timeIntervalSince($0) > 1.2 } ?? false recordingHUD.update(level: level, silent: silent) checkModelLoaded() } // MARK: - Live preview func startLivePreview() { guard livePreviewEnabled else { return } livePreview.onText = { [weak self] text in guard let self = self, self.isRecording else { return } self.recordingHUD.setPreview(self.applyReplacements(text)) } livePreview.start(languageCode: language, contextualStrings: dictionaryTerms()) } // MARK: - Model warm-up // Start getting the model into memory as soon as recording starts, so loading // overlaps with the user talking instead of following it. Only if that turns // out to be slow does the HUD explain why this transcription takes longer. func prepareModelForDictation() { dictationGeneration += 1 modelLoading = false loadNoticeShown = false if let server = whisperServer, server.isReady { guard server.needsWarmUp else { return } // used recently: still warm modelLoading = true server.warmUp(language: language) } else if let server = whisperServer, server.isStarting { modelLoading = true // already loading (app launch / model switch) } else if findWhisperServer() != nil, isValidModelPath(modelPath).valid { modelLoading = true // server crashed or failed earlier: load it now startWhisperServer() } else { return // whisper-cli only } let generation = dictationGeneration DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { [weak self] in guard let self = self, self.dictationGeneration == generation else { return } self.checkModelLoaded() // may have finished since the last meter tick guard self.modelLoading, !self.loadNoticeShown else { return } if self.isRecording { self.loadNoticeShown = true self.recordingHUD.setNotice(.loadingModel) } else if self.isTranscribing { self.loadNoticeShown = true self.recordingHUD.showTranscribing(notice: .loadingModel) } } } // Polled from the meter timer while recording func checkModelLoaded() { guard modelLoading, let server = whisperServer, server.isReady, !server.isWarmingUp else { return } modelLoading = false if loadNoticeShown { recordingHUD.setNotice(.modelLoaded) } } func transcriptionFinished() { isTranscribing = false guard !isRecording else { return } // a new dictation has already started modelLoading = false loadNoticeShown = false recordingHUD.hide() } func stopRecordingAndTranscribe() { audioRecorder?.stop() isRecording = false isTranscribing = true livePreview.stop() // Keep the HUD up while Whisper works if there is something to show: // the live preview text, or why this transcription is slower let explainLoading = modelLoading && loadNoticeShown let keepHUD = explainLoading || recordingHUD.hasPreview stopMeter(hideHUD: !keepHUD) if keepHUD { recordingHUD.showTranscribing(notice: explainLoading ? .loadingModel : nil) } statusItem.button?.title = "⏳" updateStatus("Transcribing...") if playSounds { NSSound(named: "Pop")?.play() } NSLog("Recording stopped, transcribing...") DispatchQueue.global(qos: .userInitiated).async { self.transcribe() DispatchQueue.main.async { self.transcriptionFinished() } } } // MARK: - Whisper CLI Detection func findWhisperCLI() -> String? { // First check for bundled whisper-cli if let bundlePath = Bundle.main.executablePath { let bundledCLI = (bundlePath as NSString).deletingLastPathComponent + "/whisper-cli" if FileManager.default.isExecutableFile(atPath: bundledCLI) { return bundledCLI } } // Fall back to system paths let paths = [ "/opt/homebrew/bin/whisper-cli", // ARM Mac (M1/M2/M3) "/usr/local/bin/whisper-cli", // Intel Mac "/opt/local/bin/whisper-cli", // MacPorts NSHomeDirectory() + "/bin/whisper-cli" // User local ] for path in paths { if FileManager.default.isExecutableFile(atPath: path) { return path } } return nil } func findWhisperServer() -> String? { if let bundlePath = Bundle.main.executablePath { let bundled = (bundlePath as NSString).deletingLastPathComponent + "/whisper-server" if FileManager.default.isExecutableFile(atPath: bundled) { return bundled } } let paths = [ "/opt/homebrew/bin/whisper-server", "/usr/local/bin/whisper-server", "/opt/local/bin/whisper-server", NSHomeDirectory() + "/bin/whisper-server" ] for path in paths where FileManager.default.isExecutableFile(atPath: path) { return path } return nil } // (Re)start the warm whisper-server for the current model. Falls back to // per-call whisper-cli if the server binary is missing or fails to come up. // `completion` runs on the main thread once loading is over, whatever the outcome. func startWhisperServer(completion: (() -> Void)? = nil) { whisperServer?.stop() whisperServer = nil guard let serverPath = findWhisperServer() else { NSLog("whisper-server not found — using per-call whisper-cli") completion?() return } guard isValidModelPath(modelPath).valid else { completion?() return } if !isRecording && !isTranscribing { statusItem.button?.title = "⏳" updateStatus("Loading model…") } let threads = min(8, max(2, ProcessInfo.processInfo.activeProcessorCount - 2)) let server = WhisperServer(serverPath: serverPath, modelPath: modelPath, threads: threads) whisperServer = server server.start { [weak self] ok in DispatchQueue.main.async { defer { completion?() } guard let self = self, self.whisperServer === server else { return } let busy = self.isRecording || self.isTranscribing if !busy { self.statusItem.button?.title = self.idleIcon } if ok { if !busy { self.updateStatus("Ready") } NSLog("whisper-server ready on port \(server.port)") } else { if !busy { self.updateStatus("Ready (CLI fallback)") } self.whisperServer = nil NSLog("whisper-server failed to start — falling back to whisper-cli") } } } } // MARK: - Transcription func transcribe() { let modelValidation = isValidModelPath(modelPath) guard modelValidation.valid else { DispatchQueue.main.async { self.statusItem.button?.title = self.idleIcon self.updateStatus("⚠️ \(modelValidation.error ?? "Invalid model")") if self.playSounds { NSSound(named: "Basso")?.play() } } return } guard SupportedLanguages.isValid(language) else { DispatchQueue.main.async { self.statusItem.button?.title = self.idleIcon self.updateStatus("⚠️ Invalid language") if self.playSounds { NSSound(named: "Basso")?.play() } } return } // Prefer the warm server (~0.5s); fall back to a per-call whisper-cli spawn. let prompt = customPrompt() let result: String? if let server = whisperServer, server.isReady { result = server.transcribe(wavPath: audioFilePath, language: language, prompt: prompt) } else if let server = whisperServer, server.waitUntilReady(timeout: 120) { // Loading was already under way (launch, model switch, recording start): // wait for it rather than loading the model a second time in whisper-cli result = server.transcribe(wavPath: audioFilePath, language: language, prompt: prompt) } else { result = transcribeWithCLI(prompt: prompt) } self.cleanupAudioFile() let finalText = result.map { applyReplacements($0) } DispatchQueue.main.async { if let finalText = finalText, !finalText.isEmpty { self.pasteText(finalText) } else { self.statusItem.button?.title = self.idleIcon self.updateStatus("Ready") if self.playSounds { NSSound(named: "Basso")?.play() } NSLog("No speech recognized") } } } // Per-call transcription via whisper-cli (reloads the model every time). // Fallback for when the warm server is unavailable. func transcribeWithCLI(prompt: String? = nil) -> String? { guard let whisperPath = findWhisperCLI() else { DispatchQueue.main.async { self.statusItem.button?.title = self.idleIcon self.updateStatus("⚠️ whisper-cli not found") if self.playSounds { NSSound(named: "Basso")?.play() } NSLog("whisper-cli not found in PATH") } return nil } let task = Process() task.executableURL = URL(fileURLWithPath: whisperPath) var args = ["-m", modelPath] if language.lowercased() != SupportedLanguages.autoDetect { args += ["-l", language.lowercased()] } if let prompt = prompt, !prompt.isEmpty { args += ["--prompt", prompt] } args += ["-f", audioFilePath] task.arguments = args let pipe = Pipe() task.standardOutput = pipe task.standardError = FileHandle.nullDevice do { try task.run() task.waitUntilExit() let data = pipe.fileHandleForReading.readDataToEndOfFile() let output = String(data: data, encoding: .utf8) ?? "" var result = "" for line in output.components(separatedBy: "\n") where line.hasPrefix("[") { if let range = line.range(of: "]") { result += String(line[range.upperBound...]).trimmingCharacters(in: .whitespaces) + " " } } return result.trimmingCharacters(in: .whitespaces) } catch { NSLog("Transcription failed: \(error)") return nil } } // MARK: - Paste func pasteText(_ text: String) { let pasteboard = NSPasteboard.general // Save current clipboard contents let savedItems = saveClipboard() // Set transcript to clipboard pasteboard.clearContents() pasteboard.setString(text, forType: .string) NSLog("Transcribed: \(text)") DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { // Simulate Cmd+V let source = CGEventSource(stateID: .hidSystemState) let keyDown = CGEvent(keyboardEventSource: source, virtualKey: 0x09, keyDown: true) keyDown?.flags = .maskCommand keyDown?.post(tap: .cghidEventTap) let keyUp = CGEvent(keyboardEventSource: source, virtualKey: 0x09, keyDown: false) keyUp?.flags = .maskCommand keyUp?.post(tap: .cghidEventTap) // Restore original clipboard after a short delay // If clipboard was empty, clear it (don't leave transcript) // If clipboard had content, restore it DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { if savedItems.isEmpty { NSPasteboard.general.clearContents() } else { self.restoreClipboard(savedItems) } } self.statusItem.button?.title = self.idleIcon self.updateStatus("Ready") if self.playSounds { NSSound(named: "Glass")?.play() } } } func saveClipboard() -> [[NSPasteboard.PasteboardType: Data]] { let pasteboard = NSPasteboard.general var savedItems: [[NSPasteboard.PasteboardType: Data]] = [] for item in pasteboard.pasteboardItems ?? [] { var itemData: [NSPasteboard.PasteboardType: Data] = [:] for type in item.types { if let data = item.data(forType: type) { itemData[type] = data } } if !itemData.isEmpty { savedItems.append(itemData) } } return savedItems } func restoreClipboard(_ savedItems: [[NSPasteboard.PasteboardType: Data]]) { guard !savedItems.isEmpty else { return } let pasteboard = NSPasteboard.general pasteboard.clearContents() for itemData in savedItems { let item = NSPasteboardItem() for (type, data) in itemData { item.setData(data, forType: type) } pasteboard.writeObjects([item]) } } } // MARK: - Main let app = NSApplication.shared let delegate = AppDelegate() app.delegate = delegate app.setActivationPolicy(.accessory) app.run()