وویس تایپینگ سانی اوکس مک
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

571 lines
25 KiB

import Cocoa
import AVFoundation
import ApplicationServices
public final class StatusBarController: NSObject, NSMenuDelegate {
private var statusItem: NSStatusItem?
public var onToggleRecording: (() -> Void)?
private let dateFormatter: DateFormatter = {
let df = DateFormatter()
df.dateFormat = "HH:mm:ss"
return df
}()
public override init() {
super.init()
setupStatusItem()
}
private func setupStatusItem() {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
updateIcon(state: .idle)
buildMenu()
}
public enum State {
case idle
case recording
case transcribing
}
public func updateIcon(state: State) {
guard let button = statusItem?.button else { return }
switch state {
case .idle:
let config = NSImage.SymbolConfiguration(pointSize: 15, weight: .regular)
if let image = NSImage(systemSymbolName: "mic", accessibilityDescription: "Soniox Voice")?.withSymbolConfiguration(config) {
image.isTemplate = true
button.image = image
}
button.contentTintColor = nil
button.toolTip = "Soniox Voice (Ready)"
case .recording:
let config = NSImage.SymbolConfiguration(pointSize: 15, weight: .semibold)
if let image = NSImage(systemSymbolName: "mic.fill", accessibilityDescription: "Recording")?.withSymbolConfiguration(config) {
image.isTemplate = false
button.image = image
button.contentTintColor = NSColor.systemRed
}
button.toolTip = "Recording speech..."
case .transcribing:
let config = NSImage.SymbolConfiguration(pointSize: 15, weight: .semibold)
if let image = NSImage(systemSymbolName: "waveform", accessibilityDescription: "Transcribing")?.withSymbolConfiguration(config) {
image.isTemplate = false
button.image = image
button.contentTintColor = NSColor.systemOrange
}
button.toolTip = "Transcribing with Soniox..."
}
}
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))"
let recordItem = NSMenuItem(title: recordTitle, action: #selector(toggleRecordAction), keyEquivalent: "")
recordItem.target = self
menu.addItem(recordItem)
menu.addItem(NSMenuItem.separator())
// 2. Dictation Mode Submenu
let modeMenu = NSMenu()
for mode in DictationMode.allCases {
let item = NSMenuItem(title: mode.localizedTitle, action: #selector(selectModeAction(_:)), keyEquivalent: "")
item.target = self
item.representedObject = mode
item.state = (HotkeyManager.shared.currentMode == mode) ? .on : .off
modeMenu.addItem(item)
}
let modeMenuItem = NSMenuItem(title: "⚙️ Dictation Mode", action: nil, keyEquivalent: "")
modeMenuItem.submenu = modeMenu
menu.addItem(modeMenuItem)
// 3. Hotkey Preset Submenu
let hotkeyMenu = NSMenu()
for preset in HotkeyPreset.allCases {
let item = NSMenuItem(title: preset.rawValue, action: #selector(selectHotkeyAction(_:)), keyEquivalent: "")
item.target = self
item.representedObject = preset
item.state = (HotkeyManager.shared.currentPreset == preset) ? .on : .off
hotkeyMenu.addItem(item)
}
let hotkeyMenuItem = NSMenuItem(title: "⌨️ Shortcut Key", action: nil, keyEquivalent: "")
hotkeyMenuItem.submenu = hotkeyMenu
menu.addItem(hotkeyMenuItem)
// 4. Recognition Language Submenu
let langMenu = NSMenu()
for lang in RecognitionLanguage.allCases {
let item = NSMenuItem(title: lang.displayName, action: #selector(selectLanguageAction(_:)), keyEquivalent: "")
item.target = self
item.representedObject = lang
item.state = (SonioxSettings.shared.language == lang) ? .on : .off
langMenu.addItem(item)
}
let langMenuItem = NSMenuItem(title: "🌐 Recognition Language", action: nil, keyEquivalent: "")
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())
// 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 slashCmdItem = NSMenuItem(title: "⚡️ Executive Voice Slash Commands", action: #selector(toggleVoiceCommandsAction(_:)), keyEquivalent: "")
slashCmdItem.target = self
slashCmdItem.state = SonioxSettings.shared.voiceCommands ? .on : .off
advancedMenu.addItem(slashCmdItem)
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
advancedMenu.addItem(trailingSpaceItem)
let normalizeItem = NSMenuItem(title: "✨ Persian Text Normalization", action: #selector(toggleNormalizeAction(_:)), keyEquivalent: "")
normalizeItem.target = self
normalizeItem.state = SonioxSettings.shared.normalizeText ? .on : .off
advancedMenu.addItem(normalizeItem)
let soundItem = NSMenuItem(title: "🔊 Sound Feedback", action: #selector(toggleSoundsAction(_:)), keyEquivalent: "")
soundItem.target = self
soundItem.state = SonioxSettings.shared.playSounds ? .on : .off
advancedMenu.addItem(soundItem)
advancedMenu.addItem(NSMenuItem.separator())
let editCmdsItem = NSMenuItem(title: "📝 Customize Voice Commands (JSON)...", action: #selector(editCustomCommandsAction), keyEquivalent: "")
editCmdsItem.target = self
advancedMenu.addItem(editCmdsItem)
let reloadCmdsItem = NSMenuItem(title: "🔄 Reload Custom Commands", action: #selector(reloadCustomCommandsAction), keyEquivalent: "")
reloadCmdsItem.target = self
advancedMenu.addItem(reloadCmdsItem)
let cheatSheetItem = NSMenuItem(title: "📖 Voice Commands Cheat Sheet...", action: #selector(openCheatSheetAction), keyEquivalent: "")
cheatSheetItem.target = self
advancedMenu.addItem(cheatSheetItem)
advancedMenu.addItem(NSMenuItem.separator())
let viewLogItem = NSMenuItem(title: "📜 View Voice Commands Log...", action: #selector(viewVoiceLogAction), keyEquivalent: "")
viewLogItem.target = self
advancedMenu.addItem(viewLogItem)
let openFolderItem = NSMenuItem(title: "📁 Open Logs Folder...", action: #selector(openLogFolderAction), keyEquivalent: "")
openFolderItem.target = self
advancedMenu.addItem(openFolderItem)
let clearLogItem = NSMenuItem(title: "🗑️ Clear Voice Commands Log", action: #selector(clearVoiceLogAction), keyEquivalent: "")
clearLogItem.target = self
advancedMenu.addItem(clearLogItem)
advancedMenu.addItem(NSMenuItem.separator())
let resetWindowMemItem = NSMenuItem(title: "🖥️ Reset Window Memory (Default Full-Screen)", action: #selector(resetWindowMemoryAction), keyEquivalent: "")
resetWindowMemItem.target = self
advancedMenu.addItem(resetWindowMemItem)
let advancedMenuItem = NSMenuItem(title: "🛠️ Advanced Options", action: nil, keyEquivalent: "")
advancedMenuItem.submenu = advancedMenu
menu.addItem(advancedMenuItem)
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: "")
micItem.target = self
menu.addItem(micItem)
let isAxTrusted = AXIsProcessTrusted()
let axTitle = isAxTrusted ? "✅ Accessibility: Granted" : "⚠️ Accessibility: Click to Grant"
let axItem = NSMenuItem(title: axTitle, action: #selector(openAccessibilitySettings), keyEquivalent: "")
axItem.target = self
menu.addItem(axItem)
let launchLogin = SonioxSettings.shared.launchAtLogin
let launchItem = NSMenuItem(title: "🚀 Launch at Login", action: #selector(toggleLaunchAtLoginAction(_:)), keyEquivalent: "")
launchItem.target = self
launchItem.state = launchLogin ? .on : .off
menu.addItem(launchItem)
menu.addItem(NSMenuItem.separator())
// 10. Reset to Defaults
let resetItem = NSMenuItem(title: "🔄 Reset to Recommended Defaults", action: #selector(resetDefaultsAction), keyEquivalent: "")
resetItem.target = self
menu.addItem(resetItem)
// 11. About & Quit
let aboutItem = NSMenuItem(title: "ℹ️ About Soniox Voice", action: #selector(aboutAction), keyEquivalent: "")
aboutItem.target = self
menu.addItem(aboutItem)
let quitItem = NSMenuItem(title: "❌ Quit", action: #selector(quitAction), keyEquivalent: "q")
quitItem.target = self
menu.addItem(quitItem)
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?()
}
@objc private func selectModeAction(_ sender: NSMenuItem) {
if let mode = sender.representedObject as? DictationMode {
HotkeyManager.shared.currentMode = mode
buildMenu()
}
}
@objc private func selectHotkeyAction(_ sender: NSMenuItem) {
if let preset = sender.representedObject as? HotkeyPreset {
HotkeyManager.shared.currentPreset = preset
buildMenu()
}
}
@objc private func selectLanguageAction(_ sender: NSMenuItem) {
if let lang = sender.representedObject as? RecognitionLanguage {
SonioxSettings.shared.language = lang
buildMenu()
}
}
@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 toggleVoiceCommandsAction(_ sender: NSMenuItem) {
SonioxSettings.shared.voiceCommands.toggle()
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()
}
@objc private func toggleNormalizeAction(_ sender: NSMenuItem) {
SonioxSettings.shared.normalizeText.toggle()
buildMenu()
}
@objc private func toggleSoundsAction(_ sender: NSMenuItem) {
SonioxSettings.shared.playSounds.toggle()
buildMenu()
}
@objc private func editCustomCommandsAction() {
CustomCommandManager.shared.openConfigFileInEditor()
}
@objc private func reloadCustomCommandsAction() {
CustomCommandManager.shared.loadCommands()
buildMenu()
let alert = NSAlert()
alert.messageText = "Custom Commands Reloaded"
alert.informativeText = "Successfully reloaded custom voice commands from custom_commands.json."
alert.alertStyle = .informational
alert.addButton(withTitle: "OK")
alert.runModal()
}
@objc private func openCheatSheetAction() {
let helpText = """
=== Soniox Voice Commands Cheat Sheet ===
[Applications & Windows]
• /settings OR /ستینگ -> Open macOS Settings
• /antigravity ide OR /آنتی گرویتی آیدی ای -> Open Antigravity IDE
• /antigravity OR /آنتی گرویتی -> Open Antigravity
• /firefox OR /فایرفاکس -> Open Firefox
• /chrome OR /کروم -> Open Google Chrome
• /telegram OR /تلگرام -> Open Telegram
• /left OR /چپ -> Tile window to Left half
• /right OR /راست -> Tile window to Right half
• /maximize OR /تمام صفحه -> Maximize window
• /center OR /وسط -> Center window on screen
• /hide OR /هاید -> Hide frontmost app
• /quit OR /ببند -> Quit frontmost app
[Browser & App Tabs / Accounts]
• /telegram 1..3 OR /تلگرام ۱..۳ -> Open Telegram & Switch to Account 1..3
• /firefox 1..9 OR /فایرفاکس ۱..۹ -> Open Firefox & Switch to Tab 1..9
• /chrome 1..9 OR /کروم ۱..۹ -> Open Chrome & Switch to Tab 1..9
• /safari 1..9 OR /سافاری ۱..۹ -> Open Safari & Switch to Tab 1..9
• /tab 1..9 OR /تب ۱..۹ -> Jump to Tab 1..9 in active app
• /new tab OR /تب جدید -> Open new tab (Cmd+T)
• /close tab OR /بستن تب -> Close active tab (Cmd+W)
• /reopen tab OR /ری اپن تب -> Reopen closed tab (Cmd+Shift+T)
• /next tab / /prev tab -> Switch tabs
• /reload OR /رفرش -> Reload web page (Cmd+R)
• /search [query] OR /سرچ [موضوع] -> Search on Google
• /youtube [query] OR /یوتیوب [موضوع] -> Search on YouTube
[System Controls]
• /volume up OR /صدا زیاد -> Increase volume (+15%)
• /volume down OR /صدا کم -> Decrease volume (-15%)
• /volume 50 OR /صدا ۵۰ -> Set volume to 50%
• /mute OR /میوت -> Toggle sound mute
• /lock OR /قفل -> Lock screen
• /screenshot OR /اسکرین شات -> Interactive screenshot
• /dark mode OR /دارک مود -> Toggle Dark / Light mode
[Editing & Clipboard]
• /copy OR /کپی -> Copy selection (Cmd+C)
• /paste OR /پیست -> Paste (Cmd+V)
• /cut OR /کات -> Cut selection (Cmd+X)
• /select all OR /انتخاب همه -> Select All (Cmd+A)
• /undo OR /آندو -> Undo (Cmd+Z)
• /redo OR /ریدو -> Redo (Cmd+Shift+Z)
• /save OR /سیو -> Save document (Cmd+S)
• /enter OR /اینتر -> Press Return key
[Custom Voice Commands]
• Customize anytime via 'custom_commands.json' under Advanced Options!
"""
let alert = NSAlert()
alert.messageText = "Soniox Voice Commands Reference"
alert.informativeText = helpText
alert.alertStyle = .informational
alert.addButton(withTitle: "Got it!")
alert.runModal()
}
@objc private func viewVoiceLogAction() {
VoiceCommandLogger.shared.openLogInViewer()
}
@objc private func openLogFolderAction() {
VoiceCommandLogger.shared.openLogFolder()
}
@objc private func clearVoiceLogAction() {
VoiceCommandLogger.shared.clearLog()
let alert = NSAlert()
alert.messageText = "Voice Log Cleared"
alert.informativeText = "The voice commands debug log has been cleared."
alert.alertStyle = .informational
alert.addButton(withTitle: "OK")
alert.runModal()
}
@objc private func resetWindowMemoryAction() {
WindowStateManager.shared.resetAllPreferences()
let alert = NSAlert()
alert.messageText = "Window Memory Reset"
alert.informativeText = "All apps will now open in Full Screen by default unless manually exited."
alert.alertStyle = .informational
alert.addButton(withTitle: "OK")
alert.runModal()
}
@objc private func toggleLaunchAtLoginAction(_ sender: NSMenuItem) {
SonioxSettings.shared.launchAtLogin.toggle()
buildMenu()
}
@objc private func resetDefaultsAction() {
SonioxSettings.shared.resetToDefaults()
buildMenu()
}
@objc private func openMicrophoneSettings() {
if AVCaptureDevice.authorizationStatus(for: .audio) == .notDetermined {
AVCaptureDevice.requestAccess(for: .audio) { _ in
DispatchQueue.main.async {
self.buildMenu()
}
}
} else {
let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone")!
NSWorkspace.shared.open(url)
}
}
@objc private func openAccessibilitySettings() {
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary
AXIsProcessTrustedWithOptions(options)
let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")!
NSWorkspace.shared.open(url)
}
@objc private func aboutAction() {
let alert = NSAlert()
alert.messageText = "Soniox Voice for macOS"
alert.informativeText = "High-accuracy, real-time speech-to-text dictation powered by Soniox AI.\n\nOptimized for macOS Apple Silicon."
alert.alertStyle = .informational
alert.addButton(withTitle: "OK")
alert.runModal()
}
@objc private func quitAction() {
NSApplication.shared.terminate(nil)
}
}