Phase 1+2 구현: Electron 뼈대 + STT/핫키/오케스트레이터
Phase 1: - 프로젝트 초기화 (TypeScript strict, electron-vite, ESLint, Prettier) - shared 타입 (ipc-channels 113채널, types, errors, constants) - 메인 프로세스 뼈대 (bootstrap, lifecycle, 단일 인스턴스) - LoggerService, ConfigService (electron-store ESM dynamic import) - React 19 + MUI 7 Dashboard, 시스템 트레이 Phase 2: - AudioCaptureService (node-record-lpcm16, PCM16 16kHz mono) - HotkeyService (uiohook-napi, 더블프레스, holdMode/toggleMode) - LocalSTTService (faster-whisper Python sidecar, 이중 조건 플러시) - VoiceModeService 오케스트레이터 (이중 상태머신, Action Queue) - Python sidecar (FastAPI: health/load/transcribe/shutdown) - IPC 핸들러 (voice, stt, hotkey) + Preload API 확장
This commit is contained in:
parent
e24bb8378c
commit
1d152d01a1
46 changed files with 10828 additions and 4 deletions
536
src/main/services/HotkeyService.ts
Normal file
536
src/main/services/HotkeyService.ts
Normal file
|
|
@ -0,0 +1,536 @@
|
|||
// src/main/services/HotkeyService.ts
|
||||
// uiohook-napi 기반 글로벌 키보드 후킹 서비스.
|
||||
// 설계서 01의 IHotkeyService 구현. Speakly HotkeyService + HotkeyConfig 패턴 참조.
|
||||
|
||||
import { EventEmitter } from 'events'
|
||||
import { uIOhook, UiohookKey } from 'uiohook-napi'
|
||||
import type { UiohookKeyboardEvent } from 'uiohook-napi'
|
||||
import { getLogger } from './LoggerService'
|
||||
import { configGet } from './ConfigService'
|
||||
import { D3ROError, ErrorCode } from '@shared/errors'
|
||||
import { TIMING } from '@shared/constants'
|
||||
import type { HotkeyBinding } from '@shared/types'
|
||||
|
||||
const logger = getLogger('HotkeyService')
|
||||
|
||||
// ============================================================
|
||||
// 내부 타입
|
||||
// ============================================================
|
||||
|
||||
export interface HotkeyConfig {
|
||||
/** 핫키 식별자 (예: 'voice-dictation', 'voice-handsfree') */
|
||||
id: string
|
||||
/** uiohook 키코드 */
|
||||
keyCode: number
|
||||
/** 수정자 키 목록 */
|
||||
modifiers: HotkeyModifier[]
|
||||
/** true=hold-to-talk (누르고 있는 동안 활성), false=toggle */
|
||||
holdMode: boolean
|
||||
/** 더블프레스 감지 활성화 여부 */
|
||||
doublePressEnabled: boolean
|
||||
/** 활성화 여부 */
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export type HotkeyModifier = 'ctrl' | 'alt' | 'shift' | 'meta'
|
||||
|
||||
interface HotkeyServiceEvents {
|
||||
'hotkey-pressed': (payload: { config: HotkeyConfig; timestamp: number }) => void
|
||||
'hotkey-released': (payload: {
|
||||
config: HotkeyConfig
|
||||
durationMs: number
|
||||
timestamp: number
|
||||
}) => void
|
||||
'double-press': (payload: {
|
||||
config: HotkeyConfig
|
||||
intervalMs: number
|
||||
timestamp: number
|
||||
}) => void
|
||||
error: (payload: { error: D3ROError }) => void
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Windows VK 코드 → uiohook 키코드 매핑
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Windows Virtual-Key 코드를 uiohook-napi 키코드로 변환한다.
|
||||
* ConfigService에 저장된 HotkeyBinding.keyCode는 Windows VK 코드이므로
|
||||
* uiohook 이벤트와 비교하려면 변환이 필요하다.
|
||||
*/
|
||||
const VK_TO_UIOHOOK: ReadonlyMap<number, number> = new Map([
|
||||
// 수정자 키
|
||||
[0xa0, UiohookKey.Shift], // VK_LSHIFT
|
||||
[0xa1, UiohookKey.ShiftRight], // VK_RSHIFT
|
||||
[0xa2, UiohookKey.Ctrl], // VK_LCONTROL
|
||||
[0xa3, UiohookKey.CtrlRight], // VK_RCONTROL
|
||||
[0xa4, UiohookKey.Alt], // VK_LMENU
|
||||
[0xa5, UiohookKey.AltRight], // VK_RMENU (Right Alt)
|
||||
[0x5b, UiohookKey.Meta], // VK_LWIN
|
||||
[0x5c, UiohookKey.MetaRight], // VK_RWIN
|
||||
|
||||
// 기능 키
|
||||
[0x70, UiohookKey.F1],
|
||||
[0x71, UiohookKey.F2],
|
||||
[0x72, UiohookKey.F3],
|
||||
[0x73, UiohookKey.F4],
|
||||
[0x74, UiohookKey.F5],
|
||||
[0x75, UiohookKey.F6],
|
||||
[0x76, UiohookKey.F7],
|
||||
[0x77, UiohookKey.F8],
|
||||
[0x78, UiohookKey.F9],
|
||||
[0x79, UiohookKey.F10],
|
||||
[0x7a, UiohookKey.F11],
|
||||
[0x7b, UiohookKey.F12],
|
||||
|
||||
// 일반 키
|
||||
[0x20, UiohookKey.Space],
|
||||
[0x0d, UiohookKey.Enter],
|
||||
[0x1b, UiohookKey.Escape],
|
||||
[0x08, UiohookKey.Backspace],
|
||||
[0x09, UiohookKey.Tab],
|
||||
[0x2d, UiohookKey.Insert],
|
||||
[0x2e, UiohookKey.Delete],
|
||||
[0x24, UiohookKey.Home],
|
||||
[0x23, UiohookKey.End],
|
||||
[0x21, UiohookKey.PageUp],
|
||||
[0x22, UiohookKey.PageDown],
|
||||
[0x25, UiohookKey.ArrowLeft],
|
||||
[0x26, UiohookKey.ArrowUp],
|
||||
[0x27, UiohookKey.ArrowRight],
|
||||
[0x28, UiohookKey.ArrowDown],
|
||||
|
||||
// 알파벳 (VK_A=0x41 ~ VK_Z=0x5A)
|
||||
[0x41, UiohookKey.A],
|
||||
[0x42, UiohookKey.B],
|
||||
[0x43, UiohookKey.C],
|
||||
[0x44, UiohookKey.D],
|
||||
[0x45, UiohookKey.E],
|
||||
[0x46, UiohookKey.F],
|
||||
[0x47, UiohookKey.G],
|
||||
[0x48, UiohookKey.H],
|
||||
[0x49, UiohookKey.I],
|
||||
[0x4a, UiohookKey.J],
|
||||
[0x4b, UiohookKey.K],
|
||||
[0x4c, UiohookKey.L],
|
||||
[0x4d, UiohookKey.M],
|
||||
[0x4e, UiohookKey.N],
|
||||
[0x4f, UiohookKey.O],
|
||||
[0x50, UiohookKey.P],
|
||||
[0x51, UiohookKey.Q],
|
||||
[0x52, UiohookKey.R],
|
||||
[0x53, UiohookKey.S],
|
||||
[0x54, UiohookKey.T],
|
||||
[0x55, UiohookKey.U],
|
||||
[0x56, UiohookKey.V],
|
||||
[0x57, UiohookKey.W],
|
||||
[0x58, UiohookKey.X],
|
||||
[0x59, UiohookKey.Y],
|
||||
[0x5a, UiohookKey.Z],
|
||||
|
||||
// 숫자 (VK_0=0x30 ~ VK_9=0x39)
|
||||
[0x30, UiohookKey['0']],
|
||||
[0x31, UiohookKey['1']],
|
||||
[0x32, UiohookKey['2']],
|
||||
[0x33, UiohookKey['3']],
|
||||
[0x34, UiohookKey['4']],
|
||||
[0x35, UiohookKey['5']],
|
||||
[0x36, UiohookKey['6']],
|
||||
[0x37, UiohookKey['7']],
|
||||
[0x38, UiohookKey['8']],
|
||||
[0x39, UiohookKey['9']]
|
||||
])
|
||||
|
||||
/**
|
||||
* HotkeyBinding(Windows VK 코드)을 HotkeyConfig(uiohook 키코드)로 변환한다.
|
||||
*/
|
||||
function bindingToConfig(
|
||||
id: string,
|
||||
binding: HotkeyBinding,
|
||||
holdMode: boolean,
|
||||
doublePressEnabled: boolean
|
||||
): HotkeyConfig {
|
||||
const uiohookKeyCode = VK_TO_UIOHOOK.get(binding.keyCode)
|
||||
|
||||
if (uiohookKeyCode === undefined) {
|
||||
logger.warn(
|
||||
`Unknown VK code 0x${binding.keyCode.toString(16)} for hotkey "${id}", ` +
|
||||
`using raw value ${binding.keyCode}`
|
||||
)
|
||||
}
|
||||
|
||||
const modifiers: HotkeyModifier[] = []
|
||||
if (binding.ctrl) modifiers.push('ctrl')
|
||||
if (binding.alt) modifiers.push('alt')
|
||||
if (binding.shift) modifiers.push('shift')
|
||||
if (binding.meta) modifiers.push('meta')
|
||||
|
||||
return {
|
||||
id,
|
||||
keyCode: uiohookKeyCode ?? binding.keyCode,
|
||||
modifiers,
|
||||
holdMode,
|
||||
doublePressEnabled,
|
||||
enabled: true
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// HotkeyService 클래스
|
||||
// ============================================================
|
||||
|
||||
class HotkeyService extends EventEmitter {
|
||||
private _isRunning = false
|
||||
private _registeredHotkeys: Map<string, HotkeyConfig> = new Map()
|
||||
|
||||
/** 더블프레스 감지용: 마지막 press 시각 */
|
||||
private _lastPressTime: Map<string, number> = new Map()
|
||||
|
||||
/** hold duration 계산용: press 시작 시각 */
|
||||
private _pressStartTime: Map<string, number> = new Map()
|
||||
|
||||
/** 키 반복(auto-repeat) 방지: 현재 눌려있는 키 */
|
||||
private _isKeyDown: Map<string, boolean> = new Map()
|
||||
|
||||
/** uiohook 이벤트 핸들러 (바인딩 해제용) */
|
||||
private _onKeyDown: ((e: UiohookKeyboardEvent) => void) | null = null
|
||||
private _onKeyUp: ((e: UiohookKeyboardEvent) => void) | null = null
|
||||
|
||||
get isRunning(): boolean {
|
||||
return this._isRunning
|
||||
}
|
||||
|
||||
get registeredHotkeys(): ReadonlyMap<string, HotkeyConfig> {
|
||||
return this._registeredHotkeys
|
||||
}
|
||||
|
||||
/**
|
||||
* uiohook 글로벌 키보드 후킹을 시작한다.
|
||||
* 이미 실행 중이면 무시.
|
||||
*/
|
||||
start(): void {
|
||||
if (this._isRunning) {
|
||||
logger.warn('HotkeyService already running')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
this._onKeyDown = (e: UiohookKeyboardEvent) => this._handleKeyDown(e)
|
||||
this._onKeyUp = (e: UiohookKeyboardEvent) => this._handleKeyUp(e)
|
||||
|
||||
uIOhook.on('keydown', this._onKeyDown)
|
||||
uIOhook.on('keyup', this._onKeyUp)
|
||||
uIOhook.start()
|
||||
|
||||
this._isRunning = true
|
||||
logger.info('uiohook started, global keyboard hook active')
|
||||
} catch (error) {
|
||||
const d3roError = new D3ROError(
|
||||
ErrorCode.HotkeyHookInitFailed,
|
||||
`Failed to start uiohook: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
this.emit('error', { error: d3roError })
|
||||
logger.error(d3roError.message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* uiohook 글로벌 키보드 후킹을 중지한다.
|
||||
*/
|
||||
stop(): void {
|
||||
if (!this._isRunning) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
uIOhook.stop()
|
||||
|
||||
if (this._onKeyDown) {
|
||||
uIOhook.removeListener('keydown', this._onKeyDown)
|
||||
this._onKeyDown = null
|
||||
}
|
||||
if (this._onKeyUp) {
|
||||
uIOhook.removeListener('keyup', this._onKeyUp)
|
||||
this._onKeyUp = null
|
||||
}
|
||||
|
||||
this._isRunning = false
|
||||
this._isKeyDown.clear()
|
||||
this._pressStartTime.clear()
|
||||
logger.info('uiohook stopped')
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to stop uiohook: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 핫키를 등록한다. 동일 ID가 이미 등록되어 있으면 갱신한다.
|
||||
*/
|
||||
registerHotkey(config: HotkeyConfig): void {
|
||||
if (!config.enabled) {
|
||||
logger.debug(`Hotkey "${config.id}" is disabled, skipping registration`)
|
||||
return
|
||||
}
|
||||
|
||||
this._registeredHotkeys.set(config.id, config)
|
||||
logger.info(
|
||||
`Hotkey registered: "${config.id}" ` +
|
||||
`(keyCode=${config.keyCode}, modifiers=[${config.modifiers.join(',')}], ` +
|
||||
`holdMode=${config.holdMode}, doublePress=${config.doublePressEnabled})`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 핫키 등록을 해제한다.
|
||||
*/
|
||||
unregisterHotkey(id: string): void {
|
||||
if (this._registeredHotkeys.delete(id)) {
|
||||
this._lastPressTime.delete(id)
|
||||
this._pressStartTime.delete(id)
|
||||
this._isKeyDown.delete(id)
|
||||
logger.info(`Hotkey unregistered: "${id}"`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ConfigService에서 핫키 설정을 로드하여 등록한다.
|
||||
*/
|
||||
loadFromConfig(): void {
|
||||
const hotkeyEnabled = configGet('hotkeyEnabled')
|
||||
if (!hotkeyEnabled) {
|
||||
logger.info('Hotkeys disabled in config')
|
||||
return
|
||||
}
|
||||
|
||||
const dictationBinding = configGet('dictationShortcut')
|
||||
const handsFreeBinding = configGet('handsFreeShortcut')
|
||||
const commandBinding = configGet('commandShortcut')
|
||||
|
||||
// 기존 핫키 초기화
|
||||
this._registeredHotkeys.clear()
|
||||
|
||||
// Dictation: hold-to-talk, 더블프레스 비활성
|
||||
this.registerHotkey(
|
||||
bindingToConfig('voice-dictation', dictationBinding, true, false)
|
||||
)
|
||||
|
||||
// Hands-free: toggle, 더블프레스 활성
|
||||
this.registerHotkey(
|
||||
bindingToConfig('voice-handsfree', handsFreeBinding, false, true)
|
||||
)
|
||||
|
||||
// Command: toggle, 더블프레스 비활성
|
||||
this.registerHotkey(
|
||||
bindingToConfig('voice-command', commandBinding, false, false)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
`Loaded ${this._registeredHotkeys.size} hotkeys from config`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 서비스 리소스를 정리한다.
|
||||
*/
|
||||
dispose(): void {
|
||||
this.stop()
|
||||
this._registeredHotkeys.clear()
|
||||
this._lastPressTime.clear()
|
||||
this._pressStartTime.clear()
|
||||
this._isKeyDown.clear()
|
||||
this.removeAllListeners()
|
||||
logger.info('HotkeyService disposed')
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 내부: 키 이벤트 처리
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 키 다운 이벤트를 처리한다.
|
||||
* 등록된 핫키 중 매칭되는 것을 찾아 적절한 이벤트를 발행한다.
|
||||
*/
|
||||
private _handleKeyDown(e: UiohookKeyboardEvent): void {
|
||||
const matched = this._findMatchingHotkey(e)
|
||||
if (!matched) return
|
||||
|
||||
const { id } = matched
|
||||
|
||||
// 키 반복(auto-repeat) 무시: 이미 눌려있으면 건너뜀
|
||||
if (this._isKeyDown.get(id)) {
|
||||
return
|
||||
}
|
||||
this._isKeyDown.set(id, true)
|
||||
|
||||
const now = Date.now()
|
||||
|
||||
// press 시작 시각 기록 (hold duration 계산용)
|
||||
this._pressStartTime.set(id, now)
|
||||
|
||||
// 더블프레스 감지
|
||||
if (matched.doublePressEnabled) {
|
||||
const lastPress = this._lastPressTime.get(id)
|
||||
|
||||
if (lastPress !== undefined && now - lastPress < TIMING.DOUBLE_PRESS_DURATION) {
|
||||
// 300ms 이내 연속 두 번 press = 더블프레스
|
||||
this._lastPressTime.delete(id)
|
||||
|
||||
logger.debug(`Double-press detected: "${id}" (interval=${now - lastPress}ms)`)
|
||||
this.emit('double-press', {
|
||||
config: matched,
|
||||
intervalMs: now - lastPress,
|
||||
timestamp: now
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
this._lastPressTime.set(id, now)
|
||||
}
|
||||
|
||||
logger.debug(`Hotkey pressed: "${id}"`)
|
||||
this.emit('hotkey-pressed', {
|
||||
config: matched,
|
||||
timestamp: now
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 키 업 이벤트를 처리한다.
|
||||
*/
|
||||
private _handleKeyUp(e: UiohookKeyboardEvent): void {
|
||||
const matched = this._findMatchingHotkey(e)
|
||||
if (!matched) return
|
||||
|
||||
const { id } = matched
|
||||
|
||||
// 눌려있지 않은 키의 release는 무시
|
||||
if (!this._isKeyDown.get(id)) {
|
||||
return
|
||||
}
|
||||
this._isKeyDown.set(id, false)
|
||||
|
||||
const now = Date.now()
|
||||
const pressStart = this._pressStartTime.get(id)
|
||||
const durationMs = pressStart !== undefined ? now - pressStart : 0
|
||||
this._pressStartTime.delete(id)
|
||||
|
||||
logger.debug(`Hotkey released: "${id}" (duration=${durationMs}ms)`)
|
||||
this.emit('hotkey-released', {
|
||||
config: matched,
|
||||
durationMs,
|
||||
timestamp: now
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* uiohook 키 이벤트와 등록된 핫키를 매칭한다.
|
||||
* 키코드와 수정자 키가 모두 일치해야 매칭 성공.
|
||||
*/
|
||||
private _findMatchingHotkey(e: UiohookKeyboardEvent): HotkeyConfig | null {
|
||||
for (const config of this._registeredHotkeys.values()) {
|
||||
if (!config.enabled) continue
|
||||
|
||||
// 키코드 일치 확인
|
||||
if (e.keycode !== config.keyCode) continue
|
||||
|
||||
// 수정자 키 일치 확인
|
||||
// 핫키에 지정된 수정자가 모두 눌려 있어야 하고,
|
||||
// 지정되지 않은 수정자는 눌려 있으면 안 된다.
|
||||
//
|
||||
// 단, 핫키의 주 키 자체가 수정자 키인 경우(예: Right Alt)에는
|
||||
// 해당 수정자의 altKey 등이 true로 올 수 있으므로, 주 키가 수정자인 경우
|
||||
// 해당 수정자 검사를 건너뛴다.
|
||||
const isKeyModifier = this._isModifierKeyCode(config.keyCode)
|
||||
|
||||
const wantsCtrl = config.modifiers.includes('ctrl')
|
||||
const wantsAlt = config.modifiers.includes('alt')
|
||||
const wantsShift = config.modifiers.includes('shift')
|
||||
const wantsMeta = config.modifiers.includes('meta')
|
||||
|
||||
const ctrlMatch = isKeyModifier && this._isCtrlKeyCode(config.keyCode)
|
||||
? true
|
||||
: e.ctrlKey === wantsCtrl
|
||||
const altMatch = isKeyModifier && this._isAltKeyCode(config.keyCode)
|
||||
? true
|
||||
: e.altKey === wantsAlt
|
||||
const shiftMatch = isKeyModifier && this._isShiftKeyCode(config.keyCode)
|
||||
? true
|
||||
: e.shiftKey === wantsShift
|
||||
const metaMatch = isKeyModifier && this._isMetaKeyCode(config.keyCode)
|
||||
? true
|
||||
: e.metaKey === wantsMeta
|
||||
|
||||
if (ctrlMatch && altMatch && shiftMatch && metaMatch) {
|
||||
return config
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/** 주어진 uiohook 키코드가 수정자 키(Ctrl/Alt/Shift/Meta)인지 확인 */
|
||||
private _isModifierKeyCode(keyCode: number): boolean {
|
||||
return (
|
||||
this._isCtrlKeyCode(keyCode) ||
|
||||
this._isAltKeyCode(keyCode) ||
|
||||
this._isShiftKeyCode(keyCode) ||
|
||||
this._isMetaKeyCode(keyCode)
|
||||
)
|
||||
}
|
||||
|
||||
private _isCtrlKeyCode(keyCode: number): boolean {
|
||||
return keyCode === UiohookKey.Ctrl || keyCode === UiohookKey.CtrlRight
|
||||
}
|
||||
|
||||
private _isAltKeyCode(keyCode: number): boolean {
|
||||
return keyCode === UiohookKey.Alt || keyCode === UiohookKey.AltRight
|
||||
}
|
||||
|
||||
private _isShiftKeyCode(keyCode: number): boolean {
|
||||
return keyCode === UiohookKey.Shift || keyCode === UiohookKey.ShiftRight
|
||||
}
|
||||
|
||||
private _isMetaKeyCode(keyCode: number): boolean {
|
||||
return keyCode === UiohookKey.Meta || keyCode === UiohookKey.MetaRight
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// EventEmitter 타입 오버라이드
|
||||
// ============================================================
|
||||
|
||||
override on<K extends keyof HotkeyServiceEvents>(
|
||||
event: K,
|
||||
listener: HotkeyServiceEvents[K]
|
||||
): this {
|
||||
return super.on(event, listener)
|
||||
}
|
||||
|
||||
override off<K extends keyof HotkeyServiceEvents>(
|
||||
event: K,
|
||||
listener: HotkeyServiceEvents[K]
|
||||
): this {
|
||||
return super.off(event, listener)
|
||||
}
|
||||
|
||||
override emit<K extends keyof HotkeyServiceEvents>(
|
||||
event: K,
|
||||
...args: Parameters<HotkeyServiceEvents[K]>
|
||||
): boolean {
|
||||
return super.emit(event, ...args)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 싱글톤
|
||||
// ============================================================
|
||||
|
||||
let instance: HotkeyService | null = null
|
||||
|
||||
export function getHotkeyService(): HotkeyService {
|
||||
if (!instance) {
|
||||
instance = new HotkeyService()
|
||||
}
|
||||
return instance
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue