3 Commit-ok
v1.4.0 ... main

Szerző SHA1 Üzenet Dátum
hariel1985
c2bd30bbf5 Update README for 1.4.1
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 14:18:04 +02:00
hariel1985
8bbea20dcc Bump version to 1.4.1
Patch release: native SF Symbols icons instead of emoji in the menu bar,
recording HUD, download window, menus and Settings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 14:17:44 +02:00
hariel1985
6b6d9342a9 Replace emoji icons with SF Symbols
The menu bar, recording HUD, download window, menus and Settings used
emoji as icons. They now use SF Symbols: vector, crisp at any scale, and
template-rendered in the menu bar so they follow light, dark and tinted
menu bars.

Menu bar: mic (idle), red mic.fill (recording), hourglass (transcribing /
loading the model), arrow.down.circle with the percentage (downloading).
The icon is now driven by a state (idle/recording/busy) instead of
comparing emoji strings, so download progress can't overwrite the
recording indicator. Icons carry accessibility descriptions.

Coloured symbols are tinted by drawing the monochrome symbol, which keeps
knockouts transparent (a one-colour palette filled in the tick of
checkmark.circle.fill). Status warnings take a warning flag and show a
yellow triangle instead of a "⚠️" prefix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 14:17:44 +02:00
4 fájl változott, egészen pontosan 129 új sor hozzáadva és 48 régi sor törölve

Fájl megtekintése

@@ -12,6 +12,10 @@ A simple menu bar app for voice dictation using OpenAI Whisper (local, offline).
## macOS
### What's new in 1.4.1
- 🎨 **Native icons instead of emoji** — the menu bar icon, recording overlay, download window, menus and Settings now use macOS SF Symbols: sharp at any size, matching light/dark menu bars, and the menu bar microphone turns red while recording
### What's new in 1.4.0
- 💬 **Live preview while recording** — the recording overlay shows what you're saying as you speak, using macOS's on-device dictation model (works in Hungarian too). It's a preview: the pasted text still comes from Whisper, so it can differ slightly. Toggle it in Settings.
@@ -85,9 +89,9 @@ make install
### Usage
1. Launch WhisperDictate from Applications
2. Look for the 🎤 icon in your menu bar
2. Look for the microphone icon in your menu bar
3. Press **⌃⌥D** (Control + Option + D) to start recording
4. Speak (icon changes to 🔴)
4. Speak (the menu bar microphone turns red)
5. Press **⌃⌥D** again to stop and transcribe
6. Text is automatically pasted where your cursor is

Fájl megtekintése

@@ -2,7 +2,7 @@
APP_NAME = WhisperDictate
APP_BUNDLE = $(APP_NAME).app
VERSION = 1.4.0
VERSION = 1.4.1
# Directories
SRC_DIR = src

Fájl megtekintése

@@ -19,9 +19,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.4.0</string>
<string>1.4.1</string>
<key>CFBundleVersion</key>
<string>5</string>
<string>6</string>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>LSUIElement</key>

Fájl megtekintése

