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:
Felipe M 2025-09-18 20:06:46 +02:00
parent 1db16227b2
commit 6e768a7753
Signed by: fmartingr
GPG key ID: CCFBC5637D4000A8
10 changed files with 1005 additions and 51 deletions

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