12 Commit-ok

Szerző SHA1 Üzenet Dátum
hariel1985
29d0c0e503 Update README for 1.3.0
Document the warm whisper-server, live mic-level HUD, and custom
dictionary; add them to the feature list and Settings; note the
loopback-only server and the one-time model load at startup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 16:02:20 +02:00
hariel1985
fdf8bf9743 Use English for all UI strings (HUD + dictionary)
The new HUD label and the dictionary table labels/placeholders were in
Hungarian; the rest of the app is English. Translated: "No audio input?",
"Recording…", "Dictionary (heard → replacement):", column titles
"Heard"/"Replacement", and the field placeholders/hint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 09:26:35 +02:00
hariel1985
aa241f55c8 Bump version to 1.3.0
Minor release: warm whisper-server backend (no per-dictation model
reload), live mic-level HUD with silence warning, and a custom
recognised->replacement dictionary that also biases the whisper prompt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 09:20:33 +02:00
hariel1985
913ce5ca1a Fix dictionary table: view-based editable cells, prune empty rows
The cell-based table couldn't be edited reliably (no click-to-edit, no
Tab between columns). Rewritten as a view-based NSTableView where each
cell is an editable NSTextField committed via controlTextDidEndEditing
(Enter/Tab/focus-loss). Tab now moves from "Felismert" to "Helyette".
Rows where both columns are blank are pruned on add and on window close,
so empty rows are never persisted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 09:12:33 +02:00
hariel1985
636a4acf97 Add custom dictionary (recognised -> replacement) with editable GUI
A single editable table in Settings ("Felismert" -> "Helyette"). Two
effects from one list:
- the replacement pairs are applied to every transcript (fix names/jargon,
  and "\n" expands to a real newline for spoken line-break commands);
- the "to" terms are fed to whisper as the initial --prompt to bias
  recognition toward the user's vocabulary.
Stored in UserDefaults; wired through both the warm server and the CLI
fallback. Settings window grown to fit the table + add/remove buttons.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 09:04:15 +02:00
hariel1985
4c680f9e4d Add live mic-level HUD with silence warning
Shows a small floating, non-interactive panel while recording with a
segmented VU meter driven by AVAudioRecorder metering (averagePower).
If the input peak stays at silence for >1.2s it turns red and shows
"Nincs hangbemenet?" — surfacing the dead-mic case that previously
produced empty recordings and whisper hallucinations with no feedback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 08:59:33 +02:00
hariel1985
21be80ddb4 Add warm whisper-server backend (no per-dictation model reload)
Each dictation previously spawned whisper-cli with -m, reloading the full
1.5-1.6GB model (+ ~10s Metal init) every single time. This adds a
WhisperServer manager that keeps a whisper-server child process alive with
the model preloaded, so each utterance is a fast local HTTP POST to
/inference (~0.5s) instead of a multi-second cold start.

- Bundles whisper-server alongside whisper-cli (bundle-whisper.sh).
- Server (re)starts on launch and on model change; stops on terminate;
  picks a free loopback port; -nt (plain text) + -sns (fewer hallucinations).
- transcribe() prefers the warm server and falls back to a per-call
  whisper-cli spawn if the server is unavailable.
- Warm-up now starts BEFORE the blocking accessibility prompt so the model
  load overlaps with the user granting permissions.
