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>
This commit is contained in:
@@ -10,6 +10,7 @@ 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
|
||||||
@@ -385,7 +386,7 @@ final class RecordingHUD {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - App Delegate
|
// MARK: - App Delegate
|
||||||
class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
|
class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate, NSTableViewDataSource, NSTableViewDelegate {
|
||||||
var statusItem: NSStatusItem!
|
var statusItem: NSStatusItem!
|
||||||
var audioRecorder: AVAudioRecorder?
|
var audioRecorder: AVAudioRecorder?
|
||||||
var isRecording = false
|
var isRecording = false
|
||||||
@@ -394,6 +395,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
|
|||||||
let recordingHUD = RecordingHUD()
|
let recordingHUD = RecordingHUD()
|
||||||
var meterTimer: Timer?
|
var meterTimer: Timer?
|
||||||
var silenceStart: Date?
|
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 {
|
||||||
@@ -416,6 +418,34 @@ 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()
|
||||||
@@ -594,7 +624,7 @@ 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
|
||||||
@@ -604,7 +634,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
|
|||||||
|
|
||||||
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
|
||||||
@@ -697,6 +727,52 @@ 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; colTo.isEditable = true
|
||||||
|
for col in [colFrom, colTo] {
|
||||||
|
let cell = NSTextFieldCell()
|
||||||
|
cell.isEditable = true
|
||||||
|
cell.isSelectable = true
|
||||||
|
cell.font = NSFont.systemFont(ofSize: 12)
|
||||||
|
cell.lineBreakMode = .byTruncatingTail
|
||||||
|
col.dataCell = cell
|
||||||
|
}
|
||||||
|
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)
|
||||||
@@ -794,6 +870,37 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
|
|||||||
setLaunchAtLogin(sender.state == .on)
|
setLaunchAtLogin(sender.state == .on)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Custom dictionary table (cell-based, editable)
|
||||||
|
func numberOfRows(in tableView: NSTableView) -> Int { replacements.count }
|
||||||
|
|
||||||
|
func tableView(_ tableView: NSTableView, objectValueFor tableColumn: NSTableColumn?, row: Int) -> Any? {
|
||||||
|
guard row < replacements.count, let id = tableColumn?.identifier.rawValue else { return nil }
|
||||||
|
return replacements[row][id] ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: NSTableView, setObjectValue object: Any?, for tableColumn: NSTableColumn?, row: Int) {
|
||||||
|
guard row < replacements.count, let id = tableColumn?.identifier.rawValue else { return }
|
||||||
|
var rows = replacements
|
||||||
|
rows[row][id] = (object as? String) ?? ""
|
||||||
|
replacements = rows
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func addReplacement() {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Launch at Login
|
// MARK: - Launch at Login
|
||||||
func isLaunchAtLoginEnabled() -> Bool {
|
func isLaunchAtLoginEnabled() -> Bool {
|
||||||
if #available(macOS 13.0, *) {
|
if #available(macOS 13.0, *) {
|
||||||
@@ -1102,18 +1209,21 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Prefer the warm server (~0.5s); fall back to a per-call whisper-cli spawn.
|
// Prefer the warm server (~0.5s); fall back to a per-call whisper-cli spawn.
|
||||||
|
let prompt = customPrompt()
|
||||||
let result: String?
|
let result: String?
|
||||||
if let server = whisperServer, server.isReady {
|
if let server = whisperServer, server.isReady {
|
||||||
result = server.transcribe(wavPath: audioFilePath, language: language, prompt: nil)
|
result = server.transcribe(wavPath: audioFilePath, language: language, prompt: prompt)
|
||||||
} else {
|
} else {
|
||||||
result = transcribeWithCLI()
|
result = transcribeWithCLI(prompt: prompt)
|
||||||
}
|
}
|
||||||
|
|
||||||
self.cleanupAudioFile()
|
self.cleanupAudioFile()
|
||||||
|
|
||||||
|
let finalText = result.map { applyReplacements($0) }
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
if let result = result, !result.isEmpty {
|
if let finalText = finalText, !finalText.isEmpty {
|
||||||
self.pasteText(result)
|
self.pasteText(finalText)
|
||||||
} else {
|
} else {
|
||||||
self.statusItem.button?.title = "🎤"
|
self.statusItem.button?.title = "🎤"
|
||||||
self.updateStatus("Ready")
|
self.updateStatus("Ready")
|
||||||
@@ -1125,7 +1235,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
|
|||||||
|
|
||||||
// Per-call transcription via whisper-cli (reloads the model every time).
|
// Per-call transcription via whisper-cli (reloads the model every time).
|
||||||
// Fallback for when the warm server is unavailable.
|
// Fallback for when the warm server is unavailable.
|
||||||
func transcribeWithCLI() -> String? {
|
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 = "🎤"
|
||||||
@@ -1142,6 +1252,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
|
|||||||
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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user