From 4584d30e02f8e1188835f275c2b5d6690ca41e2f Mon Sep 17 00:00:00 2001 From: hariel1985 Date: Wed, 16 Sep 2026 11:00:02 +0200 Subject: [PATCH] Add quantized models, download progress window and model warm-up Quantized models: add Small q5_1, Medium q5_0, Large v3 Turbo q5_0/q8_0 and Large v3 q5_0 to the catalog. Turbo q5_0 keeps whisper-server at ~0.7 GB instead of ~1.75 GB with the same speed. Installed models show their catalog name; the first-run wizard uses a popup instead of one button per model. Download feedback: a progress window with bar, size, speed, time left, cancel and retry, followed by "loading" and "ready" steps. Progress also shows in the menu bar icon, the status menu and the Settings button, and the Settings model list refreshes when done. Only one download runs at a time, an existing model is replaced only after a successful download, and HTTP errors are reported instead of saving the error page as a model. Model warm-up: start loading the model when recording starts instead of after it stops. After 2+ minutes idle a tiny silent request pages the model back in while the user talks; a dead server is restarted, and a server that is still loading is awaited rather than loading the model a second time via whisper-cli. If loading takes over 2.5 s the recording HUD explains why this transcription is slower. Server start no longer overwrites the recording/transcribing menu bar icon. Co-Authored-By: Claude Opus 5 --- macos/src/main.swift | 813 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 680 insertions(+), 133 deletions(-) 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() } }