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