Files
WhisperDictate/macos/src/main.swift
hariel1985 4c680f9e4d Add live mic-level HUD with silence warning
Shows a small floating, non-interactive panel while recording with a
segmented VU meter driven by AVAudioRecorder metering (averagePower).
If the input peak stays at silence for >1.2s it turns red and shows
"Nincs hangbemenet?" — surfacing the dead-mic case that previously
produced empty recordings and whisper hallucinations with no feedback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 08:59:33 +02:00

1252 sor
46 KiB
Swift

import Cocoa
import AVFoundation
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"
}
// 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",
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",
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
var 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
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.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
}
// 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 }
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<sockaddr_in>.size))
}
}
guard bound == 0 else { return 8123 }
var len = socklen_t(MemoryLayout<sockaddr_in>.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<sockaddr_in>.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.
final class MeterView: NSView {
private var level: CGFloat = 0
private var silent = false
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 }
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 ? "🎤 Nincs hangbemenet?" : "🎤 Felvétel…"
label.draw(at: NSPoint(x: 16, y: bounds.height - 26), withAttributes: [
.font: NSFont.systemFont(ofSize: 12, weight: .medium),
.foregroundColor: silent ? NSColor.systemRed : NSColor.white
])
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..<segs {
let rect = NSRect(x: margin + CGFloat(i) * (segW + gap), y: y, width: segW, height: segH)
let path = NSBezierPath(roundedRect: rect, xRadius: 2, yRadius: 2)
if i < lit {
let frac = CGFloat(i) / CGFloat(segs)
(silent ? NSColor.systemRed
: frac > 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: 260, height: 72))
func show() {
if window == nil {
let w = NSPanel(contentRect: NSRect(x: 0, y: 0, width: 260, 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
}
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()
}
func update(level: CGFloat, silent: Bool) { meter.set(level: level, silent: silent) }
func hide() { window?.orderOut(nil) }
}
// MARK: - App Delegate
class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
var statusItem: NSStatusItem!
var audioRecorder: AVAudioRecorder?
var isRecording = false
var settingsWindowController: NSWindowController?
var whisperServer: WhisperServer?
let recordingHUD = RecordingHUD()
var meterTimer: Timer?
var silenceStart: Date?
// 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) }
}
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()
NSLog("WhisperDictate started. Press ⌃⌥D to toggle recording.")
}
func hasAnyModel() -> Bool {
let validation = isValidModelPath(modelPath)
return validation.valid
}
// 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.alertStyle = .informational
// Add model options as buttons
for model in WhisperModels.available.reversed() {
alert.addButton(withTitle: "\(model.name) (\(model.size))")
}
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)
} else {
updateStatus("⚠️ No model selected")
}
}
var downloadTask: URLSessionDownloadTask?
var downloadSession: URLSession?
var currentDownloadModel: WhisperModels.Model?
var currentDownloadDestination: String?
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)
guard let url = URL(string: model.url) else {
updateStatus("⚠️ Invalid URL")
return
}
// Store for delegate callbacks
currentDownloadModel = model
currentDownloadDestination = destinationPath
let config = URLSessionConfiguration.default
downloadSession = URLSession(configuration: config, delegate: self, delegateQueue: .main)
downloadTask = downloadSession?.downloadTask(with: url)
downloadTask?.resume()
}
// 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)")
}
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
guard let model = currentDownloadModel, let destinationPath = currentDownloadDestination else { return }
let destinationURL = URL(fileURLWithPath: destinationPath)
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)")
}
currentDownloadModel = nil
currentDownloadDestination = nil
}
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
}
}
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)
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: 280),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
window.title = "WhisperDictate Settings"
window.center()
let contentView = NSView(frame: window.contentView!.bounds)
var y: CGFloat = 230
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 installedModels = WhisperModels.installedModels()
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 downloadBtn = NSButton(title: "Download...", target: self, action: #selector(downloadNewModel))
downloadBtn.frame = NSRect(x: controlX + 210, y: y, width: 80, height: 24)
contentView.addSubview(downloadBtn)
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)
// 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
}
@objc func languageChanged(_ sender: NSPopUpButton) {
if let code = sender.selectedItem?.representedObject as? String {
language = 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() {
// Create simple popup menu for model selection
let menu = NSMenu(title: "Select Model")
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
// 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 }
let model = WhisperModels.available[index]
NSLog("Starting download of \(model.name)")
downloadModel(model)
}
@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 playSoundsChanged(_ sender: NSButton) {
playSounds = sender.state == .on
}
@objc func launchAtLoginChanged(_ sender: NSButton) {
setLaunchAtLogin(sender.state == .on)
}
// 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()
} 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() {
meterTimer?.invalidate()
meterTimer = nil
silenceStart = nil
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)
}
func stopRecordingAndTranscribe() {
audioRecorder?.stop()
isRecording = false
stopMeter()
statusItem.button?.title = ""
updateStatus("Transcribing...")
if playSounds { NSSound(named: "Pop")?.play() }
NSLog("Recording stopped, transcribing...")
DispatchQueue.global(qos: .userInitiated).async {
self.transcribe()
}
}
// 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.
func startWhisperServer() {
whisperServer?.stop()
whisperServer = nil
guard let serverPath = findWhisperServer() else {
NSLog("whisper-server not found — using per-call whisper-cli")
return
}
guard isValidModelPath(modelPath).valid else { return }
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 {
guard let self = self, self.whisperServer === server else { return }
self.statusItem.button?.title = "🎤"
if ok {
self.updateStatus("Ready")
NSLog("whisper-server ready on port \(server.port)")
} else {
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.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.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 result: String?
if let server = whisperServer, server.isReady {
result = server.transcribe(wavPath: audioFilePath, language: language, prompt: nil)
} else {
result = transcribeWithCLI()
}
self.cleanupAudioFile()
DispatchQueue.main.async {
if let result = result, !result.isEmpty {
self.pasteText(result)
} else {
self.statusItem.button?.title = "🎤"
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() -> String? {
guard let whisperPath = findWhisperCLI() else {
DispatchQueue.main.async {
self.statusItem.button?.title = "🎤"
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()]
}
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.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()