Initial commit of Edison Voice menu-bar dictation app

This commit is contained in:
2026-09-09 23:49:51 +02:00
commit ad02cfb638
40 changed files with 3305 additions and 0 deletions
@@ -0,0 +1,156 @@
import Foundation
/// One correction that actually fired, kept so history can show whether the dictionary is
/// earning its place.
public struct AppliedCorrection: Codable, Hashable, Sendable {
/// The text as the engine produced it.
public let from: String
/// What it was rewritten to.
public let to: String
/// How many times it fired in this transcript.
public let count: Int
}
/// Rewrites transcribed text using the dictionary's correction pairs.
///
/// This is the guaranteed half of the dictionary. Engine biasing is a nudge — it raises the
/// odds of the right word and promises nothing — so anything that must be correct has to be
/// fixed here, after the fact, deterministically.
///
/// Three rules, all load-bearing:
///
/// **Longest match first.** "Claude Code" is applied before "Claude", so the longer rule
/// isn't pre-empted by a shorter one that overlaps it.
///
/// **Whole matches only.** Every pattern is fenced by word boundaries, so a rule for
/// "cloud code" can never touch "Cloudflare" or the ordinary word "cloud".
///
/// **Glued words still match.** Engines run words together — "CloudCode", "cloud-code" — so
/// the gap between the parts of a phrase is matched as *optional* whitespace or hyphens
/// rather than a literal space.
public struct DictionaryCorrector: Sendable {
private let rules: [Rule]
private struct Rule: Sendable {
let regex: NSRegularExpression
let replacement: String
let trigger: String
}
public init(entries: [DictionaryEntry]) {
// Longest trigger first. Sorting by the trigger's length is what makes "Claude Code"
// win over "Claude" — once the longer rule has rewritten the span, the shorter one
// no longer sees the text it would have matched.
let corrections = entries
.filter { $0.isEnabled && $0.kind == .correction }
.filter { !$0.hear.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
.sorted { $0.hear.count > $1.hear.count }
rules = corrections.compactMap { entry in
guard let regex = Self.makeRegex(for: entry.hear) else { return nil }
return Rule(
regex: regex,
replacement: NSRegularExpression.escapedTemplate(for: entry.write),
trigger: entry.hear
)
}
}
public var isEmpty: Bool { rules.isEmpty }
/// Applies every rule in order.
///
/// - Returns: the rewritten text, plus one `AppliedCorrection` per rule that fired.
public func apply(to text: String) -> (text: String, applied: [AppliedCorrection]) {
guard !rules.isEmpty, !text.isEmpty else { return (text, []) }
// Normalize to NFC before matching. macOS hands back decomposed (NFC vs NFD) strings
// in several places — a filesystem read of the dictionary being the obvious one — and
// "café" decomposed is five scalars where composed is four. The pattern and the text
// must be in the same form or an accented trigger silently never matches. The Windows
// implementation normalizes identically; this is part of the shared contract.
var result = text.precomposedStringWithCanonicalMapping
var applied: [AppliedCorrection] = []
for rule in rules {
let range = NSRange(result.startIndex..., in: result)
let matches = rule.regex.numberOfMatches(in: result, range: range)
guard matches > 0 else { continue }
// Record what the engine actually produced, not the rule's trigger — seeing the
// real mishearing is the point, and it can differ from the trigger in case or
// spacing ("CloudCode" matched by "cloud code").
let firstMatch = rule.regex.firstMatch(in: result, range: range)
let heard = firstMatch
.flatMap { Range($0.range, in: result) }
.map { String(result[$0]) } ?? rule.trigger
result = rule.regex.stringByReplacingMatches(
in: result,
range: range,
withTemplate: rule.replacement
)
applied.append(AppliedCorrection(
from: heard,
to: rule.replacement.replacingOccurrences(of: "\\", with: ""),
count: matches
))
}
return (result, applied)
}
/// Builds the pattern for one trigger phrase.
///
/// The parts are joined with `[\s\-]*` — zero or more spaces or hyphens — which is what
/// catches "CloudCode" and "Cloud-Code" alongside the spaced form.
///
/// The fences are lookarounds on letters and digits rather than `\b`. `\b` would treat a
/// trailing hyphen or apostrophe as a boundary and let a rule bite into a longer word;
/// requiring that no letter or digit sits on either side is the stricter guarantee, and
/// it's what keeps "cloud code" off "Cloudflare".
private static func makeRegex(for trigger: String) -> NSRegularExpression? {
// NFC here too, matching `apply(to:)` — a trigger typed into the UI and a trigger read
// back from the dictionary file can arrive in different normal forms.
let parts = trigger
.precomposedStringWithCanonicalMapping
.trimmingCharacters(in: .whitespacesAndNewlines)
.split(whereSeparator: { $0 == " " || $0 == "-" || $0 == "\t" })
.map { NSRegularExpression.escapedPattern(for: String($0)) }
guard !parts.isEmpty else { return nil }
let body = parts.joined(separator: "[\\s\\-]*")
let pattern = "(?<![\\p{L}\\p{N}])\(body)(?![\\p{L}\\p{N}])"
return try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive])
}
}
// MARK: - Engine biasing
public extension DictionaryCorrector {
/// The phrases to hand the speech engine as context before it transcribes.
///
/// Kept deliberately short. These models drift when given a long context list — on quiet
/// or ambiguous audio they start inventing text from the vocabulary they were primed
/// with, which is a far worse failure than the misspelling it was meant to fix.
static let biasLimit = 40
/// - Returns: the correct spellings — `.term` words and the *write* side of corrections —
/// most recently useful first, capped at `biasLimit`.
static func biasPhrases(from entries: [DictionaryEntry]) -> [String] {
var seen = Set<String>()
var phrases: [String] = []
for entry in entries where entry.isEnabled {
let phrase = entry.write.trimmingCharacters(in: .whitespacesAndNewlines)
guard !phrase.isEmpty, seen.insert(phrase.lowercased()).inserted else { continue }
phrases.append(phrase)
if phrases.count == biasLimit { break }
}
return phrases
}
}
@@ -0,0 +1,116 @@
import Foundation
/// One thing the dictionary knows.
///
/// Two kinds, because the two jobs are genuinely different:
///
/// - `.term` — a word or phrase the engine should know exists: "Anthropic", "Vercel".
/// Feeds engine biasing only; it has no "wrong" spelling to correct.
/// - `.correction` — a mapping: when you hear X, write Y. "cloud code" → "Claude Code".
/// Feeds both biasing (on Y, the correct form) and the correction pass (X → Y).
public struct DictionaryEntry: Identifiable, Codable, Hashable, Sendable {
public enum Kind: String, Codable, Sendable {
case term
case correction
}
public var id: UUID
public var kind: Kind
/// The correct text. For `.term` this is the word itself; for `.correction` it's Y —
/// what gets written. Either way this is what the engine gets biased toward.
public var write: String
/// For `.correction` only: the X in "when you hear X". Empty for `.term`.
public var hear: String
/// Disabled entries stay in the file but stop affecting anything, so you can test
/// whether a rule is helping without deleting it.
public var isEnabled: Bool
public init(id: UUID = UUID(), kind: Kind, write: String, hear: String = "", isEnabled: Bool = true) {
self.id = id
self.kind = kind
self.write = write
self.hear = hear
self.isEnabled = isEnabled
}
public static func term(_ word: String) -> DictionaryEntry {
DictionaryEntry(kind: .term, write: word)
}
public static func correction(hear: String, write: String) -> DictionaryEntry {
DictionaryEntry(kind: .correction, write: write, hear: hear)
}
/// How this entry reads in the plain-text file.
public var fileLine: String {
let body = kind == .correction ? "\(hear) -> \(write)" : write
return isEnabled ? body : "# off: \(body)"
}
}
/// A reason an entry looks likely to fire on text you didn't mean it to.
///
/// Surfaced in the UI when an entry is added — the spec's "warn me if an entry looks like
/// it would match something common". Never blocks; you may genuinely want to rewrite a
/// common word, and it's your dictionary.
public struct DictionaryWarning: Identifiable, Sendable {
public var id: String { message }
public let message: String
/// Ordinary English words that would fire constantly if used as a whole trigger.
/// Deliberately short — this catches the obvious foot-guns, not every possible one.
private static let common: Set<String> = [
"a", "about", "all", "also", "and", "any", "are", "as", "at", "back", "be", "because",
"but", "by", "call", "can", "case", "check", "class", "close", "cloud", "code", "come",
"could", "data", "day", "did", "do", "does", "down", "each", "even", "file", "find",
"first", "for", "from", "get", "give", "go", "good", "great", "group", "had", "has",
"have", "he", "her", "here", "him", "his", "how", "if", "in", "into", "is", "it",
"its", "just", "key", "know", "like", "line", "list", "look", "make", "man", "many",
"may", "me", "more", "most", "my", "need", "new", "no", "not", "now", "number", "of",
"off", "on", "one", "only", "open", "or", "other", "our", "out", "over", "page",
"part", "people", "point", "put", "read", "right", "run", "said", "same", "say",
"see", "set", "she", "should", "show", "side", "so", "some", "state", "still", "such",
"take", "team", "test", "than", "that", "the", "their", "them", "then", "there",
"these", "they", "thing", "think", "this", "time", "to", "two", "type", "up", "us",
"use", "user", "very", "want", "was", "way", "we", "well", "were", "what", "when",
"where", "which", "who", "will", "with", "word", "work", "would", "year", "you",
"your",
]
/// - Returns: warnings for `entry`, or empty if it looks safe.
public static func check(_ entry: DictionaryEntry) -> [DictionaryWarning] {
// Only the trigger side can misfire. A `.term` is never matched against text.
guard entry.kind == .correction else { return [] }
let trigger = entry.hear.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trigger.isEmpty else { return [] }
var warnings: [DictionaryWarning] = []
let words = trigger.lowercased().split(whereSeparator: { $0 == " " || $0 == "-" })
if words.count == 1, let only = words.first {
if common.contains(String(only)) {
warnings.append(DictionaryWarning(
message: "“\(trigger)” is an ordinary word. This will rewrite every use of it, "
+ "not just the ones you mean. Consider a longer phrase."
))
} else if only.count <= 3 {
warnings.append(DictionaryWarning(
message: "“\(trigger)” is very short and will match often. Consider a longer phrase."
))
}
}
if entry.write.trimmingCharacters(in: .whitespacesAndNewlines)
.caseInsensitiveCompare(trigger) == .orderedSame {
warnings.append(DictionaryWarning(
message: "This rewrites “\(trigger)” to itself, so it will never change anything."
))
}
return warnings
}
}
@@ -0,0 +1,174 @@
import Foundation
import Observation
/// The dictionary, persisted as a plain text file you can edit by hand.
///
/// A text file rather than JSON, because the spec asks for something editable outside the UI
/// and JSON is only nominally that — quoting, escaping and a trailing-comma trap for anyone
/// adding a line in a hurry. The format is one entry per line:
///
/// ```
/// Anthropic
/// Vercel
/// cloud code -> Claude Code
/// # off: whisper flow -> Wispr Flow
/// ```
///
/// A bare line is a term. `X -> Y` is a correction. `#` starts a comment, and a disabled
/// entry is written as a `# off:` comment so it survives a round trip through the file
/// without silently disappearing.
///
/// The file is watched, so editing it in a text editor updates the UI live and vice versa.
@MainActor
@Observable
public final class DictionaryStore {
public static let shared = DictionaryStore()
public private(set) var entries: [DictionaryEntry] = []
/// Bumped whenever entries change, so the engine can rebuild its bias list lazily
/// instead of on every transcription.
public private(set) var revision = 0
private var watcher: DispatchSourceFileSystemObject?
/// Set while we're writing, so our own save doesn't read back as an external edit.
private var isSaving = false
public static var fileURL: URL {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("EdisonVoice", isDirectory: true)
try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true)
return base.appendingPathComponent("dictionary.txt")
}
private init() {
load()
startWatching()
}
// MARK: - Editing
func add(_ entry: DictionaryEntry) {
entries.append(entry)
save()
}
func update(_ entry: DictionaryEntry) {
guard let index = entries.firstIndex(where: { $0.id == entry.id }) else { return }
entries[index] = entry
save()
}
func delete(_ entry: DictionaryEntry) {
entries.removeAll { $0.id == entry.id }
save()
}
func delete(ids: Set<UUID>) {
entries.removeAll { ids.contains($0.id) }
save()
}
/// Case- and diacritic-insensitive search across both sides of an entry.
func filtered(by query: String) -> [DictionaryEntry] {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return entries }
return entries.filter {
$0.write.localizedStandardContains(trimmed) || $0.hear.localizedStandardContains(trimmed)
}
}
/// A corrector over the current entries. Rebuilt on demand — compiling a few dozen small
/// regexes is cheap next to transcription, and caching it invites staleness.
public var corrector: DictionaryCorrector { DictionaryCorrector(entries: entries) }
public var biasPhrases: [String] { DictionaryCorrector.biasPhrases(from: entries) }
// MARK: - Persistence
private func load() {
guard let text = try? String(contentsOf: Self.fileURL, encoding: .utf8) else {
entries = []
revision += 1
return
}
entries = Self.parse(text)
revision += 1
}
static func parse(_ text: String) -> [DictionaryEntry] {
text.split(separator: "\n", omittingEmptySubsequences: false).compactMap { rawLine in
var line = rawLine.trimmingCharacters(in: .whitespaces)
guard !line.isEmpty else { return nil }
// `# off:` is a disabled entry; any other comment is just a comment.
var isEnabled = true
if line.hasPrefix("#") {
let stripped = line.dropFirst().trimmingCharacters(in: .whitespaces)
guard stripped.lowercased().hasPrefix("off:") else { return nil }
line = stripped.dropFirst(4).trimmingCharacters(in: .whitespaces)
isEnabled = false
guard !line.isEmpty else { return nil }
}
if let arrow = line.range(of: "->") {
let hear = line[..<arrow.lowerBound].trimmingCharacters(in: .whitespaces)
let write = line[arrow.upperBound...].trimmingCharacters(in: .whitespaces)
guard !hear.isEmpty, !write.isEmpty else { return nil }
return DictionaryEntry(kind: .correction, write: write, hear: hear, isEnabled: isEnabled)
}
return DictionaryEntry(kind: .term, write: line, isEnabled: isEnabled)
}
}
private func save() {
revision += 1
isSaving = true
defer { isSaving = false }
let body = entries.map(\.fileLine).joined(separator: "\n")
let text = Self.header + body + "\n"
try? text.write(to: Self.fileURL, atomically: true, encoding: .utf8)
}
private static let header = """
# Edison Voice dictionary
#
# Anthropic a term — the engine is told this word exists
# cloud code -> Claude Code a correction — when you hear X, write Y
# # off: some rule -> Rule a disabled entry
#
# Edit this file directly if you like; the app picks up changes immediately.
"""
// MARK: - External edits
/// Watches the file so a hand edit shows up in the UI without a relaunch.
///
/// Rearms after every event: an atomic write replaces the inode, so the descriptor we
/// were watching is gone the moment the file changes — including when *we* save.
private func startWatching() {
watcher?.cancel()
let descriptor = open(Self.fileURL.path, O_EVTONLY)
guard descriptor >= 0 else { return }
let source = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: descriptor,
eventMask: [.write, .delete, .rename, .extend],
queue: .main
)
source.setEventHandler { [weak self] in
guard let self else { return }
if !self.isSaving { self.load() }
self.startWatching()
}
source.setCancelHandler { close(descriptor) }
source.resume()
watcher = source
}
}