- Info.plist: NSAllowsLocalNetworking for the loopback HTTP call.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 08:56:45 +02:00
hariel1985
865b194eeb Bump version to 1.2.1
Patch release shipping the hardened-runtime microphone fix
(device.audio-input + allow-jit entitlements). The previously released
1.2.0 build could not access the microphone once hardened-runtime signed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 06:36:21 +02:00
hariel1985
bd20988fa0 Add cs.allow-jit entitlement for whisper.cpp Metal backend
The bundled whisper-cli / ggml dylibs JIT-compile Metal shaders at
runtime; under the hardened runtime that needs com.apple.security.cs.allow-jit
or the process gets killed. Completes the entitlement set for the
notarized Developer ID build (alongside device.audio-input).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 13:41:51 +02:00
hariel1985
3e96a6e394 Fix microphone under hardened runtime: add audio-input entitlement
The v1.2.0 Developer ID re-sign enabled the hardened runtime but the
entitlements were missing com.apple.security.device.audio-input. Under
the hardened runtime tccd refuses to even prompt for the microphone and
denies kTCCServiceMicrophone outright, so the app recorded pure silence
and whisper hallucinated text on the empty audio. Adding the resource
-access entitlement restores the mic permission prompt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 13:34:05 +02:00
hariel1985
9cd0d3eaa5 Fix version: Info.plist CFBundleShortVersionString 1.0.0 → 1.2.0
The bundle's marketing version was stuck at 1.0.0 while the Makefile and the
shipped releases are 1.2.0. Align it so the app reports its real version.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 21:57:03 +02:00
hariel1985
b0533f85f0 Add entitlements for Developer ID signing + notarization
WhisperDictate.entitlements: non-sandboxed hardened-runtime entitlements
(app-sandbox=false; cs.disable-library-validation for the bundled whisper-cli
+ ggml/whisper dylibs). Used to deep-sign the app with the company Developer ID
identity before notarization. Also ignore the stray Contents/CodeResources
signing artifact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 21:28:29 +02:00
7 fájl változott, egészen pontosan 608 új sor hozzáadva és 80 régi sor törölve

1
.gitignore vendored
Fájl megtekintése

@@ -3,6 +3,7 @@ macos/build/
macos/WhisperDictate.app/Contents/MacOS/
macos/WhisperDictate.app/Contents/Frameworks/
macos/WhisperDictate.app/Contents/_CodeSignature/
macos/WhisperDictate.app/Contents/CodeResources
WhisperDictate
*.o
*.dSYM/

Fájl megtekintése

