Files
WhisperDictate/macos/src/main.swift
hariel1985 78caac8d04 Fix silent live preview on stereo and multi-input audio interfaces
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>
2026-09-16 12:25:58 +02:00

2236 sor
89 KiB
Swift
Nyers Blame Előzmények

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Cocoa
@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()