From 8524e1f2ec5a1eecfb57766ab0393d1dcd84c3a5 Mon Sep 17 00:00:00 2001 From: Ali Alavi Date: Tue, 15 Sep 2026 17:36:42 +0000 Subject: [PATCH] feat: voice punctuation commands, domain vocabulary, audio device selector, number formatting, and 500-item persistent history --- src/AppDelegate.swift | 10 +- src/AudioRecorder.swift | 24 +++- src/HistoryManager.swift | 82 ++++++++++++ src/SonioxSettings.swift | 68 +++++++++- src/StatusBarController.swift | 180 +++++++++++++++++++++++++- src/TextProcessor.swift | 229 ++++++++++++++++++++++++++++++++++ 6 files changed, 579 insertions(+), 14 deletions(-) create mode 100644 src/HistoryManager.swift create mode 100644 src/TextProcessor.swift diff --git a/src/AppDelegate.swift b/src/AppDelegate.swift index c718c40..d40ee61 100644 --- a/src/AppDelegate.swift +++ b/src/AppDelegate.swift @@ -121,14 +121,14 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { case .success(let text): let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { - HUDOverlayController.shared.show(state: .success(text: trimmed)) - self.pasteTextToFrontmostApp(text: trimmed) - } else { - HUDOverlayController.shared.show(state: .error(message: "متنی تشخیص داده نشد")) + let processed = TextProcessor.shared.process(text: trimmed) + if !processed.isEmpty { + HistoryManager.shared.addEntry(processed) + self.pasteTextToFrontmostApp(text: processed) + } } case .failure(let error): print("Soniox finalize error:", error) - HUDOverlayController.shared.show(state: .error(message: "خطا در اتصال به سرور Soniox")) } } } diff --git a/src/AudioRecorder.swift b/src/AudioRecorder.swift index d712c65..c063fb1 100644 --- a/src/AudioRecorder.swift +++ b/src/AudioRecorder.swift @@ -38,6 +38,26 @@ public final class AudioRecorder: NSObject, AVCaptureAudioDataOutputSampleBuffer } } + public static func availableAudioDevices() -> [AVCaptureDevice] { + var types: [AVCaptureDevice.DeviceType] = [] + if #available(macOS 14.0, *) { + types = [.microphone, .external] + } else { + types = [.builtInMicrophone, .externalUnknown] + } + let session = AVCaptureDevice.DiscoverySession(deviceTypes: types, mediaType: .audio, position: .unspecified) + return session.devices + } + + private func resolveCaptureDevice() -> AVCaptureDevice? { + let all = AudioRecorder.availableAudioDevices() + if let prefID = SonioxSettings.shared.preferredAudioDeviceID, + let matched = all.first(where: { $0.uniqueID == prefID }) { + return matched + } + return AVCaptureDevice.default(for: .audio) ?? all.first + } + public func startRecording() throws { lock.lock() defer { lock.unlock() } @@ -45,8 +65,8 @@ public final class AudioRecorder: NSObject, AVCaptureAudioDataOutputSampleBuffer if isRecording { return } pcmBuffer.removeAll() - guard let device = AVCaptureDevice.default(for: .audio) else { - throw NSError(domain: "AudioRecorder", code: 1, userInfo: [NSLocalizedDescriptionKey: "میکروفونی یافت نشد"]) + guard let device = resolveCaptureDevice() else { + throw NSError(domain: "AudioRecorder", code: 1, userInfo: [NSLocalizedDescriptionKey: "No microphone device found"]) } let session = AVCaptureSession() diff --git a/src/HistoryManager.swift b/src/HistoryManager.swift new file mode 100644 index 0000000..f7ab891 --- /dev/null +++ b/src/HistoryManager.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) + } + } +} diff --git a/src/SonioxSettings.swift b/src/SonioxSettings.swift index 128087e..67764b9 100644 --- a/src/SonioxSettings.swift +++ b/src/SonioxSettings.swift @@ -34,6 +34,26 @@ public enum RecognitionLanguage: String, CaseIterable { } } +public enum NumberFormatOption: String, CaseIterable { + case digits = "digits" + case persianDigits = "persianDigits" + case words = "words" + case raw = "raw" + + public var displayName: String { + switch self { + case .digits: + return "English Digits (1, 2, 3...)" + case .persianDigits: + return "Persian Digits (۱، ۲، ۳...)" + case .words: + return "Persian Words (یک، دو، سه...)" + case .raw: + return "As Recognized (Raw)" + } + } +} + public final class SonioxSettings { public static let shared = SonioxSettings() @@ -54,6 +74,36 @@ public final class SonioxSettings { } } + public var numberFormat: NumberFormatOption { + get { + let val = defaults.string(forKey: "SonioxNumberFormat") ?? NumberFormatOption.digits.rawValue + return NumberFormatOption(rawValue: val) ?? .digits + } + set { + defaults.set(newValue.rawValue, forKey: "SonioxNumberFormat") + } + } + + public var voicePunctuation: Bool { + get { + if defaults.object(forKey: "SonioxVoicePunctuation") == nil { return true } + return defaults.bool(forKey: "SonioxVoicePunctuation") + } + set { + defaults.set(newValue, forKey: "SonioxVoicePunctuation") + } + } + + public var domainVocabulary: Bool { + get { + if defaults.object(forKey: "SonioxDomainVocabulary") == nil { return true } + return defaults.bool(forKey: "SonioxDomainVocabulary") + } + set { + defaults.set(newValue, forKey: "SonioxDomainVocabulary") + } + } + public var addTrailingSpace: Bool { get { if defaults.object(forKey: "SonioxAddTrailingSpace") == nil { return true } @@ -94,14 +144,31 @@ public final class SonioxSettings { } } + public var preferredAudioDeviceID: String? { + get { + return defaults.string(forKey: "SonioxPreferredAudioDevice") + } + set { + if let val = newValue { + defaults.set(val, forKey: "SonioxPreferredAudioDevice") + } else { + defaults.removeObject(forKey: "SonioxPreferredAudioDevice") + } + } + } + public func resetToDefaults() { defaults.removeObject(forKey: "SonioxHotkeyPreset") defaults.removeObject(forKey: "SonioxDictationMode") defaults.removeObject(forKey: "SonioxLanguage") + defaults.removeObject(forKey: "SonioxNumberFormat") + defaults.removeObject(forKey: "SonioxVoicePunctuation") + defaults.removeObject(forKey: "SonioxDomainVocabulary") defaults.removeObject(forKey: "SonioxAddTrailingSpace") defaults.removeObject(forKey: "SonioxNormalizeText") defaults.removeObject(forKey: "SonioxPlaySounds") defaults.removeObject(forKey: "SonioxLaunchAtLogin") + defaults.removeObject(forKey: "SonioxPreferredAudioDevice") updateLaunchAtLogin(enabled: false) ensureHardwareKeyRemapping() HotkeyManager.shared.registerHotkeys() @@ -109,7 +176,6 @@ public final class SonioxSettings { } public func ensureHardwareKeyRemapping() { - // Remap hardware microphone/dictation key (0xC000000CF and 0x10000009B) to F5 (0x70000003E) let script = "/usr/bin/hidutil property --set '{\"UserKeyMapping\":[{\"HIDKeyboardModifierMappingSrc\":3221225679,\"HIDKeyboardModifierMappingDst\":1879048254},{\"HIDKeyboardModifierMappingSrc\":4294967451,\"HIDKeyboardModifierMappingDst\":1879048254}]}' >/dev/null 2>&1" let task = Process() task.executableURL = URL(fileURLWithPath: "/bin/sh") diff --git a/src/StatusBarController.swift b/src/StatusBarController.swift index 59509c7..d0d455f 100644 --- a/src/StatusBarController.swift +++ b/src/StatusBarController.swift @@ -2,11 +2,18 @@ import Cocoa import AVFoundation import ApplicationServices -public final class StatusBarController { +public final class StatusBarController: NSObject, NSMenuDelegate { private var statusItem: NSStatusItem? public var onToggleRecording: (() -> Void)? - public init() { + private let dateFormatter: DateFormatter = { + let df = DateFormatter() + df.dateFormat = "HH:mm:ss" + return df + }() + + public override init() { + super.init() setupStatusItem() } @@ -58,6 +65,7 @@ public final class StatusBarController { public func buildMenu(isRecording: Bool = false) { let menu = NSMenu() menu.autoenablesItems = false + menu.delegate = self // 1. Primary Action: Start / Stop Recording let recordTitle = isRecording ? "⏹️ Stop Recording & Insert Text" : "🎙️ Start Recording (\(HotkeyManager.shared.currentPreset.rawValue))" @@ -106,11 +114,66 @@ public final class StatusBarController { langMenuItem.submenu = langMenu menu.addItem(langMenuItem) + // 5. Microphone Input Device Submenu + let micDeviceMenu = NSMenu() + let availableDevices = AudioRecorder.availableAudioDevices() + let preferredID = SonioxSettings.shared.preferredAudioDeviceID + + // Default device item + let defaultItem = NSMenuItem(title: "System Default Microphone", action: #selector(selectAudioDeviceAction(_:)), keyEquivalent: "") + defaultItem.target = self + defaultItem.representedObject = nil as String? + defaultItem.state = (preferredID == nil) ? .on : .off + micDeviceMenu.addItem(defaultItem) + + if !availableDevices.isEmpty { + micDeviceMenu.addItem(NSMenuItem.separator()) + for device in availableDevices { + let item = NSMenuItem(title: device.localizedName, action: #selector(selectAudioDeviceAction(_:)), keyEquivalent: "") + item.target = self + item.representedObject = device.uniqueID + item.state = (preferredID == device.uniqueID) ? .on : .off + micDeviceMenu.addItem(item) + } + } + let micDeviceMenuItem = NSMenuItem(title: "🎙️ Microphone Input", action: nil, keyEquivalent: "") + micDeviceMenuItem.submenu = micDeviceMenu + menu.addItem(micDeviceMenuItem) + + // 6. Number Formatting Submenu + let numberMenu = NSMenu() + for option in NumberFormatOption.allCases { + let item = NSMenuItem(title: option.displayName, action: #selector(selectNumberFormatAction(_:)), keyEquivalent: "") + item.target = self + item.representedObject = option + item.state = (SonioxSettings.shared.numberFormat == option) ? .on : .off + numberMenu.addItem(item) + } + let numberMenuItem = NSMenuItem(title: "🔢 Number Formatting", action: nil, keyEquivalent: "") + numberMenuItem.submenu = numberMenu + menu.addItem(numberMenuItem) + menu.addItem(NSMenuItem.separator()) - // 5. Advanced Insertion Settings Submenu + // 7. History Submenu (Last 500 entries) + let historyMenu = buildHistorySubmenu() + let historyMenuItem = NSMenuItem(title: "🕒 History (\(HistoryManager.shared.totalCount()) of 500)", action: nil, keyEquivalent: "") + historyMenuItem.submenu = historyMenu + menu.addItem(historyMenuItem) + + // 8. Advanced Options Submenu let advancedMenu = NSMenu() + let punctItem = NSMenuItem(title: "🗣️ Voice Punctuation Commands", action: #selector(togglePunctuationAction(_:)), keyEquivalent: "") + punctItem.target = self + punctItem.state = SonioxSettings.shared.voicePunctuation ? .on : .off + advancedMenu.addItem(punctItem) + + let vocabItem = NSMenuItem(title: "📚 Domain Vocabulary Enhancement", action: #selector(toggleDomainVocabAction(_:)), keyEquivalent: "") + vocabItem.target = self + vocabItem.state = SonioxSettings.shared.domainVocabulary ? .on : .off + advancedMenu.addItem(vocabItem) + let trailingSpaceItem = NSMenuItem(title: "␣ Add Trailing Space", action: #selector(toggleTrailingSpaceAction(_:)), keyEquivalent: "") trailingSpaceItem.target = self trailingSpaceItem.state = SonioxSettings.shared.addTrailingSpace ? .on : .off @@ -130,7 +193,9 @@ public final class StatusBarController { advancedMenuItem.submenu = advancedMenu menu.addItem(advancedMenuItem) - // 6. System Permissions + menu.addItem(NSMenuItem.separator()) + + // 9. System Permissions let micStatus = AVCaptureDevice.authorizationStatus(for: .audio) let micTitle = (micStatus == .authorized) ? "✅ Microphone Access: Granted" : "⚠️ Microphone Access: Click to Grant" let micItem = NSMenuItem(title: micTitle, action: #selector(openMicrophoneSettings), keyEquivalent: "") @@ -151,12 +216,12 @@ public final class StatusBarController { menu.addItem(NSMenuItem.separator()) - // 7. Reset to Defaults + // 10. Reset to Defaults let resetItem = NSMenuItem(title: "🔄 Reset to Recommended Defaults", action: #selector(resetDefaultsAction), keyEquivalent: "") resetItem.target = self menu.addItem(resetItem) - // 8. About & Quit + // 11. About & Quit let aboutItem = NSMenuItem(title: "ℹ️ About Soniox Voice", action: #selector(aboutAction), keyEquivalent: "") aboutItem.target = self menu.addItem(aboutItem) @@ -168,6 +233,51 @@ public final class StatusBarController { statusItem?.menu = menu } + private func buildHistorySubmenu() -> NSMenu { + let historyMenu = NSMenu() + let items = HistoryManager.shared.getRecentEntries(limit: 35) + + if items.isEmpty { + let emptyItem = NSMenuItem(title: "No dictation history yet", action: nil, keyEquivalent: "") + emptyItem.isEnabled = false + historyMenu.addItem(emptyItem) + } else { + let count = HistoryManager.shared.totalCount() + let header = NSMenuItem(title: "— Recent Transcriptions (Total: \(count)) —", action: nil, keyEquivalent: "") + header.isEnabled = false + historyMenu.addItem(header) + historyMenu.addItem(NSMenuItem.separator()) + + for item in items { + let timeStr = dateFormatter.string(from: item.timestamp) + let preview = item.text.count > 45 ? String(item.text.prefix(45)) + "…" : item.text + let title = "\(timeStr) \(preview)" + let menuItem = NSMenuItem(title: title, action: #selector(historyItemClicked(_:)), keyEquivalent: "") + menuItem.target = self + menuItem.representedObject = item.text + menuItem.toolTip = "\(item.text)\n\n(Click to copy & paste)" + historyMenu.addItem(menuItem) + } + + historyMenu.addItem(NSMenuItem.separator()) + + let copyAllItem = NSMenuItem(title: "📋 Copy All History to Clipboard", action: #selector(copyAllHistoryAction), keyEquivalent: "") + copyAllItem.target = self + historyMenu.addItem(copyAllItem) + + let clearItem = NSMenuItem(title: "🗑️ Clear History (All \(count) Items)", action: #selector(clearHistoryAction), keyEquivalent: "") + clearItem.target = self + historyMenu.addItem(clearItem) + } + + return historyMenu + } + + public func menuWillOpen(_ menu: NSMenu) { + // Refresh menu dynamically on hover/open to show live history & devices + buildMenu() + } + @objc private func toggleRecordAction() { onToggleRecording?() } @@ -193,6 +303,64 @@ public final class StatusBarController { } } + @objc private func selectAudioDeviceAction(_ sender: NSMenuItem) { + let deviceID = sender.representedObject as? String + SonioxSettings.shared.preferredAudioDeviceID = deviceID + buildMenu() + } + + @objc private func selectNumberFormatAction(_ sender: NSMenuItem) { + if let format = sender.representedObject as? NumberFormatOption { + SonioxSettings.shared.numberFormat = format + buildMenu() + } + } + + @objc private func historyItemClicked(_ sender: NSMenuItem) { + guard let text = sender.representedObject as? String else { return } + let pb = NSPasteboard.general + pb.clearContents() + pb.setString(text, forType: .string) + + // Instant paste + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + let source = CGEventSource(stateID: .combinedSessionState) + let vKeyCode: CGKeyCode = 0x09 + if let down = CGEvent(keyboardEventSource: source, virtualKey: vKeyCode, keyDown: true), + let up = CGEvent(keyboardEventSource: source, virtualKey: vKeyCode, keyDown: false) { + down.flags = .maskCommand + up.flags = .maskCommand + down.post(tap: .cghidEventTap) + up.post(tap: .cghidEventTap) + } + } + } + + @objc private func copyAllHistoryAction() { + let all = HistoryManager.shared.getAllEntries() + guard !all.isEmpty else { return } + + let formatted = all.map { "[\(self.dateFormatter.string(from: $0.timestamp))] \($0.text)" }.joined(separator: "\n") + let pb = NSPasteboard.general + pb.clearContents() + pb.setString(formatted, forType: .string) + } + + @objc private func clearHistoryAction() { + HistoryManager.shared.clearAll() + buildMenu() + } + + @objc private func togglePunctuationAction(_ sender: NSMenuItem) { + SonioxSettings.shared.voicePunctuation.toggle() + buildMenu() + } + + @objc private func toggleDomainVocabAction(_ sender: NSMenuItem) { + SonioxSettings.shared.domainVocabulary.toggle() + buildMenu() + } + @objc private func toggleTrailingSpaceAction(_ sender: NSMenuItem) { SonioxSettings.shared.addTrailingSpace.toggle() buildMenu() diff --git a/src/TextProcessor.swift b/src/TextProcessor.swift new file mode 100644 index 0000000..caee9d7 --- /dev/null +++ b/src/TextProcessor.swift @@ -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) + } +}