Files
WhisperDictate/macos/src/main.swift
hariel1985 fdf8bf9743 Use English for all UI strings (HUD + dictionary)
The new HUD label and the dictionary table labels/placeholders were in
Hungarian; the rest of the app is English. Translated: "No audio input?",
"Recording…", "Dictionary (heard → replacement):", column titles
"Heard"/"Replacement", and the field placeholders/hint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 09:26:35 +02:00

1391 sor
52 KiB
Swift
Nyers Blame Előzmények

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"
static let replacements = "customReplacements"
}
// 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 ? "🎤 No audio input?" : "🎤 Recording…"
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, NSTableViewDataSource, NSTableViewDelegate, NSTextFieldDelegate, NSWindowDelegate {
var statusItem: NSStatusItem!
var audioRecorder: AVAudioRecorder?
var isRecording = false
var settingsWindowController: NSWindowController?
var whisperServer: WhisperServer?
let recordingHUD = RecordingHUD()
var meterTimer: Timer?
var silenceStart: Date?
weak var dictTable: NSTableView?
// 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) }
}
// Custom dictionary: ordered list of ["from": recognised, "to": replacement].
var replacements: [[String: String]] {
get { (UserDefaults.standard.array(forKey: Defaults.replacements) as? [[String: String]]) ?? [] }
set { UserDefaults.standard.set(newValue, forKey: Defaults.replacements) }
}
// Whisper initial prompt built from the desired ("to") terms, to bias
// recognition toward the user's names/jargon. Returns nil if empty.
func customPrompt() -> String? {
let terms = replacements.compactMap { $0["to"] }
.map { $0.replacingOccurrences(of: "\\n", with: " ") }
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
return terms.isEmpty ? nil : terms.joined(separator: ", ")
}
// Apply the dictionary find/replace pairs to a transcript. "\n" in a
// replacement becomes a real newline (e.g. spoken-command line break).
func applyReplacements(_ text: String) -> String {
var result = text
for pair in replacements {
guard let from = pair["from"], !from.isEmpty else { continue }
let to = (pair["to"] ?? "").replacingOccurrences(of: "\\n", with: "\n")
result = result.replacingOccurrences(of: from, with: to, options: [.caseInsensitive])
}
return result
}
func applicationDidFinishLaunching(_ notification: Notification) {
setupStatusItem()
registerHotkey()
requestMicrophonePermission()
// Start warming the model server BEFORE the (blocking) accessibility
// prompt, so the multi-second model load overlaps with the user
// granting permissions instead of running after it.
if !hasAnyModel() {
showFirstRunWizard()
} else {
checkModelExists()
startWhisperServer()
}
checkAccessibilityPermission()
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: 540),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
window.title = "WhisperDictate Settings"
window.center()
window.delegate = self
let contentView = NSView(frame: window.contentView!.bounds)
var y: CGFloat = 498
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)
// Custom dictionary (recognised -> replacement)
let dictLabel = NSTextField(labelWithString: "Dictionary (heard → replacement):")
dictLabel.frame = NSRect(x: 20, y: 304, width: 300, height: 20)
contentView.addSubview(dictLabel)
let scroll = NSScrollView(frame: NSRect(x: 20, y: 104, width: 410, height: 192))
scroll.hasVerticalScroller = true
scroll.borderType = .bezelBorder
let table = NSTableView(frame: scroll.bounds)
table.usesAlternatingRowBackgroundColors = true
table.rowHeight = 22
let colFrom = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("from"))
colFrom.title = "Heard"; colFrom.width = 195; colFrom.isEditable = true
let colTo = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("to"))
colTo.title = "Replacement"; colTo.width = 195
table.addTableColumn(colFrom)
table.addTableColumn(colTo)
table.dataSource = self
table.delegate = self
scroll.documentView = table
contentView.addSubview(scroll)
dictTable = table
let addBtn = NSButton(title: "+", target: self, action: #selector(addReplacement))
addBtn.frame = NSRect(x: 20, y: 70, width: 34, height: 26)
addBtn.bezelStyle = .rounded
contentView.addSubview(addBtn)
let delBtn = NSButton(title: "", target: self, action: #selector(removeReplacement))
delBtn.frame = NSRect(x: 56, y: 70, width: 34, height: 26)
delBtn.bezelStyle = .rounded
contentView.addSubview(delBtn)
let dictHint = NSTextField(labelWithString: "The Replacement column also improves recognition. \\n = newline.")
dictHint.frame = NSRect(x: 100, y: 74, width: 330, height: 18)
dictHint.font = NSFont.systemFont(ofSize: 10)
dictHint.textColor = .tertiaryLabelColor
contentView.addSubview(dictHint)
// Models directory hint
let hintLabel = NSTextField(labelWithString: "Models stored in: ~/.whisper-models/")
hintLabel.frame = NSRect(x: 20, y: 15, width: 410, height: 20)
hintLabel.font = NSFont.systemFont(ofSize: 10)
hintLabel.textColor = .tertiaryLabelColor
contentView.addSubview(hintLabel)
window.contentView = contentView
return window
}
@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: - Custom dictionary table (view-based, editable text fields)
func numberOfRows(in tableView: NSTableView) -> Int { replacements.count }
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
guard let column = tableColumn, row < replacements.count else { return nil }
let id = column.identifier
let field: NSTextField
if let reused = tableView.makeView(withIdentifier: id, owner: self) as? NSTextField {
field = reused
} else {
field = NSTextField()
field.identifier = id
field.isBordered = false
field.drawsBackground = false
field.isEditable = true
field.isSelectable = true
field.font = NSFont.systemFont(ofSize: 12)
field.lineBreakMode = .byTruncatingTail
field.delegate = self
field.placeholderString = id.rawValue == "from" ? "heard word" : "replacement (\\n = newline)"
}
field.stringValue = replacements[row][id.rawValue] ?? ""
return field
}
// Commit an edited cell back into the model (fires on Enter, Tab, focus loss).
func controlTextDidEndEditing(_ obj: Notification) {
guard let field = obj.object as? NSTextField, let table = dictTable else { return }
let row = table.row(for: field), col = table.column(for: field)
guard row >= 0, row < replacements.count, col >= 0, col < table.tableColumns.count else { return }
let id = table.tableColumns[col].identifier.rawValue
var rows = replacements
rows[row][id] = field.stringValue
replacements = rows
}
@objc func addReplacement() {
pruneEmptyReplacements()
var rows = replacements
rows.append(["from": "", "to": ""])
replacements = rows
dictTable?.reloadData()
dictTable?.editColumn(0, row: rows.count - 1, with: nil, select: true)
}
@objc func removeReplacement() {
guard let table = dictTable, table.selectedRow >= 0, table.selectedRow < replacements.count else { return }
var rows = replacements
rows.remove(at: table.selectedRow)
replacements = rows
table.reloadData()
}
// Drop rows where both columns are blank so empty rows aren't persisted.
func pruneEmptyReplacements() {
let cleaned = replacements.filter {
!(($0["from"] ?? "").trimmingCharacters(in: .whitespaces).isEmpty
&& ($0["to"] ?? "").trimmingCharacters(in: .whitespaces).isEmpty)
}
if cleaned.count != replacements.count { replacements = cleaned }
}
func windowWillClose(_ notification: Notification) {
pruneEmptyReplacements()
}
// MARK: - Launch at Login
func isLaunchAtLoginEnabled() -> Bool {
if #available(macOS 13.0, *) {
return SMAppService.mainApp.status == .enabled
}
return false
}
func setLaunchAtLogin(_ enabled: Bool) {
if #available(macOS 13.0, *) {
do {
if enabled {
try SMAppService.mainApp.register()
} else {
try SMAppService.mainApp.unregister()
}
} catch {
NSLog("Failed to set launch at login: \(error)")
}
}
}
// MARK: - Model Validation
func isValidModelPath(_ path: String) -> (valid: Bool, error: String?) {
// Check extension
if !path.lowercased().hasSuffix(".bin") {
return (false, "Model must be a .bin file")
}
// Check for path traversal attempts
let normalized = (path as NSString).standardizingPath
if normalized.contains("..") {
return (false, "Invalid path")
}
// Check file exists
if !FileManager.default.fileExists(atPath: normalized) {
return (false, "Model not found")
}
return (true, nil)
}
func checkModelExists() {
let validation = isValidModelPath(modelPath)
if !validation.valid {
updateStatus("⚠️ \(validation.error ?? "Invalid model")")
} else {
updateStatus("Ready")
}
}
func updateStatus(_ status: String) {
if let menu = statusItem.menu {
for item in menu.items {
if item.tag == 100 {
item.title = "Status: \(status)"
}
}
}
}
// MARK: - Permissions
func requestMicrophonePermission() {
AVCaptureDevice.requestAccess(for: .audio) { granted in
if !granted {
DispatchQueue.main.async {
self.showPermissionAlert(
title: "Microphone Access Required",
message: "WhisperDictate needs microphone access to record your voice.",
settingsURL: "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone"
)
}
}
}
}
func checkAccessibilityPermission() {
let trusted = AXIsProcessTrusted()
if !trusted {
showPermissionAlert(
title: "Accessibility Access Required",
message: "WhisperDictate needs accessibility access to paste transcribed text into other apps.",
settingsURL: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"
)
}
}
func showPermissionAlert(title: String, message: String, settingsURL: String) {
let alert = NSAlert()
alert.messageText = title
alert.informativeText = message
alert.alertStyle = .warning
alert.addButton(withTitle: "Open Settings")
alert.addButton(withTitle: "Later")
if alert.runModal() == .alertFirstButtonReturn {
if let url = URL(string: settingsURL) {
NSWorkspace.shared.open(url)
}
}
}
// MARK: - Hotkey Registration
func registerHotkey() {
var hotKeyRef: EventHotKeyRef?
var gMyHotKeyID = EventHotKeyID()
gMyHotKeyID.signature = OSType(0x57485044) // "WHPD"
gMyHotKeyID.id = 1
let modifiers: UInt32 = UInt32(controlKey | optionKey)
let keyCode: UInt32 = 2 // D key
RegisterEventHotKey(keyCode, modifiers, gMyHotKeyID, GetApplicationEventTarget(), 0, &hotKeyRef)
var eventType = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))
InstallEventHandler(GetApplicationEventTarget(), { (_, event, _) -> OSStatus in
let appDelegate = NSApplication.shared.delegate as! AppDelegate
DispatchQueue.main.async {
appDelegate.toggleRecording()
}
return noErr
}, 1, &eventType, nil, nil)
}
// MARK: - Recording
@objc func toggleRecording() {
if isRecording {
stopRecordingAndTranscribe()
} else {
startRecording()
}
}
func startRecording() {
let audioURL = URL(fileURLWithPath: audioFilePath)
try? FileManager.default.removeItem(at: audioURL)
let settings: [String: Any] = [
AVFormatIDKey: Int(kAudioFormatLinearPCM),
AVSampleRateKey: 16000,
AVNumberOfChannelsKey: 1,
AVLinearPCMBitDepthKey: 16,
AVLinearPCMIsFloatKey: false,
AVLinearPCMIsBigEndianKey: false
]
do {
audioRecorder = try AVAudioRecorder(url: audioURL, settings: settings)
audioRecorder?.isMeteringEnabled = true
audioRecorder?.record()
isRecording = true
statusItem.button?.title = "🔴"
updateStatus("Recording...")
if playSounds { NSSound(named: "Tink")?.play() }
NSLog("Recording started")
startMeter()
} 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 prompt = customPrompt()
let result: String?
if let server = whisperServer, server.isReady {
result = server.transcribe(wavPath: audioFilePath, language: language, prompt: prompt)
} else {
result = transcribeWithCLI(prompt: prompt)
}
self.cleanupAudioFile()
let finalText = result.map { applyReplacements($0) }
DispatchQueue.main.async {
if let finalText = finalText, !finalText.isEmpty {
self.pasteText(finalText)
} else {
self.statusItem.button?.title = "🎤"
self.updateStatus("Ready")
if self.playSounds { NSSound(named: "Basso")?.play() }
NSLog("No speech recognized")
}
}
}
// Per-call transcription via whisper-cli (reloads the model every time).
// Fallback for when the warm server is unavailable.
func transcribeWithCLI(prompt: String? = nil) -> String? {
guard let whisperPath = findWhisperCLI() else {
DispatchQueue.main.async {
self.statusItem.button?.title = "🎤"
self.updateStatus("⚠️ whisper-cli not found")
if self.playSounds { NSSound(named: "Basso")?.play() }
NSLog("whisper-cli not found in PATH")
}
return nil
}
let task = Process()
task.executableURL = URL(fileURLWithPath: whisperPath)
var args = ["-m", modelPath]
if language.lowercased() != SupportedLanguages.autoDetect {
args += ["-l", language.lowercased()]
}
if let prompt = prompt, !prompt.isEmpty { args += ["--prompt", prompt] }
args += ["-f", audioFilePath]
task.arguments = args
let pipe = Pipe()
task.standardOutput = pipe
task.standardError = FileHandle.nullDevice
do {
try task.run()
task.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8) ?? ""
var result = ""
for line in output.components(separatedBy: "\n") where line.hasPrefix("[") {
if let range = line.range(of: "]") {
result += String(line[range.upperBound...]).trimmingCharacters(in: .whitespaces) + " "
}
}
return result.trimmingCharacters(in: .whitespaces)
} catch {
NSLog("Transcription failed: \(error)")
return nil
}
}
// MARK: - Paste
func pasteText(_ text: String) {
let pasteboard = NSPasteboard.general
// Save current clipboard contents
let savedItems = saveClipboard()
// Set transcript to clipboard
pasteboard.clearContents()
pasteboard.setString(text, forType: .string)
NSLog("Transcribed: \(text)")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
// Simulate Cmd+V
let source = CGEventSource(stateID: .hidSystemState)
let keyDown = CGEvent(keyboardEventSource: source, virtualKey: 0x09, keyDown: true)
keyDown?.flags = .maskCommand
keyDown?.post(tap: .cghidEventTap)
let keyUp = CGEvent(keyboardEventSource: source, virtualKey: 0x09, keyDown: false)
keyUp?.flags = .maskCommand
keyUp?.post(tap: .cghidEventTap)
// Restore original clipboard after a short delay
// If clipboard was empty, clear it (don't leave transcript)
// If clipboard had content, restore it
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
if savedItems.isEmpty {
NSPasteboard.general.clearContents()
} else {
self.restoreClipboard(savedItems)
}
}
self.statusItem.button?.title = "🎤"
self.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()