diff --git a/macos/src/main.swift b/macos/src/main.swift index 6482826..d11ac35 100644 --- a/macos/src/main.swift +++ b/macos/src/main.swift @@ -37,12 +37,42 @@ struct WhisperModels { 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", @@ -90,9 +120,14 @@ struct WhisperModels { } } - // Get friendly name - var name = file.replacingOccurrences(of: "ggml-", with: "").replacingOccurrences(of: ".bin", with: "") - name = name.capitalized + // 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)) } @@ -165,6 +200,29 @@ final class WhisperServer { 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 @@ -201,6 +259,7 @@ final class WhisperServer { 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 @@ -219,11 +278,51 @@ final class WhisperServer { 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 isReady, - let url = URL(string: "http://127.0.0.1:\(port)/inference"), - let wavData = try? Data(contentsOf: URL(fileURLWithPath: wavPath)) else { return nil } + 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) @@ -313,27 +412,68 @@ final class WhisperServer { // diagnostic: if the input stays silent it flags "no input" — exactly the // failure that silently produced empty recordings + whisper hallucinations. 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 } } + + // Height for the current mode + notice + var preferredHeight: CGFloat { + let base: CGFloat = mode == .recording ? 72 : 44 + switch notice { + case .loadingModel?: return base + 34 + case .modelLoaded?: return base + 20 + case nil: return base + } + } func set(level: CGFloat, silent: Bool) { self.level = max(0, min(1, level)) self.silent = silent needsDisplay = true } - func reset() { level = 0; silent = false; needsDisplay = true } + func reset() { level = 0; silent = false; mode = .recording; notice = 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 label = silent ? "🎤 No audio input?" : "🎤 Recording…" - label.draw(at: NSPoint(x: 16, y: bounds.height - 26), withAttributes: [ + 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: silent ? NSColor.systemRed : NSColor.white + .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 + } + + 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) @@ -356,11 +496,44 @@ final class MeterView: NSView { final class RecordingHUD { private var window: NSWindow? - private let meter = MeterView(frame: NSRect(x: 0, y: 0, width: 260, height: 72)) + private let meter = MeterView(frame: NSRect(x: 0, y: 0, width: 320, 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() + } + + // 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: 260, height: 72), + let w = NSPanel(contentRect: NSRect(x: 0, y: 0, width: 320, height: 72), styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false) w.isOpaque = false @@ -373,16 +546,231 @@ final class RecordingHUD { w.contentView = meter window = w } - if let screen = NSScreen.main, let window = window { - let vf = screen.visibleFrame - window.setFrameOrigin(NSPoint(x: vf.midX - window.frame.width / 2, y: vf.minY + 120)) - } - meter.reset() - window?.orderFrontRegardless() + } +} + +// 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 update(level: CGFloat, silent: Bool) { meter.set(level: level, silent: silent) } 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 @@ -390,12 +778,20 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate, 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() 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 { @@ -473,36 +869,29 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate, // MARK: - First Run Wizard func showFirstRunWizard() { - // Build description text - var infoText = "To get started, download a Whisper speech recognition model:\n\n" - for model in WhisperModels.available { - infoText += "• \(model.name) (\(model.size))\n" - infoText += " ✓ \(model.pros)\n" - infoText += " ✗ \(model.cons)\n\n" - } - let alert = NSAlert() alert.messageText = "Welcome to WhisperDictate!" - alert.informativeText = infoText + 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 - // Add model options as buttons - for model in WhisperModels.available.reversed() { - alert.addButton(withTitle: "\(model.name) (\(model.size))") + // 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) - let response = alert.runModal() - - // Map response to model index (buttons are reversed) - let modelCount = WhisperModels.available.count - let buttonIndex = response.rawValue - 1000 // NSAlertFirstButtonReturn = 1000 - - if buttonIndex < modelCount { - let modelIndex = modelCount - 1 - buttonIndex - let selectedModel = WhisperModels.available[modelIndex] - downloadModel(selectedModel) + if alert.runModal() == .alertFirstButtonReturn { + downloadModel(WhisperModels.available[popup.indexOfSelectedItem]) } else { updateStatus("⚠️ No model selected") } @@ -512,74 +901,146 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate, var downloadSession: URLSession? var currentDownloadModel: WhisperModels.Model? var currentDownloadDestination: String? + var downloadPercent = 0 + let downloadWindow = DownloadProgressWindow() func downloadModel(_ model: WhisperModels.Model) { - updateStatus("0% Downloading \(model.name)") - statusItem.button?.title = "⬇️" - - // Create models directory - let modelsDir = WhisperModels.modelsDirectory - try? FileManager.default.createDirectory(atPath: modelsDir, withIntermediateDirectories: true) - - let destinationPath = (modelsDir as NSString).appendingPathComponent(model.filename) - - // Remove existing file if any - try? FileManager.default.removeItem(atPath: destinationPath) - + // 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 } - // Store for delegate callbacks - currentDownloadModel = model - currentDownloadDestination = destinationPath + // Create models directory + let modelsDir = WhisperModels.modelsDirectory + try? FileManager.default.createDirectory(atPath: modelsDir, withIntermediateDirectories: true) - let config = URLSessionConfiguration.default - downloadSession = URLSession(configuration: config, delegate: self, delegateQueue: .main) + // 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 let model = currentDownloadModel else { return } - let progress = totalBytesExpectedToWrite > 0 ? Int((Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)) * 100) : 0 - updateStatus("\(progress)% Downloading \(model.name)") + 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 let model = currentDownloadModel, let destinationPath = currentDownloadDestination else { return } - let destinationURL = URL(fileURLWithPath: destinationPath) + guard downloadTask === self.downloadTask, + let model = currentDownloadModel, let destinationPath = currentDownloadDestination else { return } - do { - try FileManager.default.moveItem(at: location, to: destinationURL) - modelPath = destinationPath - statusItem.button?.title = "🎤" - updateStatus("Ready - \(model.name)") - if playSounds { NSSound(named: "Glass")?.play() } - NSLog("Model downloaded: \(model.name)") - startWhisperServer() - } catch { - statusItem.button?.title = "🎤" - updateStatus("⚠️ Save failed") - if playSounds { NSSound(named: "Basso")?.play() } - NSLog("Save failed: \(error)") + // 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 } - currentDownloadModel = nil - currentDownloadDestination = nil + // 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?) { - if let error = error { - statusItem.button?.title = "🎤" - updateStatus("⚠️ Download failed") - if playSounds { NSSound(named: "Basso")?.play() } - NSLog("Download failed: \(error)") - currentDownloadModel = nil - currentDownloadDestination = nil - } + // 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) { @@ -605,6 +1066,12 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate, 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()) @@ -666,40 +1133,25 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate, modelLabel.frame = NSRect(x: 20, y: y, width: labelWidth, height: 24) contentView.addSubview(modelLabel) - let installedModels = WhisperModels.installedModels() + 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 - if installedModels.isEmpty { - 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) - } else { - let modelPopup = NSPopUpButton(frame: NSRect(x: controlX, y: y, width: 200, height: 24), pullsDown: false) - modelPopup.tag = 2 - - for (index, model) in installedModels.enumerated() { - let title = "\(model.name) (\(model.size))" - modelPopup.addItem(withTitle: title) - // Use tag instead of representedObject to avoid memory issues - modelPopup.lastItem?.tag = index - } - - // Select current model - for (index, model) in installedModels.enumerated() { - if model.path == modelPath { - modelPopup.selectItem(at: index) - break - } - } - - modelPopup.target = self - modelPopup.action = #selector(modelSelected(_:)) - contentView.addSubview(modelPopup) - } + 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 @@ -777,6 +1229,21 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate, 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 @@ -806,13 +1273,23 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate, } @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)" @@ -831,9 +1308,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate, @objc func downloadModelAtIndex(_ sender: NSMenuItem) { let index = sender.tag guard index >= 0 && index < WhisperModels.available.count else { return } - let model = WhisperModels.available[index] - NSLog("Starting download of \(model.name)") - downloadModel(model) + downloadModel(WhisperModels.available[index]) } @objc func browseModel() { @@ -1086,6 +1561,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate, if playSounds { NSSound(named: "Tink")?.play() } NSLog("Recording started") startMeter() + prepareModelForDictation() } catch { NSLog("Recording failed: \(error)") if playSounds { NSSound(named: "Basso")?.play() } @@ -1102,11 +1578,11 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate, } } - func stopMeter() { + func stopMeter(hideHUD: Bool = true) { meterTimer?.invalidate() meterTimer = nil silenceStart = nil - recordingHUD.hide() + if hideHUD { recordingHUD.hide() } } func updateMeter() { @@ -1124,12 +1600,69 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate, } let silent = silenceStart.map { Date().timeIntervalSince($0) > 1.2 } ?? false recordingHUD.update(level: level, silent: silent) + checkModelLoaded() + } + + // 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 - stopMeter() + isTranscribing = true + // Model still loading and already announced: keep explaining the wait + let keepHUD = modelLoading && loadNoticeShown + stopMeter(hideHUD: !keepHUD) + if keepHUD { recordingHUD.showTranscribing(notice: .loadingModel) } statusItem.button?.title = "⏳" updateStatus("Transcribing...") if playSounds { NSSound(named: "Pop")?.play() } @@ -1137,6 +1670,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate, DispatchQueue.global(qos: .userInitiated).async { self.transcribe() + DispatchQueue.main.async { self.transcriptionFinished() } } } @@ -1183,31 +1717,40 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate, // (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. - func startWhisperServer() { + // `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 } - guard isValidModelPath(modelPath).valid else { return } - statusItem.button?.title = "⏳" - updateStatus("Loading model…") + 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 } - self.statusItem.button?.title = "🎤" + let busy = self.isRecording || self.isTranscribing + if !busy { self.statusItem.button?.title = self.idleIcon } if ok { - self.updateStatus("Ready") + if !busy { self.updateStatus("Ready") } NSLog("whisper-server ready on port \(server.port)") } else { - self.updateStatus("Ready (CLI fallback)") + if !busy { self.updateStatus("Ready (CLI fallback)") } self.whisperServer = nil NSLog("whisper-server failed to start — falling back to whisper-cli") } @@ -1220,7 +1763,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate, let modelValidation = isValidModelPath(modelPath) guard modelValidation.valid else { DispatchQueue.main.async { - self.statusItem.button?.title = "🎤" + self.statusItem.button?.title = self.idleIcon self.updateStatus("⚠️ \(modelValidation.error ?? "Invalid model")") if self.playSounds { NSSound(named: "Basso")?.play() } } @@ -1229,7 +1772,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate, guard SupportedLanguages.isValid(language) else { DispatchQueue.main.async { - self.statusItem.button?.title = "🎤" + self.statusItem.button?.title = self.idleIcon self.updateStatus("⚠️ Invalid language") if self.playSounds { NSSound(named: "Basso")?.play() } } @@ -1241,6 +1784,10 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate, 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) } @@ -1253,7 +1800,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate, if let finalText = finalText, !finalText.isEmpty { self.pasteText(finalText) } else { - self.statusItem.button?.title = "🎤" + self.statusItem.button?.title = self.idleIcon self.updateStatus("Ready") if self.playSounds { NSSound(named: "Basso")?.play() } NSLog("No speech recognized") @@ -1266,7 +1813,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate, func transcribeWithCLI(prompt: String? = nil) -> String? { guard let whisperPath = findWhisperCLI() else { DispatchQueue.main.async { - self.statusItem.button?.title = "🎤" + 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") @@ -1342,7 +1889,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate, } } - self.statusItem.button?.title = "🎤" + self.statusItem.button?.title = self.idleIcon self.updateStatus("Ready") if self.playSounds { NSSound(named: "Glass")?.play() } }