Browse Source

feat: voice punctuation commands, domain vocabulary, audio device selector, number formatting, and 500-item persistent history

main
Ali Alavi 15 hours ago
parent
commit
8524e1f2ec
  1. 10
      src/AppDelegate.swift
  2. 24
      src/AudioRecorder.swift
  3. 82
      src/HistoryManager.swift
  4. 68
      src/SonioxSettings.swift
  5. 180
      src/StatusBarController.swift
  6. 229
      src/TextProcessor.swift

10
src/AppDelegate.swift

@ -121,14 +121,14 @@ public final class AppDelegate: NSObject, NSApplicationDelegate {
case .success(let text): case .success(let text):
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { 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): case .failure(let error):
print("Soniox finalize error:", error) print("Soniox finalize error:", error)
HUDOverlayController.shared.show(state: .error(message: "خطا در اتصال به سرور Soniox"))
} }
} }
} }

24
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 { public func startRecording() throws {
lock.lock() lock.lock()
defer { lock.unlock() } defer { lock.unlock() }
@ -45,8 +65,8 @@ public final class AudioRecorder: NSObject, AVCaptureAudioDataOutputSampleBuffer
if isRecording { return } if isRecording { return }
pcmBuffer.removeAll() 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() let session = AVCaptureSession()

82
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)
}
}
}

