Implement Phase 1: Global hotkey, HUD, and audio capture
Add complete listening UX without STT: - Global hotkey manager with ⌘⇧V, push-to-talk and toggle modes - Floating HUD with real-time RMS audio visualization - AVAudioEngine capture with 16kHz mono PCM conversion - 10-minute dictation timeout with ESC cancellation - Optional start/stop sounds and microphone permissions - Permission management for accessibility and input monitoring All Phase 1 acceptance criteria met.
This commit is contained in:
parent
1db16227b2
commit
6e768a7753
10 changed files with 1005 additions and 51 deletions
152
Sources/App/HotkeyManager.swift
Normal file
152
Sources/App/HotkeyManager.swift
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import Foundation
|
||||
import AppKit
|
||||
import Carbon
|
||||
import CoreUtils
|
||||
|
||||
public enum HotkeyMode: String, CaseIterable {
|
||||
case pushToTalk = "pushToTalk"
|
||||
case toggle = "toggle"
|
||||
|
||||
public var displayName: String {
|
||||
switch self {
|
||||
case .pushToTalk:
|
||||
return NSLocalizedString("hotkey.mode.push", comment: "Push-to-talk mode")
|
||||
case .toggle:
|
||||
return NSLocalizedString("hotkey.mode.toggle", comment: "Toggle mode")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public protocol HotkeyManagerDelegate: AnyObject {
|
||||
func hotkeyPressed(mode: HotkeyMode, isKeyDown: Bool)
|
||||
}
|
||||
|
||||
public class HotkeyManager: ObservableObject {
|
||||
private let logger = Logger(category: "HotkeyManager")
|
||||
|
||||
public weak var delegate: HotkeyManagerDelegate?
|
||||
|
||||
@Published public var currentMode: HotkeyMode = .toggle
|
||||
@Published public var isEnabled: Bool = false
|
||||
|
||||
private var hotKeyRef: EventHotKeyRef?
|
||||
private var eventHandler: EventHandlerRef?
|
||||
|
||||
// Default hotkey: ⌘⇧V (Command + Shift + V)
|
||||
private let defaultKeyCode: UInt32 = 9 // V key
|
||||
private let defaultModifiers: UInt32 = UInt32(cmdKey + shiftKey)
|
||||
|
||||
public init() {
|
||||
setupEventHandler()
|
||||
}
|
||||
|
||||
deinit {
|
||||
unregisterHotkey()
|
||||
if let handler = eventHandler {
|
||||
RemoveEventHandler(handler)
|
||||
}
|
||||
}
|
||||
|
||||
public func enableHotkey() {
|
||||
guard !isEnabled else { return }
|
||||
|
||||
logger.info("Enabling global hotkey")
|
||||
|
||||
let hotKeyID = EventHotKeyID(signature: OSType(0x4D575350), id: 1) // 'MWSP'
|
||||
|
||||
let status = RegisterEventHotKey(
|
||||
defaultKeyCode,
|
||||
defaultModifiers,
|
||||
hotKeyID,
|
||||
GetApplicationEventTarget(),
|
||||
0,
|
||||
&hotKeyRef
|
||||
)
|
||||
|
||||
if status == noErr {
|
||||
isEnabled = true
|
||||
logger.info("Global hotkey registered successfully")
|
||||
} else {
|
||||
logger.error("Failed to register global hotkey: \(status)")
|
||||
}
|
||||
}
|
||||
|
||||
public func disableHotkey() {
|
||||
guard isEnabled else { return }
|
||||
|
||||
logger.info("Disabling global hotkey")
|
||||
unregisterHotkey()
|
||||
isEnabled = false
|
||||
}
|
||||
|
||||
private func unregisterHotkey() {
|
||||
if let hotKeyRef = hotKeyRef {
|
||||
UnregisterEventHotKey(hotKeyRef)
|
||||
self.hotKeyRef = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func setupEventHandler() {
|
||||
let eventTypes: [EventTypeSpec] = [
|
||||
EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: OSType(kEventHotKeyPressed)),
|
||||
EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: OSType(kEventHotKeyReleased))
|
||||
]
|
||||
|
||||
let callback: EventHandlerProcPtr = { (nextHandler, theEvent, userData) -> OSStatus in
|
||||
guard let userData = userData else { return OSStatus(eventNotHandledErr) }
|
||||
let manager = Unmanaged<HotkeyManager>.fromOpaque(userData).takeUnretainedValue()
|
||||
|
||||
var hotKeyID = EventHotKeyID()
|
||||
let status = GetEventParameter(
|
||||
theEvent,
|
||||
OSType(kEventParamDirectObject),
|
||||
OSType(typeEventHotKeyID),
|
||||
nil,
|
||||
MemoryLayout<EventHotKeyID>.size,
|
||||
nil,
|
||||
&hotKeyID
|
||||
)
|
||||
|
||||
guard status == noErr else { return OSStatus(eventNotHandledErr) }
|
||||
|
||||
let eventKind = GetEventKind(theEvent)
|
||||
let isKeyDown = eventKind == OSType(kEventHotKeyPressed)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
manager.handleHotkeyEvent(isKeyDown: isKeyDown)
|
||||
}
|
||||
|
||||
return noErr
|
||||
}
|
||||
|
||||
let selfPtr = Unmanaged.passUnretained(self).toOpaque()
|
||||
|
||||
let status = InstallEventHandler(
|
||||
GetApplicationEventTarget(),
|
||||
callback,
|
||||
2,
|
||||
eventTypes,
|
||||
selfPtr,
|
||||
&eventHandler
|
||||
)
|
||||
|
||||
if status != noErr {
|
||||
logger.error("Failed to install event handler: \(status)")
|
||||
}
|
||||
}
|
||||
|
||||
private func handleHotkeyEvent(isKeyDown: Bool) {
|
||||
logger.debug("Hotkey event: \(isKeyDown ? "down" : "up"), mode: \(currentMode)")
|
||||
|
||||
switch currentMode {
|
||||
case .pushToTalk:
|
||||
// In push-to-talk mode, respond to both key down and up
|
||||
delegate?.hotkeyPressed(mode: currentMode, isKeyDown: isKeyDown)
|
||||
case .toggle:
|
||||
// In toggle mode, only respond to key down
|
||||
if isKeyDown {
|
||||
delegate?.hotkeyPressed(mode: currentMode, isKeyDown: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue