AVAudioConverter keeps only channel 1 when downmixing stereo and outputs pure silence for layout-less multi-channel inputs (e.g. a 4-input USB interface such as the UA Volt 476P), so the dictation model heard nothing and the HUD never showed text. Pick the channel that carries the voice (loudest on smoothed energy, switching only on a clear 6 dB margin) and convert it as mono. Also reserve the dictation locale, which the Speech framework warns will become required, and log the mic format. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2236 sor
89 KiB
Swift
2236 sor
89 KiB
Swift
import Cocoa
|
||
@preconcurrency import AVFoundation
|
||
@preconcurrency import Speech
|
||
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"
|
||
static let livePreview = "livePreview"
|
||
}
|
||
|
||
// 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 q5_1",
|
||
filename: "ggml-small-q5_1.bin",
|
||
size: "181 MB",
|
||
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small-q5_1.bin",
|
||
pros: "Small accuracy at under half the download, lower RAM",
|
||
cons: "Slightly less accurate than full Small"),
|
||
Model(name: "Small",
|
||
filename: "ggml-small.bin",
|
||
size: "466 MB",
|
||
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin",
|
||
pros: "Good balance of speed and accuracy",
|
||
cons: "Slower on Intel Macs"),
|
||
Model(name: "Medium q5_0",
|
||
filename: "ggml-medium-q5_0.bin",
|
||
size: "514 MB",
|
||
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium-q5_0.bin",
|
||
pros: "Medium accuracy at a third of the download, lower RAM",
|
||
cons: "Slightly less accurate than full Medium"),
|
||
Model(name: "Large v3 Turbo q5_0",
|
||
filename: "ggml-large-v3-turbo-q5_0.bin",
|
||
size: "547 MB",
|
||
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo-q5_0.bin",
|
||
pros: "Turbo quality at a third of the download, much lower RAM",
|
||
cons: "Small accuracy loss vs. full Turbo"),
|
||
Model(name: "Large v3 Turbo q8_0",
|
||
filename: "ggml-large-v3-turbo-q8_0.bin",
|
||
size: "833 MB",
|
||
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo-q8_0.bin",
|
||
pros: "Near-identical to full Turbo at about half the download",
|
||
cons: "Larger than q5_0"),
|
||
Model(name: "Large v3 q5_0",
|
||
filename: "ggml-large-v3-q5_0.bin",
|
||
size: "1.0 GB",
|
||
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-q5_0.bin",
|
||
pros: "Large v3 accuracy at a third of the size",
|
||
cons: "Slower than Turbo, small accuracy loss vs. full Large v3"),
|
||
Model(name: "Medium",
|
||
filename: "ggml-medium.bin",
|
||
size: "1.5 GB",
|
||
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 (catalog name if known, e.g. "Large v3 Turbo q5_0")
|
||
var name: String
|
||
if let known = available.first(where: { $0.filename == file }) {
|
||
name = known.name.replacingOccurrences(of: " (Recommended)", with: "")
|
||
} else {
|
||
name = file.replacingOccurrences(of: "ggml-", with: "").replacingOccurrences(of: ".bin", with: "")
|
||
name = name.capitalized
|
||
}
|
||
|
||
result.append((path: path, name: name, size: sizeStr))
|
||
}
|
||
|
||
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
|
||
|
||
// Shared with background request threads
|
||
private let lock = NSLock()
|
||
private var lastUsed = Date()
|
||
private var warmingUp = false
|
||
|
||
// Still loading the model (process up, port not answering yet)
|
||
var isStarting: Bool { !isReady && !stopping && (process?.isRunning ?? false) }
|
||
|
||
var isWarmingUp: Bool {
|
||
lock.lock(); defer { lock.unlock() }
|
||
return warmingUp
|
||
}
|
||
|
||
// Idle for a while: macOS may have compressed or swapped the model out
|
||
var needsWarmUp: Bool {
|
||
lock.lock(); defer { lock.unlock() }
|
||
return Date().timeIntervalSince(lastUsed) > 120
|
||
}
|
||
|
||
private func markUsed() {
|
||
lock.lock(); lastUsed = Date(); lock.unlock()
|
||
}
|
||
|
||
init(serverPath: String, modelPath: String, threads: Int) {
|
||
self.serverPath = serverPath
|
||
self.modelPath = modelPath
|
||
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.markUsed()
|
||
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
|
||
}
|
||
|
||
// Block (off the main thread) while the server is still loading its model.
|
||
func waitUntilReady(timeout: TimeInterval) -> Bool {
|
||
let deadline = Date().addingTimeInterval(timeout)
|
||
while isStarting && Date() < deadline { Thread.sleep(forTimeInterval: 0.1) }
|
||
return isReady
|
||
}
|
||
|
||
// Send a tiny silent clip so the model is paged back into memory while the
|
||
// user is still talking; the real request then finds it warm.
|
||
func warmUp(language: String) {
|
||
lock.lock()
|
||
if warmingUp { lock.unlock(); return }
|
||
warmingUp = true
|
||
lock.unlock()
|
||
DispatchQueue.global(qos: .userInitiated).async {
|
||
let start = Date()
|
||
_ = self.request(wavData: WhisperServer.silentWAV, language: language, prompt: nil)
|
||
NSLog("WhisperServer: warm-up took %.2fs", Date().timeIntervalSince(start))
|
||
self.lock.lock(); self.warmingUp = false; self.lock.unlock()
|
||
}
|
||
}
|
||
|
||
// 0.5 s of 16 kHz mono 16-bit silence
|
||
static let silentWAV: Data = {
|
||
let dataSize = UInt32(8000 * 2)
|
||
var d = Data()
|
||
func u32(_ v: UInt32) { withUnsafeBytes(of: v.littleEndian) { d.append(contentsOf: $0) } }
|
||
func u16(_ v: UInt16) { withUnsafeBytes(of: v.littleEndian) { d.append(contentsOf: $0) } }
|
||
d.append(contentsOf: Array("RIFF".utf8)); u32(36 + dataSize)
|
||
d.append(contentsOf: Array("WAVEfmt ".utf8)); u32(16)
|
||
u16(1); u16(1); u32(16000); u32(32000); u16(2); u16(16) // PCM, mono, 16 kHz, 16-bit
|
||
d.append(contentsOf: Array("data".utf8)); u32(dataSize)
|
||
d.append(Data(count: Int(dataSize)))
|
||
return d
|
||
}()
|
||
|
||
// Synchronous transcription via the warm server. Call OFF the main thread.
|
||
func transcribe(wavPath: String, language: String, prompt: String?) -> String? {
|
||
guard let wavData = try? Data(contentsOf: URL(fileURLWithPath: wavPath)) else { return nil }
|
||
return request(wavData: wavData, language: language, prompt: prompt)
|
||
}
|
||
|
||
private func request(wavData: Data, language: String, prompt: String?) -> String? {
|
||
guard isReady, let url = URL(string: "http://127.0.0.1:\(port)/inference") else { return nil }
|
||
defer { markUsed() }
|
||
|
||
let boundary = "----WhisperDictate\(UInt32.random(in: 0 ... .max))"
|
||
var req = URLRequest(url: url)
|
||
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.
|
||
// MARK: - Live Preview (on-device Apple dictation model)
|
||
// Streams a rough live transcript into the recording HUD using the system's
|
||
// on-device dictation model (SpeechAnalyzer + DictationTranscriber, macOS 26).
|
||
// It is only a preview: the pasted text still comes from Whisper. It captures
|
||
// the microphone with its own AVAudioEngine so the AVAudioRecorder path used
|
||
// for Whisper stays untouched; if anything here fails there is simply no preview.
|
||
final class LivePreview {
|
||
// Called on the main thread with the transcript so far
|
||
var onText: ((String) -> Void)?
|
||
|
||
private var session = 0
|
||
private var engine: AVAudioEngine?
|
||
private var analyzer: SpeechAnalyzer?
|
||
private var input: AsyncStream<AnalyzerInput>.Continuation?
|
||
private var resultsTask: Task<Void, Never>?
|
||
|
||
// App language code -> supported dictation locale ("hu" -> hu-HU); nil if unsupported
|
||
static func locale(for languageCode: String) async -> Locale? {
|
||
let requested = languageCode == SupportedLanguages.autoDetect ? Locale.current : Locale(identifier: languageCode)
|
||
return await DictationTranscriber.supportedLocale(equivalentTo: requested)
|
||
}
|
||
|
||
private static func makeTranscriber(locale: Locale) -> DictationTranscriber {
|
||
DictationTranscriber(locale: locale, contentHints: [], transcriptionOptions: [.punctuation],
|
||
reportingOptions: [.volatileResults, .frequentFinalization], attributeOptions: [])
|
||
}
|
||
|
||
// Download the on-device model for this language in the background if it's missing
|
||
static func installModelIfNeeded(languageCode: String) {
|
||
Task {
|
||
guard let locale = await locale(for: languageCode) else { return }
|
||
do {
|
||
if let request = try await AssetInventory.assetInstallationRequest(supporting: [makeTranscriber(locale: locale)]) {
|
||
NSLog("Live preview: installing dictation model for \(locale.identifier)")
|
||
try await request.downloadAndInstall()
|
||
NSLog("Live preview: dictation model installed for \(locale.identifier)")
|
||
}
|
||
} catch {
|
||
NSLog("Live preview: model install failed: \(error)")
|
||
}
|
||
}
|
||
}
|
||
|
||
func start(languageCode: String, contextualStrings: [String]) {
|
||
stop()
|
||
let current = session
|
||
Task { @MainActor in
|
||
do {
|
||
guard let locale = await LivePreview.locale(for: languageCode) else {
|
||
NSLog("Live preview: no on-device dictation model for '\(languageCode)'")
|
||
return
|
||
}
|
||
let transcriber = LivePreview.makeTranscriber(locale: locale)
|
||
// Even when the model is already on disk the app may first have to register it;
|
||
// that takes a fraction of a second. A real download just means no preview this time.
|
||
_ = try? await AssetInventory.reserve(locale: locale)
|
||
if await AssetInventory.status(forModules: [transcriber]) != .installed,
|
||
let request = try await AssetInventory.assetInstallationRequest(supporting: [transcriber]) {
|
||
NSLog("Live preview: preparing dictation model for \(locale.identifier)")
|
||
try await request.downloadAndInstall()
|
||
}
|
||
guard current == self.session,
|
||
let analyzerFormat = await SpeechAnalyzer.bestAvailableAudioFormat(compatibleWith: [transcriber]) else { return }
|
||
|
||
// Start the microphone first; the stream buffers audio until the analyzer is running,
|
||
// so the first words aren't lost while the model spins up.
|
||
let (stream, continuation) = AsyncStream<AnalyzerInput>.makeStream()
|
||
let engine = AVAudioEngine()
|
||
let micFormat = engine.inputNode.outputFormat(forBus: 0)
|
||
guard micFormat.sampleRate > 0, current == self.session,
|
||
let monoFormat = AVAudioFormat(standardFormatWithSampleRate: micFormat.sampleRate, channels: 1),
|
||
let converter = AVAudioConverter(from: monoFormat, to: analyzerFormat) else { return }
|
||
NSLog("Live preview: mic %d ch @ %.0f Hz -> %@", micFormat.channelCount, micFormat.sampleRate, analyzerFormat.description)
|
||
engine.inputNode.installTap(onBus: 0, bufferSize: 4096, format: micFormat,
|
||
block: LivePreview.tapBlock(converter: converter, format: analyzerFormat, continuation: continuation))
|
||
try engine.start()
|
||
self.engine = engine
|
||
self.input = continuation
|
||
|
||
let analyzer = SpeechAnalyzer(modules: [transcriber])
|
||
self.analyzer = analyzer
|
||
if !contextualStrings.isEmpty {
|
||
let context = AnalysisContext()
|
||
context.contextualStrings[.general] = contextualStrings
|
||
try await analyzer.setContext(context)
|
||
}
|
||
self.resultsTask = Task { @MainActor [weak self] in
|
||
// Finalized segments accumulate; the volatile tail is replaced on every update
|
||
var finalized = ""
|
||
do {
|
||
for try await result in transcriber.results {
|
||
let text = String(result.text.characters)
|
||
let shown = LivePreview.join(finalized, text)
|
||
if result.isFinal { finalized = shown }
|
||
guard let self = self, current == self.session else { return }
|
||
self.onText?(shown)
|
||
}
|
||
} catch {
|
||
NSLog("Live preview: results ended: \(error)")
|
||
}
|
||
}
|
||
guard current == self.session else { return } // stop() already tore everything down
|
||
try await analyzer.start(inputSequence: stream)
|
||
} catch {
|
||
NSLog("Live preview failed: \(error)")
|
||
if current == self.session { self.stop() }
|
||
}
|
||
}
|
||
}
|
||
|
||
func stop() {
|
||
session += 1
|
||
if let engine = engine {
|
||
engine.inputNode.removeTap(onBus: 0)
|
||
engine.stop()
|
||
}
|
||
engine = nil
|
||
input?.finish()
|
||
input = nil
|
||
resultsTask?.cancel()
|
||
resultsTask = nil
|
||
if let analyzer = analyzer {
|
||
Task { await analyzer.cancelAndFinishNow() }
|
||
}
|
||
analyzer = nil
|
||
}
|
||
|
||
// Segments don't always carry a separating space
|
||
static func join(_ head: String, _ tail: String) -> String {
|
||
guard let last = head.last, let first = tail.first, !last.isWhitespace, !first.isWhitespace else { return head + tail }
|
||
return head + " " + tail
|
||
}
|
||
|
||
// Runs on the audio thread: take the voice channel as mono, convert it to the
|
||
// analyzer's format. Built in a static context so it carries no actor assumptions.
|
||
private static func tapBlock(converter: AVAudioConverter, format: AVAudioFormat,
|
||
continuation: AsyncStream<AnalyzerInput>.Continuation) -> AVAudioNodeTapBlock {
|
||
let downmixer = VoiceChannelPicker()
|
||
return { micBuffer, _ in
|
||
guard let buffer = downmixer.mono(from: micBuffer) else { return }
|
||
let ratio = format.sampleRate / buffer.format.sampleRate
|
||
let capacity = AVAudioFrameCount(Double(buffer.frameLength) * ratio) + 64
|
||
guard let out = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: capacity) else { return }
|
||
var consumed = false
|
||
var error: NSError?
|
||
converter.convert(to: out, error: &error) { _, status in
|
||
if consumed {
|
||
status.pointee = .noDataNow
|
||
return nil
|
||
}
|
||
consumed = true
|
||
status.pointee = .haveData
|
||
return buffer
|
||
}
|
||
if error == nil, out.frameLength > 0 {
|
||
continuation.yield(AnalyzerInput(buffer: out))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Picks the input channel that carries the voice and returns it as a mono buffer.
|
||
// AVAudioConverter keeps only channel 1 when downmixing stereo, and outputs pure
|
||
// silence for layout-less multi-channel inputs (e.g. a 4-input USB interface),
|
||
// so the live preview model would hear nothing. The loudest channel is chosen on
|
||
// smoothed energy, and only replaced by one that is clearly (6 dB) louder.
|
||
final class VoiceChannelPicker {
|
||
private var energy: [Float] = []
|
||
private var active = 0
|
||
private var monoFormat: AVAudioFormat?
|
||
|
||
func mono(from buffer: AVAudioPCMBuffer) -> AVAudioPCMBuffer? {
|
||
let channels = Int(buffer.format.channelCount)
|
||
let frames = Int(buffer.frameLength)
|
||
guard frames > 0, let data = buffer.floatChannelData else { return nil }
|
||
if channels == 1 { return buffer }
|
||
|
||
if energy.count != channels {
|
||
energy = Array(repeating: 0, count: channels)
|
||
active = 0
|
||
}
|
||
for c in 0..<channels {
|
||
var sum: Float = 0
|
||
for i in 0..<frames { sum += data[c][i] * data[c][i] }
|
||
energy[c] = energy[c] * 0.9 + sum / Float(frames) * 0.1
|
||
}
|
||
if let loudest = energy.indices.max(by: { energy[$0] < energy[$1] }), energy[loudest] > energy[active] * 4 {
|
||
active = loudest
|
||
}
|
||
|
||
if monoFormat?.sampleRate != buffer.format.sampleRate {
|
||
monoFormat = AVAudioFormat(standardFormatWithSampleRate: buffer.format.sampleRate, channels: 1)
|
||
}
|
||
guard let format = monoFormat,
|
||
let out = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: AVAudioFrameCount(frames)),
|
||
let dest = out.floatChannelData else { return nil }
|
||
dest[0].update(from: data[active], count: frames)
|
||
out.frameLength = AVAudioFrameCount(frames)
|
||
return out
|
||
}
|
||
}
|
||
|
||
final class MeterView: NSView {
|
||
enum Mode { case recording, transcribing }
|
||
enum Notice { case loadingModel, modelLoaded }
|
||
|
||
private var level: CGFloat = 0
|
||
private var silent = false
|
||
var mode = Mode.recording { didSet { needsDisplay = true } }
|
||
var notice: Notice? { didSet { needsDisplay = true } }
|
||
var previewText: String? { didSet { if previewText != oldValue { cachedPreview = nil; needsDisplay = true } } }
|
||
var hasPreview: Bool { previewBlock() != nil }
|
||
|
||
private static let maxPreviewLines = 3
|
||
private static let previewFont = NSFont.systemFont(ofSize: 13)
|
||
private var cachedPreview: (width: CGFloat, text: NSAttributedString, height: CGFloat)?
|
||
|
||
private var noticeHeight: CGFloat {
|
||
switch notice {
|
||
case .loadingModel?: return 35
|
||
case .modelLoaded?: return 19
|
||
case nil: return 0
|
||
}
|
||
}
|
||
|
||
// Height for the current mode, notice and live preview text
|
||
var preferredHeight: CGFloat {
|
||
let base: CGFloat = mode == .recording ? 72 : 44
|
||
let preview = previewBlock().map { 6 + $0.height } ?? 0
|
||
return base + noticeHeight + preview
|
||
}
|
||
|
||
// The tail of the live transcript that fits in a few lines; older words scroll off the top
|
||
private func previewBlock() -> (text: NSAttributedString, height: CGFloat)? {
|
||
guard let raw = previewText, !raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
|
||
let width = bounds.width - 32
|
||
if let cached = cachedPreview, cached.width == width { return (cached.text, cached.height) }
|
||
|
||
let attrs: [NSAttributedString.Key: Any] = [
|
||
.font: MeterView.previewFont,
|
||
.foregroundColor: NSColor(calibratedWhite: 1, alpha: 0.92)
|
||
]
|
||
func measure(_ string: NSAttributedString) -> CGFloat {
|
||
ceil(string.boundingRect(with: NSSize(width: width, height: 10_000), options: [.usesLineFragmentOrigin, .usesFontLeading]).height)
|
||
}
|
||
let lineHeight = measure(NSAttributedString(string: "Ag", attributes: attrs))
|
||
let maxHeight = lineHeight * CGFloat(MeterView.maxPreviewLines) + 1
|
||
|
||
let words = raw.split(whereSeparator: { $0.isWhitespace })
|
||
var dropped = 0
|
||
var text = NSAttributedString(string: words.joined(separator: " "), attributes: attrs)
|
||
var height = measure(text)
|
||
while height > maxHeight && dropped < words.count - 1 {
|
||
dropped += 1
|
||
text = NSAttributedString(string: "…" + words[dropped...].joined(separator: " "), attributes: attrs)
|
||
height = measure(text)
|
||
}
|
||
cachedPreview = (width, text, height)
|
||
return (text, height)
|
||
}
|
||
|
||
func set(level: CGFloat, silent: Bool) {
|
||
self.level = max(0, min(1, level))
|
||
self.silent = silent
|
||
needsDisplay = true
|
||
}
|
||
func reset() { level = 0; silent = false; mode = .recording; notice = nil; previewText = nil; needsDisplay = true }
|
||
|
||
override func draw(_ dirtyRect: NSRect) {
|
||
let bg = NSBezierPath(roundedRect: bounds, xRadius: 12, yRadius: 12)
|
||
NSColor(calibratedWhite: 0.10, alpha: 0.93).setFill()
|
||
bg.fill()
|
||
|
||
let warn = mode == .recording && silent
|
||
let label = mode == .transcribing ? "⏳ Transcribing…" : warn ? "🎤 No audio input?" : "🎤 Recording…"
|
||
var textY = bounds.height - 26
|
||
label.draw(at: NSPoint(x: 16, y: textY), withAttributes: [
|
||
.font: NSFont.systemFont(ofSize: 12, weight: .medium),
|
||
.foregroundColor: warn ? NSColor.systemRed : NSColor.white
|
||
])
|
||
|
||
switch notice {
|
||
case .loadingModel?:
|
||
textY -= 19
|
||
"Loading model into memory…".draw(at: NSPoint(x: 16, y: textY), withAttributes: [
|
||
.font: NSFont.systemFont(ofSize: 11, weight: .semibold),
|
||
.foregroundColor: NSColor.systemYellow
|
||
])
|
||
textY -= 16
|
||
"The first transcription after a pause is slower.".draw(at: NSPoint(x: 16, y: textY), withAttributes: [
|
||
.font: NSFont.systemFont(ofSize: 10.5),
|
||
.foregroundColor: NSColor(calibratedWhite: 1, alpha: 0.65)
|
||
])
|
||
case .modelLoaded?:
|
||
textY -= 19
|
||
"✓ Model loaded".draw(at: NSPoint(x: 16, y: textY), withAttributes: [
|
||
.font: NSFont.systemFont(ofSize: 11, weight: .semibold),
|
||
.foregroundColor: NSColor.systemGreen
|
||
])
|
||
case nil:
|
||
break
|
||
}
|
||
|
||
if let preview = previewBlock() {
|
||
let top = textY - 6
|
||
preview.text.draw(with: NSRect(x: 16, y: top - preview.height, width: bounds.width - 32, height: preview.height),
|
||
options: [.usesLineFragmentOrigin, .usesFontLeading])
|
||
}
|
||
|
||
guard mode == .recording else { return }
|
||
|
||
let segs = 20
|
||
let margin: CGFloat = 16, gap: CGFloat = 3, segH: CGFloat = 14, y: CGFloat = 14
|
||
let segW = (bounds.width - margin * 2 - gap * CGFloat(segs - 1)) / CGFloat(segs)
|
||
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: 420, height: 72))
|
||
|
||
func show() {
|
||
meter.reset()
|
||
makeWindowIfNeeded()
|
||
layout()
|
||
window?.orderFrontRegardless()
|
||
}
|
||
|
||
func update(level: CGFloat, silent: Bool) { meter.set(level: level, silent: silent) }
|
||
|
||
func setNotice(_ notice: MeterView.Notice?) {
|
||
meter.notice = notice
|
||
layout()
|
||
}
|
||
|
||
func setPreview(_ text: String?) {
|
||
meter.previewText = text
|
||
layout()
|
||
}
|
||
|
||
var hasPreview: Bool { meter.hasPreview }
|
||
|
||
// Stay up after recording while a slow (model-loading) transcription runs
|
||
func showTranscribing(notice: MeterView.Notice?) {
|
||
makeWindowIfNeeded()
|
||
meter.mode = .transcribing
|
||
meter.notice = notice
|
||
layout()
|
||
window?.orderFrontRegardless()
|
||
}
|
||
|
||
func hide() { window?.orderOut(nil) }
|
||
|
||
// Size to the content, anchored above the bottom-center of the screen
|
||
private func layout() {
|
||
guard let window = window, let screen = NSScreen.main else { return }
|
||
let vf = screen.visibleFrame
|
||
let size = NSSize(width: meter.frame.width, height: meter.preferredHeight)
|
||
window.setFrame(NSRect(x: vf.midX - size.width / 2, y: vf.minY + 120, width: size.width, height: size.height), display: true)
|
||
}
|
||
|
||
private func makeWindowIfNeeded() {
|
||
if window == nil {
|
||
let w = NSPanel(contentRect: NSRect(x: 0, y: 0, width: 420, 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
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Model Download Progress Window
|
||
// Standalone window with a real progress bar, size, speed and time remaining,
|
||
// followed by the "loading model" and "ready" steps. Closing it only hides it:
|
||
// the download keeps running and stays visible in the menu bar.
|
||
final class DownloadProgressWindow: NSObject {
|
||
var onCancel: (() -> Void)?
|
||
var onRetry: (() -> Void)?
|
||
|
||
private enum Phase { case downloading, loading, ready, failed }
|
||
private var phase = Phase.downloading
|
||
|
||
private var window: NSWindow?
|
||
private let titleLabel = NSTextField(labelWithString: "")
|
||
// Two indicators instead of toggling isIndeterminate on one: after an
|
||
// indeterminate phase, NSProgressIndicator stops drawing determinate values.
|
||
private let bar = NSProgressIndicator()
|
||
private let busyBar = NSProgressIndicator()
|
||
private let detailLabel = NSTextField(labelWithString: "")
|
||
private let hintLabel = NSTextField(labelWithString: "Closing this window won't stop the download.")
|
||
private let primaryButton = NSButton(title: "", target: nil, action: nil)
|
||
private let secondaryButton = NSButton(title: "", target: nil, action: nil)
|
||
|
||
// Smoothed throughput for the speed / time-remaining estimate
|
||
private var sampleTime: Date?
|
||
private var sampleBytes: Int64 = 0
|
||
private var bytesPerSecond: Double = 0
|
||
private var lastRender = Date.distantPast
|
||
|
||
private let byteFormatter: ByteCountFormatter = {
|
||
let f = ByteCountFormatter()
|
||
f.countStyle = .memory // binary units, matching the sizes in the Download menu
|
||
return f
|
||
}()
|
||
private let remainingFormatter: DateComponentsFormatter = {
|
||
let f = DateComponentsFormatter()
|
||
var calendar = Calendar.current
|
||
calendar.locale = Locale(identifier: "en_US") // UI is English; avoid "About 6 perc remaining"
|
||
f.calendar = calendar
|
||
f.unitsStyle = .full
|
||
f.allowedUnits = [.hour, .minute, .second]
|
||
f.maximumUnitCount = 1
|
||
f.includesApproximationPhrase = true
|
||
f.includesTimeRemainingPhrase = true
|
||
return f
|
||
}()
|
||
|
||
var isVisible: Bool { window?.isVisible ?? false }
|
||
|
||
func show() {
|
||
let w = window ?? makeWindow()
|
||
window = w
|
||
if !w.isVisible { w.center() }
|
||
w.makeKeyAndOrderFront(nil)
|
||
NSApp.activate(ignoringOtherApps: true)
|
||
}
|
||
|
||
func hide() { window?.orderOut(nil) }
|
||
|
||
func startDownloading(modelName: String) {
|
||
phase = .downloading
|
||
sampleTime = nil
|
||
sampleBytes = 0
|
||
bytesPerSecond = 0
|
||
lastRender = .distantPast
|
||
titleLabel.stringValue = "Downloading \(modelName)"
|
||
setBar(indeterminate: true)
|
||
setDetail("Connecting…")
|
||
hintLabel.isHidden = false
|
||
configureButtons(primary: "Cancel Download", secondary: "Hide", primaryIsDefault: false)
|
||
}
|
||
|
||
func updateProgress(written: Int64, expected: Int64) {
|
||
guard phase == .downloading else { return }
|
||
let now = Date()
|
||
if let t = sampleTime {
|
||
let dt = now.timeIntervalSince(t)
|
||
if dt >= 0.5 {
|
||
let instant = Double(written - sampleBytes) / dt
|
||
bytesPerSecond = bytesPerSecond == 0 ? instant : bytesPerSecond * 0.8 + instant * 0.2
|
||
sampleTime = now
|
||
sampleBytes = written
|
||
}
|
||
} else {
|
||
sampleTime = now
|
||
sampleBytes = written
|
||
}
|
||
// Redraw a few times per second, not on every chunk
|
||
guard now.timeIntervalSince(lastRender) >= 0.25 || written == expected else { return }
|
||
lastRender = now
|
||
|
||
var parts: [String] = []
|
||
if expected > 0 {
|
||
let fraction = Double(written) / Double(expected)
|
||
setBar(indeterminate: false, value: fraction)
|
||
parts.append("\(byteFormatter.string(fromByteCount: written)) of \(byteFormatter.string(fromByteCount: expected)) (\(Int(fraction * 100))%)")
|
||
} else {
|
||
parts.append(byteFormatter.string(fromByteCount: written))
|
||
}
|
||
if bytesPerSecond > 0 {
|
||
parts.append("\(byteFormatter.string(fromByteCount: Int64(bytesPerSecond)))/s")
|
||
if expected > 0, let eta = remainingFormatter.string(from: Double(expected - written) / bytesPerSecond) {
|
||
parts.append(eta)
|
||
}
|
||
}
|
||
setDetail(parts.joined(separator: " · "))
|
||
}
|
||
|
||
func showLoading(modelName: String) {
|
||
phase = .loading
|
||
titleLabel.stringValue = "Loading \(modelName)…"
|
||
setBar(indeterminate: true)
|
||
setDetail("Download complete. Loading the model into memory…")
|
||
hintLabel.isHidden = true
|
||
configureButtons(primary: "Hide", secondary: nil, primaryIsDefault: false)
|
||
}
|
||
|
||
func showReady(modelName: String) {
|
||
phase = .ready
|
||
titleLabel.stringValue = "✓ \(modelName) is ready"
|
||
setBar(indeterminate: false, value: 1)
|
||
setDetail("It is now the active model. Press ⌃⌥D to dictate.")
|
||
hintLabel.isHidden = true
|
||
configureButtons(primary: "Done", secondary: nil, primaryIsDefault: true)
|
||
}
|
||
|
||
func showFailed(modelName: String, message: String) {
|
||
phase = .failed
|
||
titleLabel.stringValue = "⚠️ Could not download \(modelName)"
|
||
busyBar.stopAnimation(nil)
|
||
busyBar.isHidden = true
|
||
bar.isHidden = true
|
||
setDetail(message)
|
||
hintLabel.isHidden = true
|
||
configureButtons(primary: "Retry", secondary: "Close", primaryIsDefault: true)
|
||
}
|
||
|
||
// MARK: private
|
||
private func makeWindow() -> NSWindow {
|
||
let w = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 440, height: 170),
|
||
styleMask: [.titled, .closable], backing: .buffered, defer: false)
|
||
w.title = "Model Download"
|
||
w.isReleasedWhenClosed = false // reused; the close button just hides it
|
||
|
||
let v = NSView(frame: NSRect(x: 0, y: 0, width: 440, height: 170))
|
||
|
||
titleLabel.frame = NSRect(x: 20, y: 130, width: 400, height: 20)
|
||
titleLabel.font = .boldSystemFont(ofSize: 13)
|
||
titleLabel.lineBreakMode = .byTruncatingTail
|
||
v.addSubview(titleLabel)
|
||
|
||
bar.frame = NSRect(x: 20, y: 104, width: 400, height: 20)
|
||
bar.style = .bar
|
||
bar.isIndeterminate = false
|
||
bar.minValue = 0
|
||
bar.maxValue = 1
|
||
v.addSubview(bar)
|
||
|
||
busyBar.frame = bar.frame
|
||
busyBar.style = .bar
|
||
busyBar.isIndeterminate = true
|
||
v.addSubview(busyBar)
|
||
|
||
detailLabel.frame = NSRect(x: 20, y: 80, width: 400, height: 17)
|
||
detailLabel.font = .monospacedDigitSystemFont(ofSize: 11, weight: .regular)
|
||
detailLabel.textColor = .secondaryLabelColor
|
||
detailLabel.lineBreakMode = .byTruncatingTail
|
||
v.addSubview(detailLabel)
|
||
|
||
hintLabel.frame = NSRect(x: 20, y: 60, width: 400, height: 16)
|
||
hintLabel.font = .systemFont(ofSize: 10)
|
||
hintLabel.textColor = .tertiaryLabelColor
|
||
v.addSubview(hintLabel)
|
||
|
||
primaryButton.frame = NSRect(x: 286, y: 14, width: 140, height: 32)
|
||
primaryButton.bezelStyle = .rounded
|
||
primaryButton.target = self
|
||
primaryButton.action = #selector(primaryClicked)
|
||
v.addSubview(primaryButton)
|
||
|
||
secondaryButton.frame = NSRect(x: 174, y: 14, width: 100, height: 32)
|
||
secondaryButton.bezelStyle = .rounded
|
||
secondaryButton.target = self
|
||
secondaryButton.action = #selector(secondaryClicked)
|
||
v.addSubview(secondaryButton)
|
||
|
||
w.contentView = v
|
||
return w
|
||
}
|
||
|
||
private func setBar(indeterminate: Bool, value: Double = 0) {
|
||
bar.isHidden = indeterminate
|
||
busyBar.isHidden = !indeterminate
|
||
if indeterminate {
|
||
busyBar.startAnimation(nil)
|
||
} else {
|
||
busyBar.stopAnimation(nil)
|
||
bar.doubleValue = value
|
||
}
|
||
}
|
||
|
||
private func setDetail(_ text: String) {
|
||
detailLabel.stringValue = text
|
||
detailLabel.toolTip = text // full text if truncated (e.g. long error messages)
|
||
}
|
||
|
||
private func configureButtons(primary: String, secondary: String?, primaryIsDefault: Bool) {
|
||
primaryButton.title = primary
|
||
primaryButton.keyEquivalent = primaryIsDefault ? "\r" : ""
|
||
secondaryButton.title = secondary ?? ""
|
||
secondaryButton.isHidden = secondary == nil
|
||
secondaryButton.keyEquivalent = secondary == nil ? "" : "\u{1b}" // Esc
|
||
}
|
||
|
||
@objc private func primaryClicked() {
|
||
switch phase {
|
||
case .downloading: onCancel?()
|
||
case .loading, .ready: hide()
|
||
case .failed: onRetry?()
|
||
}
|
||
}
|
||
|
||
@objc private func secondaryClicked() { hide() }
|
||
}
|
||
|
||
// MARK: - App Delegate
|
||
class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate, NSTableViewDataSource, NSTableViewDelegate, NSTextFieldDelegate, NSWindowDelegate {
|
||
var statusItem: NSStatusItem!
|
||
var audioRecorder: AVAudioRecorder?
|
||
var isRecording = false
|
||
var isTranscribing = false
|
||
// Model load state of the current dictation (see prepareModelForDictation)
|
||
var modelLoading = false
|
||
var loadNoticeShown = false
|
||
var dictationGeneration = 0
|
||
var settingsWindowController: NSWindowController?
|
||
var whisperServer: WhisperServer?
|
||
let recordingHUD = RecordingHUD()
|
||
let livePreview = LivePreview()
|
||
var meterTimer: Timer?
|
||
var silenceStart: Date?
|
||
weak var dictTable: NSTableView?
|
||
weak var settingsModelPopup: NSPopUpButton?
|
||
weak var settingsNoModelLabel: NSTextField?
|
||
weak var settingsDownloadButton: NSButton?
|
||
|
||
// Use private temp directory with unique filename
|
||
var audioFilePath: String {
|
||
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) }
|
||
}
|
||
|
||
var livePreviewEnabled: Bool {
|
||
get { UserDefaults.standard.object(forKey: Defaults.livePreview) as? Bool ?? true }
|
||
set { UserDefaults.standard.set(newValue, forKey: Defaults.livePreview) }
|
||
}
|
||
|
||
// 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 = dictionaryTerms()
|
||
return terms.isEmpty ? nil : terms.joined(separator: ", ")
|
||
}
|
||
|
||
// The desired ("to") dictionary terms, also given to the live preview model
|
||
func dictionaryTerms() -> [String] {
|
||
replacements.compactMap { $0["to"] }
|
||
.map { $0.replacingOccurrences(of: "\\n", with: " ") }
|
||
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||
.filter { !$0.isEmpty }
|
||
}
|
||
|
||
// 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()
|
||
if livePreviewEnabled { LivePreview.installModelIfNeeded(languageCode: language) }
|
||
|
||
NSLog("WhisperDictate started. Press ⌃⌥D to toggle recording.")
|
||
}
|
||
|
||
func hasAnyModel() -> Bool {
|
||
let validation = isValidModelPath(modelPath)
|
||
return validation.valid
|
||
}
|
||
|
||
// MARK: - First Run Wizard
|
||
func showFirstRunWizard() {
|
||
let alert = NSAlert()
|
||
alert.messageText = "Welcome to WhisperDictate!"
|
||
alert.informativeText = "To get started, download a Whisper speech recognition model.\n\n"
|
||
+ "Quantized models (q5/q8) are much smaller and use far less RAM, with a small accuracy trade-off. "
|
||
+ "Hover over a model for details."
|
||
alert.alertStyle = .informational
|
||
|
||
// One popup instead of one button per model (too many models for buttons)
|
||
let popup = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 320, height: 26), pullsDown: false)
|
||
for model in WhisperModels.available {
|
||
popup.addItem(withTitle: "\(model.name) (\(model.size))")
|
||
popup.lastItem?.toolTip = "✓ \(model.pros)\n✗ \(model.cons)"
|
||
}
|
||
if let recommended = WhisperModels.available.firstIndex(where: { $0.name.contains("Recommended") }) {
|
||
popup.selectItem(at: recommended)
|
||
}
|
||
alert.accessoryView = popup
|
||
alert.addButton(withTitle: "Download")
|
||
alert.addButton(withTitle: "Cancel")
|
||
|
||
NSApp.activate(ignoringOtherApps: true)
|
||
if alert.runModal() == .alertFirstButtonReturn {
|
||
downloadModel(WhisperModels.available[popup.indexOfSelectedItem])
|
||
} else {
|
||
updateStatus("⚠️ No model selected")
|
||
}
|
||
}
|
||
|
||
var downloadTask: URLSessionDownloadTask?
|
||
var downloadSession: URLSession?
|
||
var currentDownloadModel: WhisperModels.Model?
|
||
var currentDownloadDestination: String?
|
||
var downloadPercent = 0
|
||
let downloadWindow = DownloadProgressWindow()
|
||
|
||
func downloadModel(_ model: WhisperModels.Model) {
|
||
// One download at a time: asking again just brings the progress back up
|
||
if downloadTask != nil {
|
||
downloadWindow.show()
|
||
return
|
||
}
|
||
guard let url = URL(string: model.url) else {
|
||
updateStatus("⚠️ Invalid URL")
|
||
return
|
||
}
|
||
|
||
// Create models directory
|
||
let modelsDir = WhisperModels.modelsDirectory
|
||
try? FileManager.default.createDirectory(atPath: modelsDir, withIntermediateDirectories: true)
|
||
|
||
// Store for delegate callbacks. An existing file is only replaced once the
|
||
// new one has fully arrived, so a cancelled/failed download loses nothing.
|
||
currentDownloadModel = model
|
||
currentDownloadDestination = (modelsDir as NSString).appendingPathComponent(model.filename)
|
||
downloadPercent = 0
|
||
|
||
downloadWindow.onCancel = { [weak self] in self?.cancelDownload() }
|
||
downloadWindow.onRetry = { [weak self] in self?.downloadModel(model) }
|
||
downloadWindow.startDownloading(modelName: model.name)
|
||
downloadWindow.show()
|
||
|
||
if downloadSession == nil {
|
||
downloadSession = URLSession(configuration: .default, delegate: self, delegateQueue: .main)
|
||
}
|
||
downloadTask = downloadSession?.downloadTask(with: url)
|
||
downloadTask?.resume()
|
||
updateDownloadIndicators()
|
||
NSLog("Starting download of \(model.name)")
|
||
}
|
||
|
||
@objc func showDownloadWindow() {
|
||
downloadWindow.show()
|
||
}
|
||
|
||
func cancelDownload() {
|
||
guard let task = downloadTask else { return }
|
||
NSLog("Download cancelled: \(currentDownloadModel?.name ?? "")")
|
||
clearDownloadState() // first, so the cancellation callback is ignored
|
||
task.cancel()
|
||
downloadWindow.hide()
|
||
updateStatus("Download cancelled")
|
||
}
|
||
|
||
func failDownload(_ message: String) {
|
||
let name = currentDownloadModel?.name ?? "model"
|
||
clearDownloadState()
|
||
updateStatus("⚠️ Download failed")
|
||
if playSounds { NSSound(named: "Basso")?.play() }
|
||
downloadWindow.showFailed(modelName: name, message: message)
|
||
downloadWindow.show()
|
||
}
|
||
|
||
func clearDownloadState() {
|
||
downloadTask = nil
|
||
currentDownloadModel = nil
|
||
currentDownloadDestination = nil
|
||
updateDownloadIndicators()
|
||
}
|
||
|
||
// Menu bar icon when idle: download progress while a model downloads, else the mic
|
||
var idleIcon: String {
|
||
downloadTask != nil ? "⬇️ \(downloadPercent)%" : "🎤"
|
||
}
|
||
|
||
// Sync the menu bar icon, the menu's download line and the Settings button
|
||
// with the download state, without overriding the recording/transcribing icon.
|
||
func updateDownloadIndicators() {
|
||
if let title = statusItem.button?.title, title == "🎤" || title.hasPrefix("⬇️") {
|
||
statusItem.button?.title = idleIcon
|
||
}
|
||
if let item = statusItem.menu?.item(withTag: 101) {
|
||
item.isHidden = currentDownloadModel == nil
|
||
if let model = currentDownloadModel {
|
||
item.title = "⬇️ Downloading \(model.name): \(downloadPercent)% — Show Progress…"
|
||
}
|
||
}
|
||
settingsDownloadButton?.title = downloadTask != nil ? "⬇️ \(downloadPercent)%" : "Download..."
|
||
}
|
||
|
||
// MARK: - URLSessionDownloadDelegate
|
||
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
|
||
guard downloadTask === self.downloadTask else { return }
|
||
downloadWindow.updateProgress(written: totalBytesWritten, expected: totalBytesExpectedToWrite)
|
||
let percent = totalBytesExpectedToWrite > 0 ? Int((Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)) * 100) : 0
|
||
if percent != downloadPercent {
|
||
downloadPercent = percent
|
||
updateDownloadIndicators()
|
||
}
|
||
}
|
||
|
||
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
|
||
guard downloadTask === self.downloadTask,
|
||
let model = currentDownloadModel, let destinationPath = currentDownloadDestination else { return }
|
||
|
||
// Don't save an HTML error page as a model
|
||
if let http = downloadTask.response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
|
||
NSLog("Download failed: HTTP \(http.statusCode)")
|
||
failDownload("The server returned HTTP \(http.statusCode).")
|
||
return
|
||
}
|
||
|
||
// The temp file is deleted when this callback returns, so move it now
|
||
let destinationURL = URL(fileURLWithPath: destinationPath)
|
||
do {
|
||
if FileManager.default.fileExists(atPath: destinationPath) {
|
||
_ = try FileManager.default.replaceItemAt(destinationURL, withItemAt: location)
|
||
} else {
|
||
try FileManager.default.moveItem(at: location, to: destinationURL)
|
||
}
|
||
} catch {
|
||
NSLog("Save failed: \(error)")
|
||
failDownload("Could not save the model: \(error.localizedDescription)")
|
||
return
|
||
}
|
||
|
||
NSLog("Model downloaded: \(model.name)")
|
||
clearDownloadState()
|
||
modelPath = destinationPath
|
||
refreshSettingsModels()
|
||
downloadWindow.showLoading(modelName: model.name)
|
||
startWhisperServer { [weak self] in
|
||
guard let self = self else { return }
|
||
if self.playSounds { NSSound(named: "Glass")?.play() }
|
||
self.downloadWindow.showReady(modelName: model.name)
|
||
}
|
||
}
|
||
|
||
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||
// Success is handled in didFinishDownloadingTo (which clears downloadTask first)
|
||
guard task === downloadTask, let error = error else { return }
|
||
NSLog("Download failed: \(error)")
|
||
failDownload(error.localizedDescription)
|
||
}
|
||
|
||
func applicationWillTerminate(_ notification: Notification) {
|
||
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)
|
||
|
||
let downloadMenuItem = NSMenuItem(title: "", action: #selector(showDownloadWindow), keyEquivalent: "")
|
||
downloadMenuItem.target = self
|
||
downloadMenuItem.tag = 101
|
||
downloadMenuItem.isHidden = true
|
||
menu.addItem(downloadMenuItem)
|
||
|
||
menu.addItem(NSMenuItem.separator())
|
||
menu.addItem(NSMenuItem(title: "Settings...", action: #selector(showSettings), keyEquivalent: ","))
|
||
menu.addItem(NSMenuItem.separator())
|
||
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: 580),
|
||
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 = 538
|
||
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 noModelLabel = NSTextField(labelWithString: "No models - click Download")
|
||
noModelLabel.frame = NSRect(x: controlX, y: y, width: 200, height: 24)
|
||
noModelLabel.textColor = .secondaryLabelColor
|
||
contentView.addSubview(noModelLabel)
|
||
settingsNoModelLabel = noModelLabel
|
||
|
||
let modelPopup = NSPopUpButton(frame: NSRect(x: controlX, y: y, width: 200, height: 24), pullsDown: false)
|
||
modelPopup.tag = 2
|
||
modelPopup.target = self
|
||
modelPopup.action = #selector(modelSelected(_:))
|
||
contentView.addSubview(modelPopup)
|
||
settingsModelPopup = modelPopup
|
||
refreshSettingsModels()
|
||
|
||
let downloadBtn = NSButton(title: "Download...", target: self, action: #selector(downloadNewModel))
|
||
downloadBtn.frame = NSRect(x: controlX + 210, y: y, width: 80, height: 24)
|
||
contentView.addSubview(downloadBtn)
|
||
settingsDownloadButton = downloadBtn
|
||
updateDownloadIndicators()
|
||
|
||
y -= 40
|
||
|
||
// 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)
|
||
|
||
y -= 40
|
||
|
||
// Live preview
|
||
let previewCheck = NSButton(checkboxWithTitle: "Live preview while recording", target: self, action: #selector(livePreviewChanged(_:)))
|
||
previewCheck.frame = NSRect(x: controlX, y: y, width: controlWidth, height: 24)
|
||
previewCheck.state = livePreviewEnabled ? .on : .off
|
||
previewCheck.toolTip = "Shows a rough live transcript in the recording overlay using macOS's on-device dictation model. The pasted text still comes from Whisper."
|
||
contentView.addSubview(previewCheck)
|
||
|
||
// 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
|
||
}
|
||
|
||
// (Re)fill the Settings model popup, e.g. after a download finished
|
||
func refreshSettingsModels() {
|
||
guard let popup = settingsModelPopup else { return }
|
||
let installedModels = WhisperModels.installedModels()
|
||
popup.removeAllItems()
|
||
for (index, model) in installedModels.enumerated() {
|
||
popup.addItem(withTitle: "\(model.name) (\(model.size))")
|
||
// Use tag instead of representedObject to avoid memory issues
|
||
popup.lastItem?.tag = index
|
||
if model.path == modelPath { popup.selectItem(at: index) }
|
||
}
|
||
popup.isHidden = installedModels.isEmpty
|
||
settingsNoModelLabel?.isHidden = !installedModels.isEmpty
|
||
}
|
||
|
||
@objc func languageChanged(_ sender: NSPopUpButton) {
|
||
if let code = sender.selectedItem?.representedObject as? String {
|
||
language = code
|
||
if livePreviewEnabled { LivePreview.installModelIfNeeded(languageCode: 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() {
|
||
// A download is already running: show its progress instead of the menu
|
||
if downloadTask != nil {
|
||
downloadWindow.show()
|
||
return
|
||
}
|
||
|
||
// Create simple popup menu for model selection
|
||
let menu = NSMenu(title: "Select Model")
|
||
let installedPaths = Set(WhisperModels.installedModels().map { $0.path })
|
||
|
||
for (index, model) in WhisperModels.available.enumerated() {
|
||
let item = NSMenuItem(title: "\(model.name) (\(model.size))", action: #selector(downloadModelAtIndex(_:)), keyEquivalent: "")
|
||
item.target = self
|
||
item.tag = index
|
||
// Checkmark = already downloaded
|
||
let path = (WhisperModels.modelsDirectory as NSString).appendingPathComponent(model.filename)
|
||
item.state = installedPaths.contains(path) ? .on : .off
|
||
|
||
// Add subtitle with pros/cons
|
||
item.toolTip = "✓ \(model.pros)\n✗ \(model.cons)"
|
||
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 }
|
||
downloadModel(WhisperModels.available[index])
|
||
}
|
||
|
||
@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 livePreviewChanged(_ sender: NSButton) {
|
||
livePreviewEnabled = sender.state == .on
|
||
if livePreviewEnabled { LivePreview.installModelIfNeeded(languageCode: language) }
|
||
}
|
||
|
||
@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()
|
||
prepareModelForDictation()
|
||
startLivePreview()
|
||
} 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(hideHUD: Bool = true) {
|
||
meterTimer?.invalidate()
|
||
meterTimer = nil
|
||
silenceStart = nil
|
||
if hideHUD { 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)
|
||
checkModelLoaded()
|
||
}
|
||
|
||
// MARK: - Live preview
|
||
func startLivePreview() {
|
||
guard livePreviewEnabled else { return }
|
||
livePreview.onText = { [weak self] text in
|
||
guard let self = self, self.isRecording else { return }
|
||
self.recordingHUD.setPreview(self.applyReplacements(text))
|
||
}
|
||
livePreview.start(languageCode: language, contextualStrings: dictionaryTerms())
|
||
}
|
||
|
||
// MARK: - Model warm-up
|
||
// Start getting the model into memory as soon as recording starts, so loading
|
||
// overlaps with the user talking instead of following it. Only if that turns
|
||
// out to be slow does the HUD explain why this transcription takes longer.
|
||
func prepareModelForDictation() {
|
||
dictationGeneration += 1
|
||
modelLoading = false
|
||
loadNoticeShown = false
|
||
|
||
if let server = whisperServer, server.isReady {
|
||
guard server.needsWarmUp else { return } // used recently: still warm
|
||
modelLoading = true
|
||
server.warmUp(language: language)
|
||
} else if let server = whisperServer, server.isStarting {
|
||
modelLoading = true // already loading (app launch / model switch)
|
||
} else if findWhisperServer() != nil, isValidModelPath(modelPath).valid {
|
||
modelLoading = true // server crashed or failed earlier: load it now
|
||
startWhisperServer()
|
||
} else {
|
||
return // whisper-cli only
|
||
}
|
||
|
||
let generation = dictationGeneration
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { [weak self] in
|
||
guard let self = self, self.dictationGeneration == generation else { return }
|
||
self.checkModelLoaded() // may have finished since the last meter tick
|
||
guard self.modelLoading, !self.loadNoticeShown else { return }
|
||
if self.isRecording {
|
||
self.loadNoticeShown = true
|
||
self.recordingHUD.setNotice(.loadingModel)
|
||
} else if self.isTranscribing {
|
||
self.loadNoticeShown = true
|
||
self.recordingHUD.showTranscribing(notice: .loadingModel)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Polled from the meter timer while recording
|
||
func checkModelLoaded() {
|
||
guard modelLoading, let server = whisperServer, server.isReady, !server.isWarmingUp else { return }
|
||
modelLoading = false
|
||
if loadNoticeShown { recordingHUD.setNotice(.modelLoaded) }
|
||
}
|
||
|
||
func transcriptionFinished() {
|
||
isTranscribing = false
|
||
guard !isRecording else { return } // a new dictation has already started
|
||
modelLoading = false
|
||
loadNoticeShown = false
|
||
recordingHUD.hide()
|
||
}
|
||
|
||
func stopRecordingAndTranscribe() {
|
||
audioRecorder?.stop()
|
||
isRecording = false
|
||
isTranscribing = true
|
||
livePreview.stop()
|
||
// Keep the HUD up while Whisper works if there is something to show:
|
||
// the live preview text, or why this transcription is slower
|
||
let explainLoading = modelLoading && loadNoticeShown
|
||
let keepHUD = explainLoading || recordingHUD.hasPreview
|
||
stopMeter(hideHUD: !keepHUD)
|
||
if keepHUD { recordingHUD.showTranscribing(notice: explainLoading ? .loadingModel : nil) }
|
||
statusItem.button?.title = "⏳"
|
||
updateStatus("Transcribing...")
|
||
if playSounds { NSSound(named: "Pop")?.play() }
|
||
NSLog("Recording stopped, transcribing...")
|
||
|
||
DispatchQueue.global(qos: .userInitiated).async {
|
||
self.transcribe()
|
||
DispatchQueue.main.async { self.transcriptionFinished() }
|
||
}
|
||
}
|
||
|
||
// 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.
|
||
// `completion` runs on the main thread once loading is over, whatever the outcome.
|
||
func startWhisperServer(completion: (() -> Void)? = nil) {
|
||
whisperServer?.stop()
|
||
whisperServer = nil
|
||
|
||
guard let serverPath = findWhisperServer() else {
|
||
NSLog("whisper-server not found — using per-call whisper-cli")
|
||
completion?()
|
||
return
|
||
}
|
||
guard isValidModelPath(modelPath).valid else {
|
||
completion?()
|
||
return
|
||
}
|
||
|
||
if !isRecording && !isTranscribing {
|
||
statusItem.button?.title = "⏳"
|
||
updateStatus("Loading model…")
|
||
}
|
||
|
||
let threads = min(8, max(2, ProcessInfo.processInfo.activeProcessorCount - 2))
|
||
let server = WhisperServer(serverPath: serverPath, modelPath: modelPath, threads: threads)
|
||
whisperServer = server
|
||
server.start { [weak self] ok in
|
||
DispatchQueue.main.async {
|
||
defer { completion?() }
|
||
guard let self = self, self.whisperServer === server else { return }
|
||
let busy = self.isRecording || self.isTranscribing
|
||
if !busy { self.statusItem.button?.title = self.idleIcon }
|
||
if ok {
|
||
if !busy { self.updateStatus("Ready") }
|
||
NSLog("whisper-server ready on port \(server.port)")
|
||
} else {
|
||
if !busy { 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.idleIcon
|
||
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.idleIcon
|
||
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 if let server = whisperServer, server.waitUntilReady(timeout: 120) {
|
||
// Loading was already under way (launch, model switch, recording start):
|
||
// wait for it rather than loading the model a second time in whisper-cli
|
||
result = server.transcribe(wavPath: audioFilePath, language: language, prompt: prompt)
|
||
} else {
|
||
result = transcribeWithCLI(prompt: prompt)
|
||
}
|
||
|
||
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.idleIcon
|
||
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.idleIcon
|
||
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.idleIcon
|
||
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()
|