68
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 final class SonioxSettings {
public static let shared = 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 { public var addTrailingSpace: Bool {
get { get {
if defaults.object(forKey: "SonioxAddTrailingSpace") == nil { return true } 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() { public func resetToDefaults() {
defaults.removeObject(forKey: "SonioxHotkeyPreset") defaults.removeObject(forKey: "SonioxHotkeyPreset")
defaults.removeObject(forKey: "SonioxDictationMode") defaults.removeObject(forKey: "SonioxDictationMode")
defaults.removeObject(forKey: "SonioxLanguage") defaults.removeObject(forKey: "SonioxLanguage")
defaults.removeObject(forKey: "SonioxNumberFormat")
defaults.removeObject(forKey: "SonioxVoicePunctuation")
defaults.removeObject(forKey: "SonioxDomainVocabulary")
defaults.removeObject(forKey: "SonioxAddTrailingSpace") defaults.removeObject(forKey: "SonioxAddTrailingSpace")
defaults.removeObject(forKey: "SonioxNormalizeText") defaults.removeObject(forKey: "SonioxNormalizeText")
defaults.removeObject(forKey: "SonioxPlaySounds") defaults.removeObject(forKey: "SonioxPlaySounds")
defaults.removeObject(forKey: "SonioxLaunchAtLogin") defaults.removeObject(forKey: "SonioxLaunchAtLogin")
defaults.removeObject(forKey: "SonioxPreferredAudioDevice")
updateLaunchAtLogin(enabled: false) updateLaunchAtLogin(enabled: false)
ensureHardwareKeyRemapping() ensureHardwareKeyRemapping()
HotkeyManager.shared.registerHotkeys() HotkeyManager.shared.registerHotkeys()
@ -109,7 +176,6 @@ public final class SonioxSettings {
} }
public func ensureHardwareKeyRemapping() { 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 script = "/usr/bin/hidutil property --set '{\"UserKeyMapping\":[{\"HIDKeyboardModifierMappingSrc\":3221225679,\"HIDKeyboardModifierMappingDst\":1879048254},{\"HIDKeyboardModifierMappingSrc\":4294967451,\"HIDKeyboardModifierMappingDst\":1879048254}]}' >/dev/null 2>&1"
let task = Process() let task = Process()
task.executableURL = URL(fileURLWithPath: "/bin/sh") task.executableURL = URL(fileURLWithPath: "/bin/sh")

180
src/StatusBarController.swift

@ -2,11 +2,18 @@ import Cocoa
import AVFoundation import AVFoundation
import ApplicationServices import ApplicationServices
public final class StatusBarController {
public final class StatusBarController: NSObject, NSMenuDelegate {
private var statusItem: NSStatusItem? private var statusItem: NSStatusItem?
public var onToggleRecording: (() -> Void)? 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() setupStatusItem()
} }
@ -58,6 +65,7 @@ public final class StatusBarController {
public func buildMenu(isRecording: Bool = false) { public func buildMenu(isRecording: Bool = false) {
let menu = NSMenu() let menu = NSMenu()
menu.autoenablesItems = false menu.autoenablesItems = false
menu.delegate = self
// 1. Primary Action: Start / Stop Recording // 1. Primary Action: Start / Stop Recording
let recordTitle = isRecording ? "⏹️ Stop Recording & Insert Text" : "🎙️ Start Recording (\(HotkeyManager.shared.currentPreset.rawValue))" let recordTitle = isRecording ? "⏹️ Stop Recording & Insert Text" : "🎙️ Start Recording (\(HotkeyManager.shared.currentPreset.rawValue))"
@ -106,11 +114,66 @@ public final class StatusBarController {
langMenuItem.submenu = langMenu langMenuItem.submenu = langMenu
menu.addItem(langMenuItem) 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()) 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 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: "") let trailingSpaceItem = NSMenuItem(title: "␣ Add Trailing Space", action: #selector(toggleTrailingSpaceAction(_:)), keyEquivalent: "")
trailingSpaceItem.target = self trailingSpaceItem.target = self
trailingSpaceItem.state = SonioxSettings.shared.addTrailingSpace ? .on : .off trailingSpaceItem.state = SonioxSettings.shared.addTrailingSpace ? .on : .off
@ -130,7 +193,9 @@ public final class StatusBarController {
advancedMenuItem.submenu = advancedMenu advancedMenuItem.submenu = advancedMenu
menu.addItem(advancedMenuItem) menu.addItem(advancedMenuItem)
// 6. System Permissions
menu.addItem(NSMenuItem.separator())
// 9. System Permissions
let micStatus = AVCaptureDevice.authorizationStatus(for: .audio) let micStatus = AVCaptureDevice.authorizationStatus(for: .audio)
let micTitle = (micStatus == .authorized) ? "✅ Microphone Access: Granted" : "⚠️ Microphone Access: Click to Grant" let micTitle = (micStatus == .authorized) ? "✅ Microphone Access: Granted" : "⚠️ Microphone Access: Click to Grant"
let micItem = NSMenuItem(title: micTitle, action: #selector(openMicrophoneSettings), keyEquivalent: "") let micItem = NSMenuItem(title: micTitle, action: #selector(openMicrophoneSettings), keyEquivalent: "")
@ -151,12 +216,12 @@ public final class StatusBarController {
menu.addItem(NSMenuItem.separator()) menu.addItem(NSMenuItem.separator())
// 7. Reset to Defaults
// 10. Reset to Defaults
let resetItem = NSMenuItem(title: "🔄 Reset to Recommended Defaults", action: #selector(resetDefaultsAction), keyEquivalent: "") let resetItem = NSMenuItem(title: "🔄 Reset to Recommended Defaults", action: #selector(resetDefaultsAction), keyEquivalent: "")
resetItem.target = self resetItem.target = self
menu.addItem(resetItem) menu.addItem(resetItem)
// 8. About & Quit
// 11. About & Quit
let aboutItem = NSMenuItem(title: "ℹ️ About Soniox Voice", action: #selector(aboutAction), keyEquivalent: "") let aboutItem = NSMenuItem(title: "ℹ️ About Soniox Voice", action: #selector(aboutAction), keyEquivalent: "")
aboutItem.target = self aboutItem.target = self
menu.addItem(aboutItem) menu.addItem(aboutItem)
@ -168,6 +233,51 @@ public final class StatusBarController {
statusItem?.menu = menu 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() { @objc private func toggleRecordAction() {
onToggleRecording?() 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) { @objc private func toggleTrailingSpaceAction(_ sender: NSMenuItem) {
SonioxSettings.shared.addTrailingSpace.toggle() SonioxSettings.shared.addTrailingSpace.toggle()
buildMenu() buildMenu()

229
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)
}
}
Loading…
Cancel
Save