5 Commit-ok

Szerző SHA1 Üzenet Dátum
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
4 fájl változott, egészen pontosan 536 új sor hozzáadva és 50 régi sor törölve

Fájl megtekintése

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

Fájl megtekintése

@@ -19,13 +19,18 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.2.1</string> <string>1.3.0</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>2</string> <string>3</string>
<key>LSMinimumSystemVersion</key> <key>LSMinimumSystemVersion</key>
<string>13.0</string> <string>13.0</string>
<key>LSUIElement</key> <key>LSUIElement</key>
<true/> <true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSAppleEventsUsageDescription</key> <key>NSAppleEventsUsageDescription</key>
<string>WhisperDictate needs accessibility access to paste transcribed text.</string> <string>WhisperDictate needs accessibility access to paste transcribed text.</string>
<key>NSHighResolutionCapable</key> <key>NSHighResolutionCapable</key>

Fájl megtekintése

@@ -25,6 +25,17 @@ mkdir -p "$FRAMEWORKS_DIR"
cp "$WHISPER_CLI_REAL" "$MACOS_DIR/whisper-cli" cp "$WHISPER_CLI_REAL" "$MACOS_DIR/whisper-cli"
chmod +x "$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 # List of dylibs to copy
DYLIBS=( DYLIBS=(
"libwhisper.1.dylib" "libwhisper.1.dylib"
@@ -49,9 +60,12 @@ for dylib in "${DYLIBS[@]}"; do
fi fi
done done
# Fix dylib paths in whisper-cli # Fix dylib paths in the bundled executables (whisper-cli + whisper-server)
for dylib in "${DYLIBS[@]}"; do for exe in whisper-cli whisper-server; do
install_name_tool -change "@rpath/$dylib" "@executable_path/../Frameworks/$dylib" "$MACOS_DIR/whisper-cli" 2>/dev/null || true [ -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 done
# Fix dylib paths in each dylib (they reference each other) # Fix dylib paths in each dylib (they reference each other)
@@ -67,8 +81,10 @@ for dylib in "${DYLIBS[@]}"; do
fi fi
done done
# Sign everything # Sign everything (ad-hoc; the release flow re-signs with Developer ID afterward)
codesign --force --sign - "$MACOS_DIR/whisper-cli" 2>/dev/null || true 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 for dylib in "${DYLIBS[@]}"; do
codesign --force --sign - "$FRAMEWORKS_DIR/$dylib" 2>/dev/null || true codesign --force --sign - "$FRAMEWORKS_DIR/$dylib" 2>/dev/null || true
done done

Fájl megtekintése

@@ -3,12 +3,14 @@ import AVFoundation
import Carbon.HIToolbox import Carbon.HIToolbox
import ServiceManagement import ServiceManagement
import ApplicationServices import ApplicationServices
import Darwin
// MARK: - User Defaults Keys // MARK: - User Defaults Keys
struct Defaults { struct Defaults {
static let language = "whisperLanguage" static let language = "whisperLanguage"
static let modelPath = "whisperModelPath" static let modelPath = "whisperModelPath"
static let playSounds = "playSounds" static let playSounds = "playSounds"
static let replacements = "customReplacements"
} }
// MARK: - Whisper Models // 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 ? "🎤 Nincs hangbemenet?" : "🎤 Felvétel…"
label.draw(at: NSPoint(x: 16, y: bounds.height - 26), withAttributes: [
.font: NSFont.systemFont(ofSize: 12, weight: .medium),
.foregroundColor: silent ? NSColor.systemRed : NSColor.white
])
let segs = 20
let margin: CGFloat = 16, gap: CGFloat = 3, segH: CGFloat = 14, y: CGFloat = 14
let segW = (bounds.width - margin * 2 - gap * CGFloat(segs - 1)) / CGFloat(segs)
let lit = Int((level * CGFloat(segs)).rounded())
for i in 0..<segs {
let rect = NSRect(x: margin + CGFloat(i) * (segW + gap), y: y, width: segW, height: segH)
let path = NSBezierPath(roundedRect: rect, xRadius: 2, yRadius: 2)
if i < lit {
let frac = CGFloat(i) / CGFloat(segs)
(silent ? NSColor.systemRed
: frac > 0.85 ? .systemRed
: frac > 0.6 ? .systemYellow : .systemGreen).setFill()
} else {
NSColor(calibratedWhite: 1, alpha: 0.12).setFill()
}
path.fill()
}
}
}
final class RecordingHUD {
private var window: NSWindow?
private let meter = MeterView(frame: NSRect(x: 0, y: 0, width: 260, height: 72))
func show() {
if window == nil {
let w = NSPanel(contentRect: NSRect(x: 0, y: 0, width: 260, height: 72),
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered, defer: false)
w.isOpaque = false
w.backgroundColor = .clear
w.hasShadow = true
w.level = .statusBar
w.ignoresMouseEvents = true
w.isFloatingPanel = true
w.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary, .ignoresCycle]
w.contentView = meter
window = w
}
if let screen = NSScreen.main, let window = window {
let vf = screen.visibleFrame
window.setFrameOrigin(NSPoint(x: vf.midX - window.frame.width / 2, y: vf.minY + 120))
}
meter.reset()
window?.orderFrontRegardless()
}
func update(level: CGFloat, silent: Bool) { meter.set(level: level, silent: silent) }
func hide() { window?.orderOut(nil) }
}
// MARK: - App Delegate // MARK: - App Delegate
class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate { class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate, NSTableViewDataSource, NSTableViewDelegate, NSTextFieldDelegate, NSWindowDelegate {
var statusItem: NSStatusItem! var statusItem: NSStatusItem!
var audioRecorder: AVAudioRecorder? var audioRecorder: AVAudioRecorder?
var isRecording = false var isRecording = false
var settingsWindowController: NSWindowController? 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 // Use private temp directory with unique filename
var audioFilePath: String { var audioFilePath: String {
@@ -178,19 +418,51 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
set { UserDefaults.standard.set(newValue, forKey: Defaults.playSounds) } 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) { func applicationDidFinishLaunching(_ notification: Notification) {
setupStatusItem() setupStatusItem()
registerHotkey() registerHotkey()
requestMicrophonePermission() 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() { if !hasAnyModel() {
showFirstRunWizard() showFirstRunWizard()
} else { } else {
checkModelExists() checkModelExists()
startWhisperServer()
} }
checkAccessibilityPermission()
NSLog("WhisperDictate started. Press ⌃⌥D to toggle recording.") NSLog("WhisperDictate started. Press ⌃⌥D to toggle recording.")
} }
@@ -287,6 +559,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
updateStatus("Ready - \(model.name)") updateStatus("Ready - \(model.name)")
if playSounds { NSSound(named: "Glass")?.play() } if playSounds { NSSound(named: "Glass")?.play() }
NSLog("Model downloaded: \(model.name)") NSLog("Model downloaded: \(model.name)")
startWhisperServer()
} catch { } catch {
statusItem.button?.title = "🎤" statusItem.button?.title = "🎤"
updateStatus("⚠️ Save failed") updateStatus("⚠️ Save failed")
@@ -310,6 +583,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
} }
func applicationWillTerminate(_ notification: Notification) { func applicationWillTerminate(_ notification: Notification) {
whisperServer?.stop()
cleanupAudioFile() cleanupAudioFile()
} }
@@ -350,17 +624,18 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
func createSettingsWindow() -> NSWindow { func createSettingsWindow() -> NSWindow {
let window = 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], styleMask: [.titled, .closable],
backing: .buffered, backing: .buffered,
defer: false defer: false
) )
window.title = "WhisperDictate Settings" window.title = "WhisperDictate Settings"
window.center() window.center()
window.delegate = self
let contentView = NSView(frame: window.contentView!.bounds) let contentView = NSView(frame: window.contentView!.bounds)
var y: CGFloat = 230 var y: CGFloat = 498
let labelWidth: CGFloat = 120 let labelWidth: CGFloat = 120
let controlX: CGFloat = 140 let controlX: CGFloat = 140
let controlWidth: CGFloat = 280 let controlWidth: CGFloat = 280
@@ -453,6 +728,44 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
loginCheck.state = isLaunchAtLoginEnabled() ? .on : .off loginCheck.state = isLaunchAtLoginEnabled() ? .on : .off
contentView.addSubview(loginCheck) contentView.addSubview(loginCheck)
// Custom dictionary (recognised -> replacement)
let dictLabel = NSTextField(labelWithString: "Szótár (felismert → csere):")
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 = "Felismert"; colFrom.width = 195; colFrom.isEditable = true
let colTo = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("to"))
colTo.title = "Helyette"; 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: "A „Helyette” oszlop a felismerést is pontosítja. \\n = új sor.")
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 // Models directory hint
let hintLabel = NSTextField(labelWithString: "Models stored in: ~/.whisper-models/") let hintLabel = NSTextField(labelWithString: "Models stored in: ~/.whisper-models/")
hintLabel.frame = NSRect(x: 20, y: 15, width: 410, height: 20) 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 let path = installedModels[index].path
modelPath = path modelPath = path
checkModelExists() checkModelExists()
startWhisperServer()
NSLog("Model changed to: \(path)") NSLog("Model changed to: \(path)")
} }
} }
@@ -549,6 +863,72 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
setLaunchAtLogin(sender.state == .on) 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" ? "felismert szó" : "csere (\\n = új sor)"
}
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 // MARK: - Launch at Login
func isLaunchAtLoginEnabled() -> Bool { func isLaunchAtLoginEnabled() -> Bool {
if #available(macOS 13.0, *) { if #available(macOS 13.0, *) {
@@ -698,21 +1078,58 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
do { do {
audioRecorder = try AVAudioRecorder(url: audioURL, settings: settings) audioRecorder = try AVAudioRecorder(url: audioURL, settings: settings)
audioRecorder?.isMeteringEnabled = true
audioRecorder?.record() audioRecorder?.record()
isRecording = true isRecording = true
statusItem.button?.title = "🔴" statusItem.button?.title = "🔴"
updateStatus("Recording...") updateStatus("Recording...")
if playSounds { NSSound(named: "Tink")?.play() } if playSounds { NSSound(named: "Tink")?.play() }
NSLog("Recording started") NSLog("Recording started")
startMeter()
} catch { } catch {
NSLog("Recording failed: \(error)") NSLog("Recording failed: \(error)")
if playSounds { NSSound(named: "Basso")?.play() } 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() { func stopRecordingAndTranscribe() {
audioRecorder?.stop() audioRecorder?.stop()
isRecording = false isRecording = false
stopMeter()
statusItem.button?.title = "" statusItem.button?.title = ""
updateStatus("Transcribing...") updateStatus("Transcribing...")
if playSounds { NSSound(named: "Pop")?.play() } if playSounds { NSSound(named: "Pop")?.play() }
@@ -749,9 +1166,57 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
return nil 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 // MARK: - Transcription
func transcribe() { func transcribe() {
// Validate inputs before execution
let modelValidation = isValidModelPath(modelPath) let modelValidation = isValidModelPath(modelPath)
guard modelValidation.valid else { guard modelValidation.valid else {
DispatchQueue.main.async { DispatchQueue.main.async {
@@ -771,6 +1236,34 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
return 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 { guard let whisperPath = findWhisperCLI() else {
DispatchQueue.main.async { DispatchQueue.main.async {
self.statusItem.button?.title = "🎤" self.statusItem.button?.title = "🎤"
@@ -778,17 +1271,16 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
if self.playSounds { NSSound(named: "Basso")?.play() } if self.playSounds { NSSound(named: "Basso")?.play() }
NSLog("whisper-cli not found in PATH") NSLog("whisper-cli not found in PATH")
} }
return return nil
} }
let task = Process() let task = Process()
task.executableURL = URL(fileURLWithPath: whisperPath) task.executableURL = URL(fileURLWithPath: whisperPath)
// Build arguments - skip language flag for auto-detect
var args = ["-m", modelPath] var args = ["-m", modelPath]
if language.lowercased() != SupportedLanguages.autoDetect { if language.lowercased() != SupportedLanguages.autoDetect {
args += ["-l", language.lowercased()] args += ["-l", language.lowercased()]
} }
if let prompt = prompt, !prompt.isEmpty { args += ["--prompt", prompt] }
args += ["-f", audioFilePath] args += ["-f", audioFilePath]
task.arguments = args task.arguments = args
@@ -799,45 +1291,18 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
do { do {
try task.run() try task.run()
task.waitUntilExit() task.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile() let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8) ?? "" let output = String(data: data, encoding: .utf8) ?? ""
let lines = output.components(separatedBy: "\n")
var result = "" var result = ""
for line in lines { for line in output.components(separatedBy: "\n") where line.hasPrefix("[") {
if line.hasPrefix("[") {
if let range = line.range(of: "]") { if let range = line.range(of: "]") {
let text = String(line[range.upperBound...]).trimmingCharacters(in: .whitespaces) result += 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")
} }
} }
return result.trimmingCharacters(in: .whitespaces)
} catch { } 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
} }
} }