@@ -12,17 +12,26 @@ A simple menu bar app for voice dictation using OpenAI Whisper (local, offline).
## macOS
### What's new in 1.3.0
-**Warm transcription server** — the Whisper model stays loaded in a background `whisper-server`, so each dictation is a fast local call (~0.5s) instead of reloading the 1.51.6 GB model on every utterance
- 📊 **Live mic-level HUD** while recording, which warns ("No audio input?") if the microphone stays silent
- 📖 **Custom dictionary** — an editable *Heard → Replacement* table that fixes names/jargon in the transcript and also biases recognition
### Features
- 🎤 Global hotkey (⌃⌥D) to start/stop recording
- ⚡ Fast transcription via a warm whisper-server (model stays loaded — no per-dictation reload), with automatic fallback to `whisper-cli`
- 📊 Live mic-level meter while recording, with a "no audio input" warning
- 📖 Custom dictionary (heard → replacement) that also biases recognition
- 🔒 Fully offline - uses local Whisper model
- Automatic paste into any focused app
- ⌨️ Automatic paste into any focused app
- 📋 Clipboard preservation - your copied content is restored after paste
- ⚙️ Settings window with model selection dropdown
- 📥 Built-in model downloader with progress indicator
- 🚀 Launch at login support
- 🔊 Sound feedback (optional)
- 📦 Self-contained - whisper-cli bundled in app
- 📦 Self-contained - whisper-cli and whisper-server bundled in the app
### Requirements
@@ -75,6 +84,7 @@ make install
Click the menu bar icon → Settings to configure:
- **Language**: Auto-detect or 31 supported languages
- **Model**: Select from installed models or download new ones
- **Dictionary**: Add *Heard → Replacement* pairs to fix names/jargon (use `\n` for a newline); the replacement terms also improve recognition
- **Sound feedback**: Toggle audio feedback on/off
- **Launch at login**: Start automatically when you log in
@@ -93,6 +103,8 @@ Download models directly from the app or manually:
Models are stored in `~/.whisper-models/`
> With the warm server (1.3.0+), the selected model loads once at startup (a few seconds); after that each dictation skips the reload, so the speeds above are roughly the per-dictation transcription time.
### Audio Feedback
- 🔔 **Tink** - Recording started
@@ -114,6 +126,7 @@ Grant these in System Settings → Privacy & Security:
## Security
- All processing is done locally - no data leaves your device
- The bundled `whisper-server` is bound to `127.0.0.1` (loopback only) and is never exposed on the network
- Audio files are stored in private temp directory and deleted after transcription
- Clipboard is cleared after paste (transcript doesn't remain accessible)
- Original clipboard content is preserved and restored after paste

Fájl megtekintése

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

Fájl megtekintése

@@ -2,35 +2,40 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>WhisperDictate</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>CFBundleIdentifier</key>
<string>com.whisperdictate.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>WhisperDictate</string>
<key>CFBundleDisplayName</key>
<string>WhisperDictate</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>LSUIElement</key>
<true/>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSMicrophoneUsageDescription</key>
<string>WhisperDictate needs microphone access to record your voice for transcription.</string>
<key>NSAppleEventsUsageDescription</key>
<string>WhisperDictate needs accessibility access to paste transcribed text.</string>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>WhisperDictate</string>
<key>CFBundleExecutable</key>
<string>WhisperDictate</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>CFBundleIdentifier</key>
<string>com.whisperdictate.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>WhisperDictate</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.3.0</string>
<key>CFBundleVersion</key>
<string>3</string>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>LSUIElement</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSAppleEventsUsageDescription</key>
<string>WhisperDictate needs accessibility access to paste transcribed text.</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSMicrophoneUsageDescription</key>
<string>WhisperDictate needs microphone access to record your voice for transcription.</string>
</dict>
</plist>

Fájl megtekintése

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Not sandboxed: WhisperDictate uses global Accessibility / synthetic
keystrokes and spawns a bundled CLI, which the App Sandbox forbids.
Distributed via Developer ID, not the Mac App Store. -->
<key>com.apple.security.app-sandbox</key>
<false/>
<!-- The app bundles whisper-cli + ggml/whisper dylibs in the app. Disabling
library validation lets the process tree load them without tripping the
hardened runtime's same-Team-ID check. -->
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<!-- Hardened-runtime resource-access entitlement. REQUIRED for microphone
access under the hardened runtime: without it tccd refuses to even
prompt and denies kTCCServiceMicrophone outright, so the app records
silence (and whisper hallucinates on the empty audio). Must be present
on any Developer ID / notarized build. -->
<key>com.apple.security.device.audio-input</key>
<true/>
<!-- whisper.cpp's Metal/ggml backend generates code at runtime; the
hardened runtime needs allow-jit so the bundled whisper-cli/dylibs
don't get killed for JIT. -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
</dict>
</plist>

Fájl megtekintése

@@ -25,6 +25,17 @@ mkdir -p "$FRAMEWORKS_DIR"
cp "$WHISPER_CLI_REAL" "$MACOS_DIR/whisper-cli"
chmod +x "$MACOS_DIR/whisper-cli"
# Copy whisper-server (warm, preloaded-model HTTP backend) from the same bin dir
WHISPER_BIN_DIR=$(dirname "$WHISPER_CLI_REAL")
WHISPER_SERVER_REAL="$WHISPER_BIN_DIR/whisper-server"
if [ -f "$WHISPER_SERVER_REAL" ]; then
cp "$WHISPER_SERVER_REAL" "$MACOS_DIR/whisper-server"
chmod +x "$MACOS_DIR/whisper-server"
echo "Copied: whisper-server"
else
echo "Warning: whisper-server not found at $WHISPER_SERVER_REAL"
fi
# List of dylibs to copy
DYLIBS=(
"libwhisper.1.dylib"
@@ -49,9 +60,12 @@ for dylib in "${DYLIBS[@]}"; do
fi
done
# Fix dylib paths in whisper-cli
for dylib in "${DYLIBS[@]}"; do
install_name_tool -change "@rpath/$dylib" "@executable_path/../Frameworks/$dylib" "$MACOS_DIR/whisper-cli" 2>/dev/null || true
# Fix dylib paths in the bundled executables (whisper-cli + whisper-server)
for exe in whisper-cli whisper-server; do
[ -f "$MACOS_DIR/$exe" ] || continue
for dylib in "${DYLIBS[@]}"; do
install_name_tool -change "@rpath/$dylib" "@executable_path/../Frameworks/$dylib" "$MACOS_DIR/$exe" 2>/dev/null || true
done
done
# Fix dylib paths in each dylib (they reference each other)
@@ -67,8 +81,10 @@ for dylib in "${DYLIBS[@]}"; do
fi
done
# Sign everything
codesign --force --sign - "$MACOS_DIR/whisper-cli" 2>/dev/null || true
# Sign everything (ad-hoc; the release flow re-signs with Developer ID afterward)
for exe in whisper-cli whisper-server; do
[ -f "$MACOS_DIR/$exe" ] && codesign --force --sign - "$MACOS_DIR/$exe" 2>/dev/null || true
done
for dylib in "${DYLIBS[@]}"; do
codesign --force --sign - "$FRAMEWORKS_DIR/$dylib" 2>/dev/null || true
done

Fájl megtekintése

@@ -3,12 +3,14 @@ import AVFoundation
import Carbon.HIToolbox
import ServiceManagement
import ApplicationServices
import Darwin
// MARK: - User Defaults Keys
struct Defaults {
static let language = "whisperLanguage"
static let modelPath = "whisperModelPath"
static let playSounds = "playSounds"
static let replacements = "customReplacements"
}
// MARK: - Whisper Models
@@ -150,12 +152,250 @@ struct SupportedLanguages {
}
}
// MARK: - Whisper Server (warm, preloaded-model backend)
// Keeps a whisper-server child process alive with the model already loaded, so
// each dictation is a fast local HTTP call (~0.5s) instead of a multi-second
// model + Metal reload on every utterance (what plain whisper-cli does).
final class WhisperServer {
private let serverPath: String
private let modelPath: String
private let threads: Int
private var process: Process?
private var stopping = false
private(set) var port: Int = 0
private(set) var isReady = false
init(serverPath: String, modelPath: String, threads: Int) {
self.serverPath = serverPath
self.modelPath = modelPath
self.threads = threads
}
func start(onReady: @escaping (Bool) -> Void) {
port = WhisperServer.findFreePort()
let task = Process()
task.executableURL = URL(fileURLWithPath: serverPath)
task.arguments = [
"-m", modelPath,
"--host", "127.0.0.1",
"--port", String(port),
"-t", String(threads),
"-nt", // no timestamps -> plain text response
"-sns", // suppress non-speech tokens -> fewer hallucinations
]
task.standardOutput = FileHandle.nullDevice
task.standardError = FileHandle.nullDevice
task.terminationHandler = { [weak self] _ in self?.isReady = false }
do {
try task.run()
process = task
} catch {
NSLog("WhisperServer: failed to launch: \(error)")
onReady(false)
return
}
// Model load can take ~10s (incl. Metal init); poll the port off-thread.
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
guard let self = self else { return }
let deadline = Date().addingTimeInterval(120)
while Date() < deadline {
if self.stopping || !(self.process?.isRunning ?? false) { break }
if WhisperServer.canConnect(port: self.port) {
self.isReady = true
onReady(true)
return
}
Thread.sleep(forTimeInterval: 0.3)
}
onReady(false)
}
}
func stop() {
stopping = true
isReady = false
process?.terminationHandler = nil
process?.terminate()
process = nil
}
// Synchronous transcription via the warm server. Call OFF the main thread.
func transcribe(wavPath: String, language: String, prompt: String?) -> String? {
guard isReady,
let url = URL(string: "http://127.0.0.1:\(port)/inference"),
let wavData = try? Data(contentsOf: URL(fileURLWithPath: wavPath)) else { return nil }
let boundary = "----WhisperDictate\(UInt32.random(in: 0 ... .max))"
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
req.timeoutInterval = 120
var body = Data()
let nl = "\r\n"
func raw(_ s: String) { body.append(s.data(using: .utf8)!) }
// audio file part
raw("--\(boundary)\(nl)")
raw("Content-Disposition: form-data; name=\"file\"; filename=\"audio.wav\"\(nl)")
raw("Content-Type: audio/wav\(nl)\(nl)")
body.append(wavData)
raw(nl)
// text fields
func field(_ name: String, _ value: String) {
raw("--\(boundary)\(nl)")
raw("Content-Disposition: form-data; name=\"\(name)\"\(nl)\(nl)")
raw("\(value)\(nl)")
}
field("response_format", "json")
field("temperature", "0")
if language.lowercased() != SupportedLanguages.autoDetect { field("language", language.lowercased()) }
if let prompt = prompt, !prompt.isEmpty { field("prompt", prompt) }
raw("--\(boundary)--\(nl)")
req.httpBody = body
let sem = DispatchSemaphore(value: 0)
var result: String?
URLSession.shared.dataTask(with: req) { data, _, error in
defer { sem.signal() }
if let error = error { NSLog("WhisperServer: request error: \(error)"); return }
guard let data = data,
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let text = obj["text"] as? String else { return }
result = text.trimmingCharacters(in: .whitespacesAndNewlines)
}.resume()
sem.wait()
return result
}
// MARK: socket helpers (find a free loopback port + readiness probe)
static func findFreePort() -> Int {
let fd = socket(AF_INET, SOCK_STREAM, 0)
if fd < 0 { return 8123 }
defer { close(fd) }
var addr = sockaddr_in()
addr.sin_family = sa_family_t(AF_INET)
addr.sin_addr.s_addr = inet_addr("127.0.0.1")
addr.sin_port = 0
let bound = withUnsafePointer(to: &addr) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
Darwin.bind(fd, $0, socklen_t(MemoryLayout<sockaddr_in>.size))
}
}
guard bound == 0 else { return 8123 }
var len = socklen_t(MemoryLayout<sockaddr_in>.size)
_ = withUnsafeMutablePointer(to: &addr) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
getsockname(fd, $0, &len)
}
}
return Int(UInt16(bigEndian: addr.sin_port))
}
static func canConnect(port: Int) -> Bool {
let fd = socket(AF_INET, SOCK_STREAM, 0)
if fd < 0 { return false }
defer { close(fd) }
var addr = sockaddr_in()
addr.sin_family = sa_family_t(AF_INET)
addr.sin_port = in_port_t(UInt16(port)).bigEndian
addr.sin_addr.s_addr = inet_addr("127.0.0.1")
let r = withUnsafePointer(to: &addr) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
Darwin.connect(fd, $0, socklen_t(MemoryLayout<sockaddr_in>.size))
}
}
return r == 0
}
}
// MARK: - Recording HUD (live mic level + silence warning)
// A small floating, non-interactive panel shown while recording. Doubles as a
// diagnostic: if the input stays silent it flags "no input" exactly the
// failure that silently produced empty recordings + whisper hallucinations.
final class MeterView: NSView {
private var level: CGFloat = 0
private var silent = false
func set(level: CGFloat, silent: Bool) {
self.level = max(0, min(1, level))
self.silent = silent
needsDisplay = true
}
func reset() { level = 0; silent = false; needsDisplay = true }
override func draw(_ dirtyRect: NSRect) {
let bg = NSBezierPath(roundedRect: bounds, xRadius: 12, yRadius: 12)
NSColor(calibratedWhite: 0.10, alpha: 0.93).setFill()
bg.fill()
let label = silent ? "🎤 No audio input?" : "🎤 Recording…"
label.draw(at: NSPoint(x: 16, y: bounds.height - 26), withAttributes: [
.font: NSFont.systemFont(ofSize: 12, weight: .medium),
.foregroundColor: silent ? NSColor.systemRed : NSColor.white
])
let segs = 20
let margin: CGFloat = 16, gap: CGFloat = 3, segH: CGFloat = 14, y: CGFloat = 14
let segW = (bounds.width - margin * 2 - gap * CGFloat(segs - 1)) / CGFloat(segs)
let lit = Int((level * CGFloat(segs)).rounded())
for i in 0..<segs {
let rect = NSRect(x: margin + CGFloat(i) * (segW + gap), y: y, width: segW, height: segH)
let path = NSBezierPath(roundedRect: rect, xRadius: 2, yRadius: 2)
if i < lit {
let frac = CGFloat(i) / CGFloat(segs)
(silent ? NSColor.systemRed
: frac > 0.85 ? .systemRed
: frac > 0.6 ? .systemYellow : .systemGreen).setFill()
} else {
NSColor(calibratedWhite: 1, alpha: 0.12).setFill()
}
path.fill()
}
}
}
final class RecordingHUD {
private var window: NSWindow?
private let meter = MeterView(frame: NSRect(x: 0, y: 0, width: 260, height: 72))
func show() {
if window == nil {
let w = NSPanel(contentRect: NSRect(x: 0, y: 0, width: 260, height: 72),
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered, defer: false)
w.isOpaque = false
w.backgroundColor = .clear
w.hasShadow = true
w.level = .statusBar
w.ignoresMouseEvents = true
w.isFloatingPanel = true
w.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary, .ignoresCycle]
w.contentView = meter
window = w
}
if let screen = NSScreen.main, let window = window {
let vf = screen.visibleFrame
window.setFrameOrigin(NSPoint(x: vf.midX - window.frame.width / 2, y: vf.minY + 120))
}
meter.reset()
window?.orderFrontRegardless()
}
func update(level: CGFloat, silent: Bool) { meter.set(level: level, silent: silent) }
func hide() { window?.orderOut(nil) }
}
// MARK: - App Delegate
class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate, NSTableViewDataSource, NSTableViewDelegate, NSTextFieldDelegate, NSWindowDelegate {
var statusItem: NSStatusItem!
var audioRecorder: AVAudioRecorder?
var isRecording = false
var settingsWindowController: NSWindowController?
var whisperServer: WhisperServer?
let recordingHUD = RecordingHUD()
var meterTimer: Timer?
var silenceStart: Date?
weak var dictTable: NSTableView?
// Use private temp directory with unique filename
var audioFilePath: String {
@@ -178,19 +418,51 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
set { UserDefaults.standard.set(newValue, forKey: Defaults.playSounds) }
}
// Custom dictionary: ordered list of ["from": recognised, "to": replacement].
var replacements: [[String: String]] {
get { (UserDefaults.standard.array(forKey: Defaults.replacements) as? [[String: String]]) ?? [] }
set { UserDefaults.standard.set(newValue, forKey: Defaults.replacements) }
}
// Whisper initial prompt built from the desired ("to") terms, to bias
// recognition toward the user's names/jargon. Returns nil if empty.
func customPrompt() -> String? {
let terms = replacements.compactMap { $0["to"] }
.map { $0.replacingOccurrences(of: "\\n", with: " ") }
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
return terms.isEmpty ? nil : terms.joined(separator: ", ")
}
// Apply the dictionary find/replace pairs to a transcript. "\n" in a
// replacement becomes a real newline (e.g. spoken-command line break).
func applyReplacements(_ text: String) -> String {
var result = text
for pair in replacements {
guard let from = pair["from"], !from.isEmpty else { continue }
let to = (pair["to"] ?? "").replacingOccurrences(of: "\\n", with: "\n")
result = result.replacingOccurrences(of: from, with: to, options: [.caseInsensitive])
}
return result
}
func applicationDidFinishLaunching(_ notification: Notification) {
setupStatusItem()
registerHotkey()
requestMicrophonePermission()
checkAccessibilityPermission()
// First-run: check if model exists, if not show setup wizard
// Start warming the model server BEFORE the (blocking) accessibility
// prompt, so the multi-second model load overlaps with the user
// granting permissions instead of running after it.
if !hasAnyModel() {
showFirstRunWizard()
} else {
checkModelExists()
startWhisperServer()
}
checkAccessibilityPermission()
NSLog("WhisperDictate started. Press ⌃⌥D to toggle recording.")
}
@@ -287,6 +559,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
updateStatus("Ready - \(model.name)")
if playSounds { NSSound(named: "Glass")?.play() }
NSLog("Model downloaded: \(model.name)")
startWhisperServer()
} catch {
statusItem.button?.title = "🎤"
updateStatus("⚠️ Save failed")
@@ -310,6 +583,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
}
func applicationWillTerminate(_ notification: Notification) {
whisperServer?.stop()
cleanupAudioFile()
}
@@ -350,17 +624,18 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
func createSettingsWindow() -> NSWindow {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 450, height: 280),
contentRect: NSRect(x: 0, y: 0, width: 450, height: 540),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
window.title = "WhisperDictate Settings"
window.center()
window.delegate = self
let contentView = NSView(frame: window.contentView!.bounds)
var y: CGFloat = 230
var y: CGFloat = 498
let labelWidth: CGFloat = 120
let controlX: CGFloat = 140
let controlWidth: CGFloat = 280
@@ -453,6 +728,44 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
loginCheck.state = isLaunchAtLoginEnabled() ? .on : .off
contentView.addSubview(loginCheck)
// Custom dictionary (recognised -> replacement)
let dictLabel = NSTextField(labelWithString: "Dictionary (heard → replacement):")
dictLabel.frame = NSRect(x: 20, y: 304, width: 300, height: 20)
contentView.addSubview(dictLabel)
let scroll = NSScrollView(frame: NSRect(x: 20, y: 104, width: 410, height: 192))
scroll.hasVerticalScroller = true
scroll.borderType = .bezelBorder
let table = NSTableView(frame: scroll.bounds)
table.usesAlternatingRowBackgroundColors = true
table.rowHeight = 22
let colFrom = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("from"))
colFrom.title = "Heard"; colFrom.width = 195; colFrom.isEditable = true
let colTo = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("to"))
colTo.title = "Replacement"; colTo.width = 195
table.addTableColumn(colFrom)
table.addTableColumn(colTo)
table.dataSource = self
table.delegate = self
scroll.documentView = table
contentView.addSubview(scroll)
dictTable = table
let addBtn = NSButton(title: "+", target: self, action: #selector(addReplacement))
addBtn.frame = NSRect(x: 20, y: 70, width: 34, height: 26)
addBtn.bezelStyle = .rounded
contentView.addSubview(addBtn)
let delBtn = NSButton(title: "", target: self, action: #selector(removeReplacement))
delBtn.frame = NSRect(x: 56, y: 70, width: 34, height: 26)
delBtn.bezelStyle = .rounded
contentView.addSubview(delBtn)
let dictHint = NSTextField(labelWithString: "The Replacement column also improves recognition. \\n = newline.")
dictHint.frame = NSRect(x: 100, y: 74, width: 330, height: 18)
dictHint.font = NSFont.systemFont(ofSize: 10)
dictHint.textColor = .tertiaryLabelColor
contentView.addSubview(dictHint)
// Models directory hint
let hintLabel = NSTextField(labelWithString: "Models stored in: ~/.whisper-models/")
hintLabel.frame = NSRect(x: 20, y: 15, width: 410, height: 20)
@@ -487,6 +800,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
let path = installedModels[index].path
modelPath = path
checkModelExists()
startWhisperServer()
NSLog("Model changed to: \(path)")
}
}
@@ -549,6 +863,72 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
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, *) {
@@ -698,21 +1078,58 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
do {
audioRecorder = try AVAudioRecorder(url: audioURL, settings: settings)
audioRecorder?.isMeteringEnabled = true
audioRecorder?.record()
isRecording = true
statusItem.button?.title = "🔴"
updateStatus("Recording...")
if playSounds { NSSound(named: "Tink")?.play() }
NSLog("Recording started")
startMeter()
} catch {
NSLog("Recording failed: \(error)")
if playSounds { NSSound(named: "Basso")?.play() }
}
}
// MARK: - Live mic meter (HUD)
func startMeter() {
silenceStart = nil
recordingHUD.show()
meterTimer?.invalidate()
meterTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
self?.updateMeter()
}
}
func stopMeter() {
meterTimer?.invalidate()
meterTimer = nil
silenceStart = nil
recordingHUD.hide()
}
func updateMeter() {
guard isRecording, let recorder = audioRecorder else { return }
recorder.updateMeters()
let avg = recorder.averagePower(forChannel: 0) // dBFS, ~-160...0
let peak = recorder.peakPower(forChannel: 0)
let level = max(0, min(1, CGFloat((avg + 50) / 50)))
// Flag "no input" if the peak stays at silence for over ~1.2s.
if peak < -50 {
if silenceStart == nil { silenceStart = Date() }
} else {
silenceStart = nil
}
let silent = silenceStart.map { Date().timeIntervalSince($0) > 1.2 } ?? false
recordingHUD.update(level: level, silent: silent)
}
func stopRecordingAndTranscribe() {
audioRecorder?.stop()
isRecording = false
stopMeter()
statusItem.button?.title = ""
updateStatus("Transcribing...")
if playSounds { NSSound(named: "Pop")?.play() }
@@ -749,9 +1166,57 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
return nil
}
func findWhisperServer() -> String? {
if let bundlePath = Bundle.main.executablePath {
let bundled = (bundlePath as NSString).deletingLastPathComponent + "/whisper-server"
if FileManager.default.isExecutableFile(atPath: bundled) { return bundled }
}
let paths = [
"/opt/homebrew/bin/whisper-server",
"/usr/local/bin/whisper-server",
"/opt/local/bin/whisper-server",
NSHomeDirectory() + "/bin/whisper-server"
]
for path in paths where FileManager.default.isExecutableFile(atPath: path) { return path }
return nil
}
// (Re)start the warm whisper-server for the current model. Falls back to
// per-call whisper-cli if the server binary is missing or fails to come up.
func startWhisperServer() {
whisperServer?.stop()
whisperServer = nil
guard let serverPath = findWhisperServer() else {
NSLog("whisper-server not found — using per-call whisper-cli")
return
}
guard isValidModelPath(modelPath).valid else { return }
statusItem.button?.title = ""
updateStatus("Loading model…")
let threads = min(8, max(2, ProcessInfo.processInfo.activeProcessorCount - 2))
let server = WhisperServer(serverPath: serverPath, modelPath: modelPath, threads: threads)
whisperServer = server
server.start { [weak self] ok in
DispatchQueue.main.async {
guard let self = self, self.whisperServer === server else { return }
self.statusItem.button?.title = "🎤"
if ok {
self.updateStatus("Ready")
NSLog("whisper-server ready on port \(server.port)")
} else {
self.updateStatus("Ready (CLI fallback)")
self.whisperServer = nil
NSLog("whisper-server failed to start — falling back to whisper-cli")
}
}
}
}
// MARK: - Transcription
func transcribe() {
// Validate inputs before execution
let modelValidation = isValidModelPath(modelPath)
guard modelValidation.valid else {
DispatchQueue.main.async {
@@ -771,6 +1236,34 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
return
}
// Prefer the warm server (~0.5s); fall back to a per-call whisper-cli spawn.
let prompt = customPrompt()
let result: String?
if let server = whisperServer, server.isReady {
result = server.transcribe(wavPath: audioFilePath, language: language, prompt: prompt)
} else {
result = transcribeWithCLI(prompt: prompt)
}
self.cleanupAudioFile()
let finalText = result.map { applyReplacements($0) }
DispatchQueue.main.async {
if let finalText = finalText, !finalText.isEmpty {
self.pasteText(finalText)
} else {
self.statusItem.button?.title = "🎤"
self.updateStatus("Ready")
if self.playSounds { NSSound(named: "Basso")?.play() }
NSLog("No speech recognized")
}
}
}
// Per-call transcription via whisper-cli (reloads the model every time).
// Fallback for when the warm server is unavailable.
func transcribeWithCLI(prompt: String? = nil) -> String? {
guard let whisperPath = findWhisperCLI() else {
DispatchQueue.main.async {
self.statusItem.button?.title = "🎤"
@@ -778,17 +1271,16 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
if self.playSounds { NSSound(named: "Basso")?.play() }
NSLog("whisper-cli not found in PATH")
}
return
return nil
}
let task = Process()
task.executableURL = URL(fileURLWithPath: whisperPath)
// Build arguments - skip language flag for auto-detect
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
@@ -799,45 +1291,18 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
do {
try task.run()
task.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8) ?? ""
let lines = output.components(separatedBy: "\n")
var result = ""
for line in lines {
if line.hasPrefix("[") {
if let range = line.range(of: "]") {
let text = String(line[range.upperBound...]).trimmingCharacters(in: .whitespaces)
result += text + " "
}
}
}
result = result.trimmingCharacters(in: .whitespaces)
// Cleanup audio file after transcription
self.cleanupAudioFile()
DispatchQueue.main.async {
if !result.isEmpty {
self.pasteText(result)
} else {
self.statusItem.button?.title = "🎤"
self.updateStatus("Ready")
if self.playSounds { NSSound(named: "Basso")?.play() }
NSLog("No speech recognized")
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 {
// Cleanup even on error
self.cleanupAudioFile()
DispatchQueue.main.async {
self.statusItem.button?.title = "🎤"
self.updateStatus("Error")
if self.playSounds { NSSound(named: "Basso")?.play() }
NSLog("Transcription failed: \(error)")
}
NSLog("Transcription failed: \(error)")
return nil
}
}