- d3roPalette 11개 토큰 추가 (sidebar, chassis, inactive 등) - DS 컴포넌트 6개 + 페이지 5개 매직넘버 → 팔레트 참조 (0개 잔여) - HotkeyRecordModal 신규: 커스텀 핫키 녹화 모달 - SettingsModal 재작성: 음성 모드 3개(받아쓰기/Agent/원터치) + 핫키 변경 - DashboardPage 재작성: Hero + 통계 4카드 + CRT 서비스 상태 + 히스토리 날짜 그룹핑 - recording-tip 색상 수정: #1F5DF2(파란) → #f25b29(앰버) - 설계 문서: phase-8.md, speakly-settings-ui.md
16 KiB
16 KiB
Speakly Settings & Hotkey UI 분석
Speakly의 Settings Modal 및 HotkeyRecordModal 리버스엔지니어링 결과. D3RO-VOICE Phase 8 구현 시 참조 자료.
1. Settings Modal 구조
1.1 전체 레이아웃
Dialog (fullWidth, maxWidth='sm')
├── DialogTitle: "Settings" + 닫기(X) 아이콘
├── Tabs: [Account | General | About]
└── TabPanels
├── Account: 로그인 정보, 사용량, 구독 (D3RO 제거)
├── General: 핵심 설정 전체
└── About: 버전, 라이선스, 피드백 링크
D3RO-VOICE 적용: Account 탭 제거, General 탭만 유지 (또는 탭 분할 확장: General / Audio / STT / LLM).
1.2 General 탭 세부 섹션
General 탭은 상단부터 순서대로 다음 섹션으로 구성된다:
General Tab
├── 🎤 Voice Modes (음성 모드)
│ ├── Dictation Mode — Switch + Hotkey Chip
│ ├── Agent Mode — Switch + "Dictation 더블프레스" 설명
│ └── Hands-free (One-touch) — Switch + Hotkey Chip
│
├── 🔊 Audio
│ ├── Microphone — Select (디바이스 목록)
│ ├── Mic Test — Button + Level Meter
│ ├── Sound effects — Switch
│ └── Mute audio when dictating — Switch
│
├── 🌐 Language & Appearance
│ ├── UI Language — Select (ko/en)
│ ├── Theme — Select (light/dark/system)
│ └── Start minimized — Switch
│
└── ⚙️ System
├── Launch at startup — Switch
├── Close to tray — Switch
└── Reset all settings — Button (확인 다이얼로그)
1.3 음성 모드 섹션 UI 패턴
각 모드는 동일한 행(row) 레이아웃을 따른다:
┌──────────────────────────────────────────────────────────────┐
│ [Switch] Dictation Mode [Right Alt] Chip │
│ Hold to talk – press and hold the shortcut key │
│ to start recording │
├──────────────────────────────────────────────────────────────┤
│ [Switch] Agent Mode │
│ Double-press the dictation shortcut to activate │
│ (requires Dictation Mode enabled) │
├──────────────────────────────────────────────────────────────┤
│ [Switch] Hands-free (One-touch) [Ctrl+\] Chip │
│ Toggle on/off – press once to start, again to stop │
└──────────────────────────────────────────────────────────────┘
핵심 동작:
- Dictation Switch OFF → Agent Mode Switch 자동 비활성 (disabled + 툴팁 "Dictation 필요")
- Hotkey Chip 클릭 → HotkeyRecordModal 열림
- Agent Mode에는 별도 핫키가 없음 (Dictation 핫키 더블프레스로 트리거)
1.4 마이크 선택 + 테스트 UI
┌──────────────────────────────────────────────────┐
│ Microphone │
│ [Select: System Default (Realtek Audio)] │
│ │
│ [🎤 Test Microphone] ▓▓▓▓▓░░░░░ Level: 0.45 │
│ ← real-time RMS bar → │
└──────────────────────────────────────────────────┘
- 테스트 버튼 클릭 →
audio:testDeviceIPC (2초간 캡처) - 캡처 중 실시간 RMS 레벨 바 표시 (100ms 간격 갱신)
- 완료 후 평균/피크 레벨 표시
- 오디오 없으면 경고: "마이크에서 소리가 감지되지 않습니다"
2. HotkeyRecordModal 구현
2.1 Modal 구조
Dialog (maxWidth='xs')
├── DialogTitle: "Record Shortcut"
├── DialogContent
│ ├── 안내 텍스트: "Press the key combination you want to use"
│ ├── 키 표시 영역: [Chip][Chip][+][Chip] (예: Ctrl + Shift + F5)
│ ├── 유효성 메시지 (성공/경고/에러)
│ └── 현재 바인딩 표시: "Current: Right Alt"
└── DialogActions
├── Cancel — 변경 없이 닫기
└── Save — 새 바인딩 저장
2.2 키 감지 로직
// Modal mount 시 리스너 등록
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();
const binding: HotkeyBinding = {
keyCode: e.keyCode, // deprecated지만 uiohook 호환용
ctrl: e.ctrlKey,
alt: e.altKey,
shift: e.shiftKey,
meta: e.metaKey,
displayLabel: buildDisplayLabel(e),
};
// 수정자 키만 눌린 경우 → 아직 완성 안 됨 (Chip은 표시하되 Save 비활성)
if (isModifierOnly(e)) {
setPendingBinding(binding);
setIsComplete(false);
return;
}
// 일반 키 + 수정자 조합 → 완성
setPendingBinding(binding);
setIsComplete(true);
validate(binding);
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [open]);
2.3 키 표시 라벨 매핑
Speakly는 platform-specific 키 라벨을 사용한다. Windows 기준:
const KEY_LABELS: Record<number, string> = {
// 수정자 키
0xA0: 'Left Shift', 0xA1: 'Right Shift',
0xA2: 'Left Ctrl', 0xA3: 'Right Ctrl',
0xA4: 'Left Alt', 0xA5: 'Right Alt',
0x5B: 'Left Win', 0x5C: 'Right Win',
// 기능 키
0x70: 'F1', 0x71: 'F2', 0x72: 'F3', 0x73: 'F4',
0x74: 'F5', 0x75: 'F6', 0x76: 'F7', 0x77: 'F8',
0x78: 'F9', 0x79: 'F10', 0x7A: 'F11', 0x7B: 'F12',
// 특수 키
0x1B: 'Escape', 0x09: 'Tab', 0x14: 'CapsLock',
0x20: 'Space', 0x0D: 'Enter', 0x08: 'Backspace',
0x2D: 'Insert', 0x2E: 'Delete', 0x24: 'Home',
0x23: 'End', 0x21: 'PageUp', 0x22: 'PageDown',
// 방향키
0x25: '←', 0x26: '↑', 0x27: '→', 0x28: '↓',
// 숫자패드
0x90: 'NumLock', 0x6F: 'Num/', 0x6A: 'Num*',
0x6D: 'Num-', 0x6B: 'Num+', 0x6E: 'Num.',
};
function buildDisplayLabel(e: KeyboardEvent): string {
const parts: string[] = [];
if (e.ctrlKey) parts.push('Ctrl');
if (e.altKey) parts.push('Alt');
if (e.shiftKey) parts.push('Shift');
if (e.metaKey) parts.push('Win');
// 수정자 키 자체는 중복 추가하지 않음
if (!isModifierOnly(e)) {
const label = KEY_LABELS[e.keyCode] ?? e.key?.toUpperCase() ?? `Key${e.keyCode}`;
parts.push(label);
}
return parts.join(' + ');
}
2.4 유효성 검증
interface ValidationResult {
valid: boolean;
message: string;
severity: 'success' | 'warning' | 'error';
}
function validate(binding: HotkeyBinding): ValidationResult {
const label = binding.displayLabel;
// 1. 시스템 예약키 체크
if (RESERVED_HOTKEYS.has(label)) {
return {
valid: false,
message: `${label} is reserved by the system`,
severity: 'error',
};
}
// 2. 다른 모드와 중복 체크
const existing = findConflict(binding);
if (existing) {
return {
valid: false,
message: `Already used by "${existing.modeName}"`,
severity: 'warning',
};
}
// 3. 단일 수정자 키 경고 (Right Alt 등은 허용하지만 경고)
if (isModifierOnly({ keyCode: binding.keyCode })) {
return {
valid: true,
message: 'Modifier-only shortcuts may conflict with other apps',
severity: 'warning',
};
}
return { valid: true, message: 'Shortcut available', severity: 'success' };
}
2.5 MUI Chip 표시
// 키 조합을 개별 Chip으로 표시
function KeyChips({ label }: { label: string }) {
const parts = label.split(' + ');
return (
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center' }}>
{parts.map((part, i) => (
<React.Fragment key={part}>
{i > 0 && <Typography sx={{ color: 'text.secondary' }}>+</Typography>}
<Chip
label={part}
size="small"
sx={{
bgcolor: 'background.default',
border: '1px solid',
borderColor: 'divider',
fontFamily: 'monospace',
fontWeight: 600,
fontSize: '12px',
}}
/>
</React.Fragment>
))}
</Box>
);
}
3. 음성 모드 3가지 상세 분석
3.1 Dictation (받아쓰기)
| 항목 | 값 |
|---|---|
| 트리거 | 핫키 press (hold) |
| 녹음 방식 | hold-to-talk: 키 누르는 동안 녹음, 놓으면 종료 |
| 기본 핫키 | Right Alt (0xA5) |
| 더블프레스 | Agent Mode 트리거로 사용 (300ms 이내) |
| 최소 시간 | 700ms 이하 → accidentalPress 취소 |
| 후처리 | 기본: 원본 삽입, 설정에 따라 polish/translate |
키 press ─→ startRecording ─→ [holding] ─→ 키 release ─→ stopRecording
700ms↑ ↓
accidentalPress? STT → [LLM] → insert
3.2 Agent Mode (에이전트)
| 항목 | 값 |
|---|---|
| 트리거 | Dictation 핫키 더블프레스 (300ms 이내) |
| 녹음 방식 | Dictation과 동일 (hold-to-talk) |
| 의존성 | Dictation Mode가 활성이어야 함 |
| 후처리 | 항상 LLM 처리 (커스텀 명령어 또는 기본 polish) |
| 별도 핫키 | 없음 (Dictation 핫키의 더블프레스) |
키 press ─→ 300ms 이내 재press ─→ "Agent Mode" 활성
│
hold-to-talk (녹음)
│
release → STT → LLM(agent) → insert
HotkeyService 내부 처리:
// 더블프레스 감지
private onKeyDown(hotkeyId: string, config: HotkeyConfig): void {
const now = Date.now();
const lastPress = this.lastPressTime.get(hotkeyId) ?? 0;
if (config.doublePressEnabled && (now - lastPress) < TIMING.DOUBLE_PRESS_DURATION) {
// 더블프레스 → Agent Mode
this.emit('double-press', { config, intervalMs: now - lastPress, timestamp: now });
this.lastPressTime.delete(hotkeyId);
return;
}
this.lastPressTime.set(hotkeyId, now);
this.emit('hotkey-pressed', { config, timestamp: now });
}
3.3 Hands-free / One-touch (원터치)
| 항목 | 값 |
|---|---|
| 트리거 | 별도 핫키 press (toggle) |
| 녹음 방식 | 토글: 1회 누르면 녹음 시작, 다시 누르면 종료 |
| 기본 핫키 | 미설정 (사용자가 직접 바인딩) |
| 의존성 | 독립적 (Dictation과 무관) |
| 후처리 | 설정에 따라 분기 |
| VAD | 선택적 (침묵 감지 시 자동 종료 옵션) |
키 press(1회) ─→ startRecording ─→ [recording...] ─→ 키 press(2회) ─→ stopRecording
↓
STT → [LLM] → insert
Speakly 구현 특징:
holdMode: false로 HotkeyConfig 등록- 내부
isRecording상태 토글 - ESC 키로 녹음 취소 (별도 리스너)
- UI에 녹음 중 상태 표시 (RecordingTip 지속 표시)
4. D3RO-VOICE 적용 방안
4.1 HotkeyRecordModal 구현 계획
Speakly의 HotkeyRecordModal을 D3RO-VOICE의 IPC 채널에 맞게 재구현한다:
렌더러 (React) 메인 프로세스
│ │
│── hotkey:getDictationShortcut ────────►│
│◄── HotkeyBinding ────────────────────│
│ │
│ [사용자가 키 조합 누름] │
│ → 로컬 keydown 리스너로 감지 │
│ → validate() 수행 │
│ → UI에 Chip 표시 │
│ │
│── hotkey:setDictationShortcut ────────►│
│ { binding: HotkeyBinding } │── HotkeyService.updateConfig()
│◄── { success: true } ─────────────────│── uiohook 핫키 재등록
4.2 음성 모드 Settings UI 구현
SettingsModal의 General 탭에 음성 모드 섹션을 추가한다:
// Settings General 탭 — 음성 모드 섹션
<Typography variant="caption" sx={{ mb: 1 }}>VOICE MODES</Typography>
{/* Dictation */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Switch checked={dictationEnabled} onChange={...} />
<Typography>Dictation Mode</Typography>
<Typography variant="body2" color="text.secondary">
Hold to talk — press and hold the shortcut key
</Typography>
</Box>
<Chip
label={dictationHotkey.displayLabel}
onClick={() => openHotkeyRecord('dictation')}
clickable
/>
</Box>
{/* Agent */}
<Box>
<Switch
checked={agentEnabled}
onChange={...}
disabled={!dictationEnabled} // 의존성
/>
<Typography>Agent Mode</Typography>
<Typography variant="body2" color="text.secondary">
Double-press the dictation shortcut
{!dictationEnabled && ' (requires Dictation Mode)'}
</Typography>
</Box>
{/* Hands-free */}
<Box>
<Switch checked={handsFreeEnabled} onChange={...} />
<Typography>Hands-free (One-touch)</Typography>
<Chip
label={handsFreeHotkey?.displayLabel ?? 'Not set'}
onClick={() => openHotkeyRecord('handsFree')}
clickable
/>
</Box>
4.3 IPC 채널 매핑
| Speakly 패턴 | D3RO-VOICE IPC 채널 | 비고 |
|---|---|---|
| getHotkeyConfig('dictation') | hotkey:getDictationShortcut |
기존 |
| setHotkeyConfig('dictation', binding) | hotkey:setDictationShortcut |
기존 |
| getHotkeyConfig('handsFree') | hotkey:getHandsFreeShortcut |
기존 |
| setHotkeyConfig('handsFree', binding) | hotkey:setHandsFreeShortcut |
기존 |
| isVoiceModeEnabled('dictation') | config:get({ key: 'dictationEnabled' }) |
신규 |
| setVoiceModeEnabled('dictation', bool) | config:set({ key: 'dictationEnabled', value }) |
신규 |
| isVoiceModeEnabled('agent') | config:get({ key: 'agentModeEnabled' }) |
신규 |
| isVoiceModeEnabled('handsFree') | config:get({ key: 'handsFreeEnabled' }) |
신규 |
4.4 키코드 호환성
D3RO-VOICE는 두 가지 키코드 시스템을 사용한다:
| 컨텍스트 | 키코드 시스템 | 예시 (Right Alt) |
|---|---|---|
| ConfigService (저장) | Windows VK 코드 | 0xA5 (165) |
| uiohook-napi (런타임) | uiohook 키코드 | UiohookKey.AltRight (56) |
| KeyboardEvent (렌더러) | DOM keyCode | 18 (Alt) |
HotkeyRecordModal에서 DOM keyCode를 수신하여 Windows VK 코드로 변환 후 저장한다.
HotkeyService에서는 VK 코드를 uiohook 키코드로 변환하여 매칭한다 (vkToUiohook 맵 기존 구현 참조).
4.5 시스템 예약키 블랙리스트
D3RO-VOICE에서 차단할 키 조합:
// Windows 시스템 예약키
const RESERVED = [
'Ctrl+C', 'Ctrl+V', 'Ctrl+X', 'Ctrl+Z', 'Ctrl+Y', // 편집
'Ctrl+A', 'Ctrl+S', 'Ctrl+P', 'Ctrl+F', // 일반
'Alt+F4', 'Alt+Tab', 'Alt+Escape', // 윈도우 관리
'Ctrl+Alt+Delete', 'Ctrl+Shift+Escape', // 시스템
'Win+L', 'Win+D', 'Win+E', 'Win+R', 'Win+Tab', // Win 단축키
'PrintScreen', 'Ctrl+PrintScreen', // 캡처
'F1', // 도움말
];
끝.