Browse Source
feat: voice punctuation commands, domain vocabulary, audio device selector, number formatting, and 500-item persistent history
main
feat: voice punctuation commands, domain vocabulary, audio device selector, number formatting, and 500-item persistent history
main
6 changed files with 579 additions and 14 deletions
-
10src/AppDelegate.swift
-
24src/AudioRecorder.swift
-
82src/HistoryManager.swift
-
68src/SonioxSettings.swift
-
180src/StatusBarController.swift
-
229src/TextProcessor.swift
@ -0,0 +1,82 @@ |
|||||
|
import Foundation |
||||
|
import Cocoa |
||||
|
|
||||
|
public struct HistoryItem: Codable, Identifiable { |
||||
|
public let id: String |
||||
|
public let text: String |
||||
|
public let timestamp: Date |
||||
|
|
||||
|
public init(text: String, timestamp: Date = Date()) { |
||||
|
self.id = UUID().uuidString |
||||
|
self.text = text |
||||
|
self.timestamp = timestamp |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public final class HistoryManager { |
||||
|
public static let shared = HistoryManager() |
||||
|
|
||||
|
public let maxItems = 500 |
||||
|
private let fileURL: URL |
||||
|
private let queue = DispatchQueue(label: "com.soniox.history", qos: .utility) |
||||
|
private var cachedItems: [HistoryItem] = [] |
||||
|
|
||||
|
private init() { |
||||
|
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! |
||||
|
let dir = appSupport.appendingPathComponent("SonioxVoice") |
||||
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) |
||||
|
self.fileURL = dir.appendingPathComponent("history.json") |
||||
|
loadFromDisk() |
||||
|
} |
||||
|
|
||||
|
private func loadFromDisk() { |
||||
|
guard let data = try? Data(contentsOf: fileURL), |
||||
|
let items = try? JSONDecoder().decode([HistoryItem].self, from: data) else { |
||||
|
cachedItems = [] |
||||
|
return |
||||
|
} |
||||
|
cachedItems = items.sorted(by: { $0.timestamp > $1.timestamp }) |
||||
|
} |
||||
|
|
||||
|
public func addEntry(_ text: String) { |
||||
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) |
||||
|
guard !trimmed.isEmpty else { return } |
||||
|
|
||||
|
queue.async { |
||||
|
let item = HistoryItem(text: trimmed) |
||||
|
self.cachedItems.insert(item, at: 0) |
||||
|
if self.cachedItems.count > self.maxItems { |
||||
|
self.cachedItems = Array(self.cachedItems.prefix(self.maxItems)) |
||||
|
} |
||||
|
if let data = try? JSONEncoder().encode(self.cachedItems) { |
||||
|
try? data.write(to: self.fileURL, options: .atomic) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public func getRecentEntries(limit: Int = 40) -> [HistoryItem] { |
||||
|
return queue.sync { |
||||
|
let count = min(limit, cachedItems.count) |
||||
|
return Array(cachedItems.prefix(count)) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public func getAllEntries() -> [HistoryItem] { |
||||
|
return queue.sync { |
||||
|
return cachedItems |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public func totalCount() -> Int { |
||||
|
return queue.sync { |
||||
|
return cachedItems.count |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public func clearAll() { |
||||
|
queue.async { |
||||
|
self.cachedItems.removeAll() |
||||
|
try? FileManager.default.removeItem(at: self.fileURL) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,229 @@ |
|||||
|
import Foundation |
||||
|
|
||||
|
public final class TextProcessor { |
||||
|
public static let shared = TextProcessor() |
||||
|
|
||||
|
private init() {} |
||||
|
|
||||
|
public func process(text: String) -> String { |
||||
|
var result = text.trimmingCharacters(in: .whitespacesAndNewlines) |
||||
|
if result.isEmpty { return "" } |
||||
|
|
||||
|
// 1. Voice Punctuation Commands |
||||
|
if SonioxSettings.shared.voicePunctuation { |
||||
|
result = applyVoicePunctuation(result) |
||||
|
} |
||||
|
|
||||
|
// 2. Domain Vocabulary Enhancement |
||||
|
if SonioxSettings.shared.domainVocabulary { |
||||
|
result = applyDomainVocabulary(result) |
||||
|
} |
||||
|
|
||||
|
// 3. Number Formatting |
||||
|
result = applyNumberFormatting(result, format: SonioxSettings.shared.numberFormat) |
||||
|
|
||||
|
// 4. Text Normalization |
||||
|
if SonioxSettings.shared.normalizeText { |
||||
|
result = normalizeText(result) |
||||
|
} |
||||
|
|
||||
|
return result.trimmingCharacters(in: .whitespacesAndNewlines) |
||||
|
} |
||||
|
|
||||
|
private func applyVoicePunctuation(_ text: String) -> String { |
||||
|
var t = " " + text + " " |
||||
|
|
||||
|
// Persian Voice Commands |
||||
|
let persianMappings: [(String, String)] = [ |
||||
|
(" علامت سوال", "؟"), |
||||
|
(" علامت سؤال", "؟"), |
||||
|
(" علامت پرسش", "؟"), |
||||
|
(" علامت تعجب", "!"), |
||||
|
(" نقطه ویرگول", "؛"), |
||||
|
(" خط بعد", "\n"), |
||||
|
(" سطر بعد", "\n"), |
||||
|
(" سر خط", "\n"), |
||||
|
(" اینتر", "\n"), |
||||
|
(" برو خط بعد", "\n"), |
||||
|
(" نقطه", "."), |
||||
|
(" ویرگول", "،"), |
||||
|
(" کاما", "،"), |
||||
|
(" دو نقطه", ":"), |
||||
|
(" سه نقطه", "..."), |
||||
|
(" گیومه باز", " «"), |
||||
|
(" گیومه بسته", "» "), |
||||
|
(" پرانتز باز", " ("), |
||||
|
(" پرانتز بسته", ") ") |
||||
|
] |
||||
|
|
||||
|
for (cmd, rep) in persianMappings { |
||||
|
t = t.replacingOccurrences(of: cmd, with: rep, options: .caseInsensitive) |
||||
|
} |
||||
|
|
||||
|
// English Voice Commands |
||||
|
let englishMappings: [(String, String)] = [ |
||||
|
(" question mark", "?"), |
||||
|
(" exclamation mark", "!"), |
||||
|
(" exclamation point", "!"), |
||||
|
(" semi colon", ";"), |
||||
|
(" semicolon", ";"), |
||||
|
(" new line", "\n"), |
||||
|
(" enter key", "\n"), |
||||
|
(" full stop", "."), |
||||
|
(" period", "."), |
||||
|
(" comma", ","), |
||||
|
(" colon", ":"), |
||||
|
(" ellipsis", "..."), |
||||
|
(" open quote", " \""), |
||||
|
(" close quote", "\" "), |
||||
|
(" open parenthesis", " ("), |
||||
|
(" close parenthesis", ") ") |
||||
|
] |
||||
|
|
||||
|
for (cmd, rep) in englishMappings { |
||||
|
t = t.replacingOccurrences(of: cmd, with: rep, options: .caseInsensitive) |
||||
|
} |
||||
|
|
||||
|
return t |
||||
|
} |
||||
|
|
||||
|
private func applyDomainVocabulary(_ text: String) -> String { |
||||
|
var t = text |
||||
|
let terms: [(String, String)] = [ |
||||
|
("نیوهورایزن", "NewHorizon"), |
||||
|
("نیو هورایزن", "NewHorizon"), |
||||
|
("نیوهوریزن", "NewHorizon"), |
||||
|
("زر محک", "ZarMahak"), |
||||
|
("زرمحک", "ZarMahak"), |
||||
|
("سانی اوکس", "Soniox"), |
||||
|
("سانیوکس", "Soniox"), |
||||
|
("سانی اکس", "Soniox"), |
||||
|
("تیم بای", "TeamBy"), |
||||
|
("تیمبی", "TeamBy"), |
||||
|
("دوودی", "Dovodi"), |
||||
|
("داودی", "Dovodi"), |
||||
|
("داکر", "Docker"), |
||||
|
("کوبرنتیز", "Kubernetes"), |
||||
|
("کوبرنتس", "Kubernetes"), |
||||
|
("ای اس ایکس آی", "ESXi"), |
||||
|
("ای اس ایکس ای", "ESXi"), |
||||
|
("فست ای پی آی", "FastAPI"), |
||||
|
("فست ای پی ای", "FastAPI"), |
||||
|
("پستگرس کیو ال", "PostgreSQL"), |
||||
|
("پستگرس", "PostgreSQL"), |
||||
|
("ردیس", "Redis"), |
||||
|
("فلاتر", "Flutter"), |
||||
|
("وایارگارد", "WireGuard"), |
||||
|
("وایرگارد", "WireGuard"), |
||||
|
("میکروتیک", "MikroTik"), |
||||
|
("هرمس", "Hermes"), |
||||
|
("نکست جی اس", "Next.js"), |
||||
|
("ری اکت", "React"), |
||||
|
("پایتون", "Python"), |
||||
|
("سویفت", "Swift") |
||||
|
] |
||||
|
|
||||
|
for (target, canonical) in terms { |
||||
|
t = t.replacingOccurrences(of: target, with: canonical, options: .caseInsensitive) |
||||
|
} |
||||
|
return t |
||||
|
} |
||||
|
|
||||
|
private func applyNumberFormatting(_ text: String, format: NumberFormatOption) -> String { |
||||
|
switch format { |
||||
|
case .raw: |
||||
|
return text |
||||
|
|
||||
|
case .digits, .persianDigits: |
||||
|
// Convert spoken Persian numbers to digits |
||||
|
var t = " " + text + " " |
||||
|
|
||||
|
// Compound tens and units |
||||
|
let compounds: [(String, String)] = [ |
||||
|
(" بیست و نه ", " 29 "), (" بیست و هشت ", " 28 "), (" بیست و هفت ", " 27 "), (" بیست و شش ", " 26 "), |
||||
|
(" بیست و پنج ", " 25 "), (" بیست و چهار ", " 24 "), (" بیست و سه ", " 23 "), (" بیست و دو ", " 22 "), (" بیست و یک ", " 21 "), |
||||
|
(" سی و نه ", " 39 "), (" سی و هشت ", " 38 "), (" سی و هفت ", " 37 "), (" سی و شش ", " 36 "), |
||||
|
(" سی و پنج ", " 35 "), (" سی و چهار ", " 34 "), (" سی و سه ", " 33 "), (" سی و دو ", " 32 "), (" سی و یک ", " 31 "), |
||||
|
(" چهل و نه ", " 49 "), (" چهل و هشت ", " 48 "), (" چهل و هفت ", " 47 "), (" چهل و شش ", " 46 "), |
||||
|
(" چهل و پنج ", " 45 "), (" چهل و چهار ", " 44 "), (" چهل و سه ", " 43 "), (" چهل و دو ", " 42 "), (" چهل و یک ", " 41 "), |
||||
|
(" پنجاه و نه ", " 59 "), (" پنجاه و هشت ", " 58 "), (" پنجاه و هفت ", " 57 "), (" پنجاه و شش ", " 56 "), |
||||
|
(" پنجاه و پنج ", " 55 "), (" پنجاه و چهار ", " 54 "), (" پنجاه و سه ", " 53 "), (" پنجاه و دو ", " 52 "), (" پنجاه و یک ", " 51 "), |
||||
|
(" شصت و نه ", " 69 "), (" شصت و هشت ", " 68 "), (" شصت و هفت ", " 67 "), (" شصت و شش ", " 66 "), |
||||
|
(" شصت و پنج ", " 65 "), (" شصت و چهار ", " 64 "), (" شصت و سه ", " 63 "), (" شصت و دو ", " 62 "), (" شصت و یک ", " 61 "), |
||||
|
(" هفتاد و نه ", " 79 "), (" هفتاد و هشت ", " 78 "), (" هفتاد و هفت ", " 77 "), (" هفتاد و شش ", " 76 "), |
||||
|
(" هفتاد و پنج ", " 75 "), (" هفتاد و چهار ", " 74 "), (" هفتاد و سه ", " 73 "), (" هفتاد و دو ", " 72 "), (" هفتاد و یک ", " 71 "), |
||||
|
(" هشتاد و نه ", " 89 "), (" هشتاد و هشت ", " 88 "), (" هشتاد و هفت ", " 87 "), (" هشتاد و شش ", " 86 "), |
||||
|
(" هشتاد و پنج ", " 85 "), (" هشتاد و چهار ", " 84 "), (" هشتاد و سه ", " 83 "), (" هشتاد و دو ", " 82 "), (" هشتاد و یک ", " 81 "), |
||||
|
(" نود و نه ", " 99 "), (" نود و هشت ", " 98 "), (" نود و هفت ", " 97 "), (" نود و شش ", " 96 "), |
||||
|
(" نود و پنج ", " 95 "), (" نود و چهار ", " 94 "), (" نود و سه ", " 93 "), (" نود و دو ", " 92 "), (" نود و یک ", " 91 ") |
||||
|
] |
||||
|
|
||||
|
for (word, num) in compounds { |
||||
|
t = t.replacingOccurrences(of: word, with: num) |
||||
|
} |
||||
|
|
||||
|
// Standard single numbers & round numbers |
||||
|
let simples: [(String, String)] = [ |
||||
|
(" ده ", " 10 "), (" یازده ", " 11 "), (" دوازده ", " 12 "), (" سیزده ", " 13 "), (" چهارده ", " 14 "), |
||||
|
(" پانزده ", " 15 "), (" شانزده ", " 16 "), (" هفده ", " 17 "), (" هجده ", " 18 "), (" نوزده ", " 19 "), |
||||
|
(" بیست ", " 20 "), (" سی ", " 30 "), (" چهل ", " 40 "), (" پنجاه ", " 50 "), |
||||
|
(" شصت ", " 60 "), (" هفتاد ", " 70 "), (" هشتاد ", " 80 "), (" نود ", " 90 "), |
||||
|
(" صد ", " 100 "), (" دویست ", " 200 "), (" سیصد ", " 300 "), (" چهارصد ", " 400 "), |
||||
|
(" پانصد ", " 500 "), (" ششصد ", " 600 "), (" هفتصد ", " 700 "), (" هشتصد ", " 800 "), (" نهصد ", " 900 "), |
||||
|
(" هزار ", " 1000 "), (" میلیون ", " 1000000 "), |
||||
|
(" صفر ", " 0 "), (" یک ", " 1 "), (" دو ", " 2 "), (" سه ", " 3 "), (" چهار ", " 4 "), |
||||
|
(" پنج ", " 5 "), (" شش ", " 6 "), (" هفت ", " 7 "), (" هشت ", " 8 "), (" نه ", " 9 ") |
||||
|
] |
||||
|
|
||||
|
for (word, num) in simples { |
||||
|
t = t.replacingOccurrences(of: word, with: num) |
||||
|
} |
||||
|
|
||||
|
if format == .persianDigits { |
||||
|
let pDigits = ["0":"۰", "1":"۱", "2":"۲", "3":"۳", "4":"۴", "5":"۵", "6":"۶", "7":"۷", "8":"۸", "9":"۹"] |
||||
|
for (en, fa) in pDigits { |
||||
|
t = t.replacingOccurrences(of: en, with: fa) |
||||
|
} |
||||
|
} |
||||
|
return t.trimmingCharacters(in: .whitespacesAndNewlines) |
||||
|
|
||||
|
case .words: |
||||
|
var t = text |
||||
|
let toWords = [ |
||||
|
"0": "صفر", "1": "یک", "2": "دو", "3": "سه", "4": "چهار", |
||||
|
"5": "پنج", "6": "شش", "7": "هفت", "8": "هشت", "9": "نه", |
||||
|
"۰": "صفر", "۱": "یک", "۲": "دو", "۳": "سه", "۴": "چهار", |
||||
|
"۵": "پنج", "۶": "شش", "۷": "هفت", "۸": "هشت", "۹": "نه" |
||||
|
] |
||||
|
for (digit, word) in toWords { |
||||
|
t = t.replacingOccurrences(of: digit, with: word) |
||||
|
} |
||||
|
return t |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private func normalizeText(_ text: String) -> String { |
||||
|
var t = text |
||||
|
// Clean double spaces |
||||
|
while t.contains(" ") { |
||||
|
t = t.replacingOccurrences(of: " ", with: " ") |
||||
|
} |
||||
|
// Remove space before punctuation |
||||
|
let punctuationList = [".", "،", "؛", "؟", "!", ":", ")", "»"] |
||||
|
for p in punctuationList { |
||||
|
t = t.replacingOccurrences(of: " " + p, with: p) |
||||
|
} |
||||
|
// Ensure space after punctuation (unless end of line) |
||||
|
for p in [".", "،", "؛", "؟", "!"] { |
||||
|
t = t.replacingOccurrences(of: p, with: p + " ") |
||||
|
} |
||||
|
// Fix spaces inside parentheses / quotes |
||||
|
t = t.replacingOccurrences(of: "( ", with: "(") |
||||
|
t = t.replacingOccurrences(of: "« ", with: "«") |
||||
|
|
||||
|
while t.contains(" ") { |
||||
|
t = t.replacingOccurrences(of: " ", with: " ") |
||||
|
} |
||||
|
return t.trimmingCharacters(in: .whitespacesAndNewlines) |
||||
|
} |
||||
|
} |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue