Initial commit of Edison Voice menu-bar dictation app
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user