Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

## 功能特色

- 🎙️ **一鍵聽寫**:按右 Option(可自訂)開始/停止錄音,自動貼入游標位置
- 🎙️ **一鍵聽寫**:按可自訂快捷鍵開始/停止錄音,自動貼入游標位置
- 🇹🇼 **台灣中文優化**:使用Mac 語音辨識,可選擇 Breeze-ASR-25 模型,繁體中文效果最佳
- ✏️ **自動標點符號**:語音辨識後自動補上逗號、句號、問號等標點
- 🤖 **AI 文字修正**(選用):macOS 26+ 可啟用 Apple Intelligence 進一步精煉標點與語句
Expand Down Expand Up @@ -85,7 +85,7 @@ export SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"

1. 啟動後選單列出現麥克風圖示
2. 在任何輸入框游標點一下(讓視窗取得焦點)
3. **按住右 Option** → 說話 → **放開**,文字自動打入
3. **按住設定中的辨識快捷鍵** → 說話 → **放開**,文字自動打入

> 可在設定中切換為「切換模式」(按一下開始,再按一下停止)

Expand All @@ -97,15 +97,15 @@ export SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"
|------|------|
| 🎙️ 麥克風 | 錄製語音 |
| ♿ 輔助使用 | 模擬 Cmd+V 貼入文字 |
| ⌨️ 輸入監控 | 偵測全域 Right Option 快捷鍵 |
| ⌨️ 輸入監控 | 偵測全域辨識快捷鍵 |

---

## 架構說明

