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>
1252 sor
46 KiB
Swift
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()
|