@@ -15,6 +15,35 @@ struct Defaults {
static let livePreview = "livePreview"
}
// MARK: - Icons
// SF Symbols: vector icons that stay sharp at any scale. Template symbols are
// tinted by macOS (menu bar, menus, buttons) and follow light/dark appearance.
enum Icons {
static func symbol(_ name: String, pointSize: CGFloat = 13, weight: NSFont.Weight = .regular, description: String? = nil) -> NSImage? {
let image = NSImage(systemSymbolName: name, accessibilityDescription: description)?
.withSymbolConfiguration(.init(pointSize: pointSize, weight: weight))
image?.isTemplate = true
return image
}
// Fixed colour, for states that need it (recording) and for custom drawing (HUD).
// Tints the monochrome symbol so knockouts stay transparent (the tick in
// checkmark.circle.fill); a one-colour palette would fill them in. Drawn
// lazily, so it stays sharp on any display scale.
static func coloredSymbol(_ name: String, pointSize: CGFloat, weight: NSFont.Weight = .regular, color: NSColor, description: String? = nil) -> NSImage? {
guard let symbol = symbol(name, pointSize: pointSize, weight: weight, description: description) else { return nil }
let image = NSImage(size: symbol.size, flipped: false) { rect in
symbol.draw(in: rect)
color.set()
rect.fill(using: .sourceAtop)
return true
}
image.accessibilityDescription = description
image.isTemplate = false
return image
}
}
// MARK: - Whisper Models
struct WhisperModels {
struct Model {
@@ -681,24 +710,40 @@ final class MeterView: NSView {
}
func reset() { level = 0; silent = false; mode = .recording; notice = nil; previewText = nil; needsDisplay = true }
// Draws a symbol centred on a text line drawn at `origin`; returns the x where the text starts
private static func drawSymbol(_ name: String, color: NSColor, font: NSFont, at origin: NSPoint) -> CGFloat {
guard let image = Icons.coloredSymbol(name, pointSize: font.pointSize, weight: .medium, color: color) else { return origin.x }
let lineHeight = ceil(font.ascender - font.descender)
image.draw(in: NSRect(x: origin.x, y: origin.y + (lineHeight - image.size.height) / 2,
width: image.size.width, height: image.size.height))
return origin.x + image.size.width + 5
}
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…"
let titleFont = NSFont.systemFont(ofSize: 12, weight: .medium)
let (symbol, symbolColor, label): (String, NSColor, String) =
mode == .transcribing ? ("hourglass", .white, "Transcribing…")
: warn ? ("mic.slash.fill", .systemRed, "No audio input?")
: ("mic.fill", .systemRed, "Recording…")
var textY = bounds.height - 26
label.draw(at: NSPoint(x: 16, y: textY), withAttributes: [
.font: NSFont.systemFont(ofSize: 12, weight: .medium),
let titleX = MeterView.drawSymbol(symbol, color: symbolColor, font: titleFont, at: NSPoint(x: 16, y: textY))
label.draw(at: NSPoint(x: titleX, y: textY), withAttributes: [
.font: titleFont,
.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),
let font = NSFont.systemFont(ofSize: 11, weight: .semibold)
let x = MeterView.drawSymbol("memorychip", color: .systemYellow, font: font, at: NSPoint(x: 16, y: textY))
"Loading model into memory…".draw(at: NSPoint(x: x, y: textY), withAttributes: [
.font: font,
.foregroundColor: NSColor.systemYellow
])
textY -= 16
@@ -708,8 +753,10 @@ final class MeterView: NSView {
])
case .modelLoaded?:
textY -= 19
"✓ Model loaded".draw(at: NSPoint(x: 16, y: textY), withAttributes: [
.font: NSFont.systemFont(ofSize: 11, weight: .semibold),
let font = NSFont.systemFont(ofSize: 11, weight: .semibold)
let x = MeterView.drawSymbol("checkmark.circle.fill", color: .systemGreen, font: font, at: NSPoint(x: 16, y: textY))
"Model loaded".draw(at: NSPoint(x: x, y: textY), withAttributes: [
.font: font,
.foregroundColor: NSColor.systemGreen
])
case nil:
@@ -818,6 +865,7 @@ final class DownloadProgressWindow: NSObject {
private var phase = Phase.downloading
private var window: NSWindow?
private let titleIcon = NSImageView()
private let titleLabel = NSTextField(labelWithString: "")
// Two indicators instead of toggling isIndeterminate on one: after an
// indeterminate phase, NSProgressIndicator stops drawing determinate values.
@@ -871,6 +919,7 @@ final class DownloadProgressWindow: NSObject {
bytesPerSecond = 0
lastRender = .distantPast
titleLabel.stringValue = "Downloading \(modelName)"
setTitleIcon("arrow.down.circle.fill", color: .controlAccentColor)
setBar(indeterminate: true)
setDetail("Connecting…")
hintLabel.isHidden = false
@@ -916,6 +965,7 @@ final class DownloadProgressWindow: NSObject {
func showLoading(modelName: String) {
phase = .loading
titleLabel.stringValue = "Loading \(modelName)…"
setTitleIcon("memorychip.fill", color: .controlAccentColor)
setBar(indeterminate: true)
setDetail("Download complete. Loading the model into memory…")
hintLabel.isHidden = true
@@ -924,7 +974,8 @@ final class DownloadProgressWindow: NSObject {
func showReady(modelName: String) {
phase = .ready
titleLabel.stringValue = "✓ \(modelName) is ready"
titleLabel.stringValue = "\(modelName) is ready"
setTitleIcon("checkmark.circle.fill", color: .systemGreen)
setBar(indeterminate: false, value: 1)
setDetail("It is now the active model. Press ⌃⌥D to dictate.")
hintLabel.isHidden = true
@@ -933,7 +984,8 @@ final class DownloadProgressWindow: NSObject {
func showFailed(modelName: String, message: String) {
phase = .failed
titleLabel.stringValue = "⚠️ Could not download \(modelName)"
titleLabel.stringValue = "Could not download \(modelName)"
setTitleIcon("exclamationmark.triangle.fill", color: .systemYellow)
busyBar.stopAnimation(nil)
busyBar.isHidden = true
bar.isHidden = true
@@ -951,7 +1003,11 @@ final class DownloadProgressWindow: NSObject {
let v = NSView(frame: NSRect(x: 0, y: 0, width: 440, height: 170))
titleLabel.frame = NSRect(x: 20, y: 130, width: 400, height: 20)
titleIcon.frame = NSRect(x: 20, y: 131, width: 18, height: 18)
titleIcon.symbolConfiguration = .init(pointSize: 14, weight: .semibold)
v.addSubview(titleIcon)
titleLabel.frame = NSRect(x: 44, y: 130, width: 376, height: 20)
titleLabel.font = .boldSystemFont(ofSize: 13)
titleLabel.lineBreakMode = .byTruncatingTail
v.addSubview(titleLabel)
@@ -995,6 +1051,11 @@ final class DownloadProgressWindow: NSObject {
return w
}
private func setTitleIcon(_ name: String, color: NSColor) {
titleIcon.image = NSImage(systemSymbolName: name, accessibilityDescription: nil)
titleIcon.contentTintColor = color
}
private func setBar(indeterminate: Bool, value: Double = 0) {
bar.isHidden = indeterminate
busyBar.isHidden = !indeterminate
@@ -1162,7 +1223,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
if alert.runModal() == .alertFirstButtonReturn {
downloadModel(WhisperModels.available[popup.indexOfSelectedItem])
} else {
updateStatus("⚠️ No model selected")
updateStatus("No model selected", warning: true)
}
}
@@ -1180,7 +1241,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
return
}
guard let url = URL(string: model.url) else {
updateStatus("⚠️ Invalid URL")
updateStatus("Invalid URL", warning: true)
return
}
@@ -1224,7 +1285,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
func failDownload(_ message: String) {
let name = currentDownloadModel?.name ?? "model"
clearDownloadState()
updateStatus("⚠️ Download failed")
updateStatus("Download failed", warning: true)
if playSounds { NSSound(named: "Basso")?.play() }
downloadWindow.showFailed(modelName: name, message: message)
downloadWindow.show()
@@ -1237,24 +1298,43 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
updateDownloadIndicators()
}
// Menu bar icon when idle: download progress while a model downloads, else the mic
var idleIcon: String {
downloadTask != nil ? "⬇️ \(downloadPercent)%" : "🎤"
// Menu bar icon. While idle it shows the download progress if a model is downloading.
enum MenuBarIcon { case idle, recording, busy }
var menuBarIcon = MenuBarIcon.idle { didSet { refreshMenuBarIcon() } }
func refreshMenuBarIcon() {
guard let button = statusItem?.button else { return }
let downloading = menuBarIcon == .idle && downloadTask != nil
switch menuBarIcon {
case .idle:
button.image = downloading
? Icons.symbol("arrow.down.circle", pointSize: 15, description: "WhisperDictate: downloading a model")
: Icons.symbol("mic", pointSize: 15, description: "WhisperDictate")
case .recording:
button.image = Icons.coloredSymbol("mic.fill", pointSize: 15, color: .systemRed, description: "WhisperDictate: recording")
case .busy:
button.image = Icons.symbol("hourglass", pointSize: 15, description: "WhisperDictate: working")
}
button.imagePosition = .imageLeading
button.title = downloading ? "\(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
}
refreshMenuBarIcon()
if let item = statusItem.menu?.item(withTag: 101) {
item.isHidden = currentDownloadModel == nil
if let model = currentDownloadModel {
item.title = "⬇️ Downloading \(model.name): \(downloadPercent)% — Show Progress…"
item.title = "Downloading \(model.name): \(downloadPercent)% — Show Progress…"
}
}
settingsDownloadButton?.title = downloadTask != nil ? "⬇️ \(downloadPercent)%" : "Download..."
if let button = settingsDownloadButton {
let downloading = downloadTask != nil
button.image = downloading ? Icons.symbol("arrow.down.circle", pointSize: 11) : nil
button.imagePosition = .imageLeading
button.title = downloading ? "\(downloadPercent)%" : "Download..."
}
}
// MARK: - URLSessionDownloadDelegate
@@ -1324,7 +1404,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
// MARK: - Status Item
func setupStatusItem() {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
statusItem.button?.title = "🎤"
refreshMenuBarIcon()
let menu = NSMenu()
@@ -1336,6 +1416,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
menu.addItem(statusMenuItem)
let downloadMenuItem = NSMenuItem(title: "", action: #selector(showDownloadWindow), keyEquivalent: "")
downloadMenuItem.image = Icons.symbol("arrow.down.circle")
downloadMenuItem.target = self
downloadMenuItem.tag = 101
downloadMenuItem.isHidden = true
@@ -1734,20 +1815,16 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
func checkModelExists() {
let validation = isValidModelPath(modelPath)
if !validation.valid {
updateStatus("⚠️ \(validation.error ?? "Invalid model")")
updateStatus(validation.error ?? "Invalid model", warning: true)
} 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)"
}
}
}
func updateStatus(_ status: String, warning: Bool = false) {
guard let item = statusItem.menu?.item(withTag: 100) else { return }
item.title = "Status: \(status)"
item.image = warning ? Icons.coloredSymbol("exclamationmark.triangle.fill", pointSize: 13, color: .systemYellow) : nil
}
// MARK: - Permissions
@@ -1840,7 +1917,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
audioRecorder?.isMeteringEnabled = true
audioRecorder?.record()
isRecording = true
statusItem.button?.title = "🔴"
menuBarIcon = .recording
updateStatus("Recording...")
if playSounds { NSSound(named: "Tink")?.play() }
NSLog("Recording started")
@@ -1961,7 +2038,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
let keepHUD = explainLoading || recordingHUD.hasPreview
stopMeter(hideHUD: !keepHUD)
if keepHUD { recordingHUD.showTranscribing(notice: explainLoading ? .loadingModel : nil) }
statusItem.button?.title = "⏳"
menuBarIcon = .busy
updateStatus("Transcribing...")
if playSounds { NSSound(named: "Pop")?.play() }
NSLog("Recording stopped, transcribing...")
@@ -2031,7 +2108,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
}
if !isRecording && !isTranscribing {
statusItem.button?.title = "⏳"
menuBarIcon = .busy
updateStatus("Loading model…")
}
@@ -2043,7 +2120,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
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 !busy { self.menuBarIcon = .idle }
if ok {
if !busy { self.updateStatus("Ready") }
NSLog("whisper-server ready on port \(server.port)")
@@ -2061,8 +2138,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
let modelValidation = isValidModelPath(modelPath)
guard modelValidation.valid else {
DispatchQueue.main.async {
self.statusItem.button?.title = self.idleIcon
self.updateStatus("⚠️ \(modelValidation.error ?? "Invalid model")")
self.menuBarIcon = .idle
self.updateStatus(modelValidation.error ?? "Invalid model", warning: true)
if self.playSounds { NSSound(named: "Basso")?.play() }
}
return
@@ -2070,8 +2147,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
guard SupportedLanguages.isValid(language) else {
DispatchQueue.main.async {
self.statusItem.button?.title = self.idleIcon
self.updateStatus("⚠️ Invalid language")
self.menuBarIcon = .idle
self.updateStatus("Invalid language", warning: true)
if self.playSounds { NSSound(named: "Basso")?.play() }
}
return
@@ -2098,7 +2175,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
if let finalText = finalText, !finalText.isEmpty {
self.pasteText(finalText)
} else {
self.statusItem.button?.title = self.idleIcon
self.menuBarIcon = .idle
self.updateStatus("Ready")
if self.playSounds { NSSound(named: "Basso")?.play() }
NSLog("No speech recognized")
@@ -2111,8 +2188,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
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")
self.menuBarIcon = .idle
self.updateStatus("whisper-cli not found", warning: true)
if self.playSounds { NSSound(named: "Basso")?.play() }
NSLog("whisper-cli not found in PATH")
}
@@ -2187,7 +2264,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
}
}
self.statusItem.button?.title = self.idleIcon
self.menuBarIcon = .idle
self.updateStatus("Ready")
if self.playSounds { NSSound(named: "Glass")?.play() }
}