```
按快捷鍵 (Right Option)
└─ HotkeyManager (NSEvent.flagsChanged)
按辨識快捷鍵
└─ HotkeyManager (CGEvent/NSEvent flagsChanged)
└─ AudioRecorder (AVAudioEngine, WAV)
└─ WhisperBridge (stdin/stdout JSON-RPC)
└─ transcribe_server.py
Expand Down Expand Up @@ -146,6 +146,7 @@ backend/
|------|------|
| 模型 | 選擇語音辨識模型 |
| 語言 | 自動偵測 / 指定語言代碼(如 `zh`、`en`) |
| 辨識快捷鍵 | 選擇用來開始/停止錄音的 modifier key |
| 快捷鍵模式 | 按住模式 / 切換模式 |
| 浮動指示器 | 錄音時顯示/隱藏底部面板 |
| 文字修正 | 啟用 Apple Intelligence 標點修正(需 macOS 26+) |
Expand Down
16 changes: 15 additions & 1 deletion TypelessMLX/Sources/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ class AppState: ObservableObject {
@AppStorage("showFloatingOverlay") var showFloatingOverlay: Bool = true
@AppStorage("playSounds") var playSounds: Bool = false
@AppStorage("launchAtLogin") var launchAtLogin: Bool = false
@AppStorage("hotkeyKeyCode") var hotkeyKeyCode: Int = 61 // Right Option
@AppStorage("hotkeyKeyCode") var hotkeyKeyCode: Int = RecognitionHotkey.defaultKeyCode
@AppStorage("language") var language: String = "auto"
@AppStorage("hotkeyMode") var hotkeyMode: String = "toggle" // "toggle" or "hold"
@AppStorage("maxHistoryCount") var maxHistoryCount: Int = 50
Expand Down Expand Up @@ -113,6 +113,20 @@ class AppState: ObservableObject {
Self.availableModels.first { $0.id == selectedModelID } ?? Self.availableModels[0]
}

var recognitionHotkey: RecognitionHotkey {
RecognitionHotkey.resolve(keyCode: hotkeyKeyCode)
}

var hotkeyDisplayName: String {
recognitionHotkey.displayName
}

func setRecognitionHotkey(_ hotkey: RecognitionHotkey) {
guard hotkeyKeyCode != hotkey.keyCode else { return }
objectWillChange.send()
hotkeyKeyCode = hotkey.keyCode
}

/// Resolved model path or HF repo for Python backend
var resolvedModelPath: String {
let model = selectedModel
Expand Down
140 changes: 134 additions & 6 deletions TypelessMLX/Sources/HotkeyManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@ class HotkeyManager {
private var appState: AppState?
private var flagsMonitor: Any?
private var localFlagsMonitor: Any?
private var eventTap: CFMachPort?
private var eventTapRunLoopSource: CFRunLoopSource?
private var isRecording = false
private var recordingStartTime: Date?
private var overlay: RecordingOverlay?
private let lock = NSLock()
private var isProcessing = false
private var hotkeyIsDown = false
private var consecutiveFailures = 0
private static let maxConsecutiveFailures = 3

Expand All @@ -30,13 +33,25 @@ class HotkeyManager {
logInfo("HotkeyManager", "Setup. keyCode=\(appState.hotkeyKeyCode), mode=\(appState.hotkeyMode)")
}

func refreshMonitors() {
lock.lock()
hotkeyIsDown = false
lock.unlock()
setupMonitors()
logInfo("HotkeyManager", "Monitors refreshed")
}

@objc private func handleAudioDeviceLost() {
logError("HotkeyManager", "Audio device lost during recording")
guard let appState = appState else { return }
handleFailure(appState: appState, message: "錄音裝置中斷,請重新連接麥克風")
}

private func setupMonitors() {
teardownMonitors()

setupEventTap()

// Global monitor — fires when other apps are focused
flagsMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.flagsChanged]) { [weak self] event in
self?.handleFlagsChanged(event)
Expand All @@ -50,19 +65,73 @@ class HotkeyManager {
}

private func handleFlagsChanged(_ event: NSEvent) {
guard let appState = appState else { return }

let keyCode = Int(event.keyCode)
guard keyCode == appState.hotkeyKeyCode else { return }
let flags = event.modifierFlags
let flagsRaw = UInt64(flags.rawValue)
handleHotkeyTransition(
keyCode: keyCode,
flagsRaw: flagsRaw,
modifierIsActive: { hotkey in hotkey.modifier.isActive(flags) },
source: "NSEvent.flagsChanged"
)
}

private func handleCGEvent(_ event: CGEvent, type: CGEventType) {
guard type == .flagsChanged else { return }
let keyCode = Int(event.getIntegerValueField(.keyboardEventKeycode))
let flags = event.flags
let flagsRaw = UInt64(flags.rawValue)

handleHotkeyTransition(
keyCode: keyCode,
flagsRaw: flagsRaw,
modifierIsActive: { hotkey in hotkey.modifier.isActive(flags) },
source: "CGEvent.\(type.rawValue)"
)
}

private func handleHotkeyTransition(
keyCode: Int,
flagsRaw: UInt64,
modifierIsActive: (RecognitionHotkey) -> Bool,
source: String
) {
guard let appState = appState else { return }

let isKeyPressed = event.modifierFlags.contains(.option)
let mode = appState.hotkeyMode
let configuredHotkey = appState.recognitionHotkey

lock.lock()
let wasHotkeyDown = hotkeyIsDown
guard configuredHotkey.isRelevant(
eventKeyCode: keyCode,
flagsRaw: flagsRaw,
wasHotkeyDown: wasHotkeyDown
) else {
lock.unlock()
if isConfiguredModifierFamilyEvent(hotkey: configuredHotkey, keyCode: keyCode, flagsRaw: flagsRaw) {
logInfo("HotkeyManager", "Observed non-hotkey \(configuredHotkey.displayName) family event. keyCode=\(keyCode), configured=\(configuredHotkey.keyCode), flags=0x\(String(flagsRaw, radix: 16))")
}
return
}

let isKeyPressed = configuredHotkey.isPressed(
eventKeyCode: keyCode,
flagsRaw: flagsRaw,
modifierIsActive: modifierIsActive(configuredHotkey)
)
guard hotkeyIsDown != isKeyPressed else {
lock.unlock()
logDebug("HotkeyManager", "Ignoring duplicate \(source) keyCode=\(keyCode), down=\(isKeyPressed), flags=0x\(String(flagsRaw, radix: 16))")
return
}
hotkeyIsDown = isKeyPressed
let currentlyRecording = isRecording
let processing = isProcessing
lock.unlock()

logInfo("HotkeyManager", "Hotkey \(isKeyPressed ? "pressed" : "released") via \(source). keyCode=\(keyCode), configured=\(configuredHotkey.keyCode), hotkey=\(configuredHotkey.displayName), mode=\(mode), recording=\(currentlyRecording), processing=\(processing), flags=0x\(String(flagsRaw, radix: 16))")

DispatchQueue.main.async {
if mode == "hold" {
// Hold-to-talk: press=start, release=stop
Expand All @@ -85,6 +154,66 @@ class HotkeyManager {
}
}

private func isConfiguredModifierFamilyEvent(hotkey: RecognitionHotkey, keyCode: Int, flagsRaw: UInt64) -> Bool {
hotkey.relatedKeyCodes.contains(keyCode) || hotkey.isRelevant(eventKeyCode: keyCode, flagsRaw: flagsRaw, wasHotkeyDown: false)
}

private func setupEventTap() {
let mask = CGEventMask(1 << CGEventType.flagsChanged.rawValue)
let userInfo = Unmanaged.passUnretained(self).toOpaque()

guard let tap = CGEvent.tapCreate(
tap: .cgSessionEventTap,
place: .headInsertEventTap,
options: .listenOnly,
eventsOfInterest: mask,
callback: { _, type, event, userInfo in
guard let userInfo else {
return Unmanaged.passUnretained(event)
}

let manager = Unmanaged<HotkeyManager>.fromOpaque(userInfo).takeUnretainedValue()
manager.handleCGEvent(event, type: type)
return Unmanaged.passUnretained(event)
},
userInfo: userInfo
) else {
logWarn("HotkeyManager", "CGEvent tap unavailable; falling back to NSEvent flagsChanged monitors")
return
}

guard let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) else {
CFMachPortInvalidate(tap)
logWarn("HotkeyManager", "Failed to create CGEvent tap run-loop source")
return
}

eventTap = tap
eventTapRunLoopSource = source
CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes)
CGEvent.tapEnable(tap: tap, enable: true)
logInfo("HotkeyManager", "CGEvent tap registered")
}

private func teardownMonitors() {
if let monitor = flagsMonitor {
NSEvent.removeMonitor(monitor)
flagsMonitor = nil
}
if let monitor = localFlagsMonitor {
NSEvent.removeMonitor(monitor)
localFlagsMonitor = nil
}
if let source = eventTapRunLoopSource {
CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes)
eventTapRunLoopSource = nil
}
if let tap = eventTap {
CFMachPortInvalidate(tap)
eventTap = nil
}
}

private func startRecording() {
guard let appState = appState else { return }

Expand Down Expand Up @@ -303,8 +432,7 @@ class HotkeyManager {
}

deinit {
if let monitor = flagsMonitor { NSEvent.removeMonitor(monitor) }
if let monitor = localFlagsMonitor { NSEvent.removeMonitor(monitor) }
teardownMonitors()
NotificationCenter.default.removeObserver(self)
}
}
Loading