Phase 8: SSOT 리팩토링 + HotkeyRecordModal + Dashboard 재작성

- 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
This commit is contained in:
Yun Chan 2026-04-05 09:54:55 +09:00
parent f41fc277e3
commit 28371a9d1a
19 changed files with 1879 additions and 403 deletions

213
docs/phases/phase-8.md Normal file
View file

@ -0,0 +1,213 @@
# Phase 8: 핵심 기능 보강 + UI 디자인 근본 재설계
## 목표
1. **핫키 변경 UI 구현** (HotkeyRecordModal) — 앱 테스트를 위한 차단 해제
2. **Settings에 음성 모드 토글 3개 추가** (받아쓰기/Agent/원터치)
3. **d3roPalette SSOT 확장 및 매직넘버 ~40곳 제거**
4. **DashboardPage 기능 중심 재작성** (통계+히스토리+상태)
5. **HistoryPage 날짜 그룹핑**
6. **recording-tip 색상 버그 수정** (파란색 `#1F5DF2` → 앰버 `#f25b29`)
## 전제 조건
Phase 7.5 완료 (SoundEffect, AutoLaunch, HotkeyService, VoiceModeService 모두 동작)
---
## 태스크
### 8.1 HotkeyRecordModal 구현
- **파일**: `src/renderer/components/HotkeyRecordModal.tsx`
- Speakly HotkeyRecordModal 패턴 차용:
1. Modal 열림 → `document.addEventListener('keydown')` 리스너 등록
2. 키 조합 감지 → `HotkeyBinding` 형태로 Chip/태그 표시
3. 유효성 검증: 시스템 예약키 블랙리스트 (Ctrl+C, Ctrl+V, Alt+F4, Ctrl+Alt+Del 등)
4. 중복 핫키 충돌 감지 → 경고 메시지 표시
5. "저장" 클릭 → IPC `hotkey:setDictationShortcut` / `hotkey:setHandsFreeShortcut` 호출
6. "취소" 또는 ESC → 모달 닫기, 변경 없음
- 키 표시: platform-specific 라벨 (`Ctrl` → Windows, 키코드 → 표시 문자열 매핑)
- HotkeyBinding 구조:
```typescript
interface HotkeyBinding {
keyCode: number;
ctrl: boolean;
alt: boolean;
shift: boolean;
meta: boolean;
displayLabel: string; // 예: "Right Alt", "Ctrl+Shift+F5"
}
```
- **IPC 채널**: 기존 `hotkey:setDictationShortcut`, `hotkey:setHandsFreeShortcut`, `hotkey:setCommandShortcut` 활용
- **시스템 예약키 블랙리스트** (`src/shared/constants.ts`에 추가):
```typescript
export const RESERVED_HOTKEYS: Set<string> = new Set([
'Ctrl+C', 'Ctrl+V', 'Ctrl+X', 'Ctrl+Z', 'Ctrl+A', 'Ctrl+S',
'Alt+F4', 'Alt+Tab', 'Ctrl+Alt+Delete', 'Win+L', 'Win+D',
'Ctrl+Shift+Escape', 'PrintScreen',
]);
```
### 8.2 Settings 핫키 섹션 UI
- **파일**: `src/renderer/components/SettingsModal.tsx`
- General 탭에 "Shortcuts" 섹션 추가 (기존 토글 설정 아래):
- 받아쓰기(Dictation) 핫키: 현재 바인딩 표시 + "변경" 버튼 → HotkeyRecordModal 열기
- 핸즈프리(Hands-free) 핫키: 동일 패턴
- Agent Mode 핫키: 더블프레스 기반이므로 "받아쓰기 키 2회 누름" 안내 텍스트만 표시
- 각 핫키 옆에 현재 바인딩을 `Chip` 컴포넌트로 표시 (예: `Right Alt`)
- 핫키 변경 성공 시 → `Chip` 갱신 + 토스트 알림
- IPC 호출: `hotkey:getDictationShortcut`, `hotkey:getHandsFreeShortcut`로 초기값 로드
### 8.3 음성 모드 토글 3개 (Settings 연동)
- **파일**: `src/renderer/components/SettingsModal.tsx` (General 탭)
- Speakly Settings의 음성 모드 섹션 패턴:
- **Dictation Mode**: `Switch` 토글 (기본 ON) + 핫키 Chip + "Hold-to-talk" 설명
- **Agent Mode**: `Switch` 토글 (기본 OFF) + "Dictation 키 더블프레스" 설명
- Dictation이 OFF이면 Agent도 비활성 (의존 관계 표시)
- **Hands-free (One-touch)**: `Switch` 토글 (기본 OFF) + 핫키 Chip + "Toggle on/off" 설명
- **IPC / ConfigService 연동**:
- `config:set({ key: 'dictationEnabled', value: boolean })`
- `config:set({ key: 'agentModeEnabled', value: boolean })`
- `config:set({ key: 'handsFreeEnabled', value: boolean })`
- AppConfig에 3개 필드 추가 (`src/shared/types.ts`)
- **HotkeyService 연동**: 모드가 비활성이면 해당 핫키 이벤트 무시
### 8.4 d3roPalette SSOT 확장
- **파일**: `src/renderer/theme.ts`
- 현재 `d3roPalette`에 누락된 시맨틱 토큰 추가:
```typescript
export const d3roPalette = {
// ...기존 유지...
interactive: {
/** 비활성 아이콘/라벨 (현재 '#77797c' 매직넘버로 사용 중) */
muted: '#77797c',
/** 호버 시 앰버로 전환할 때 사용 */
hoverAccent: '#f25b29',
/** 호버 시 위험 색상 */
hoverDanger: '#ef4444',
},
metadata: {
/** 보조 정보 텍스트 (현재 '#5c2615' 매직넘버) */
dim: '#5c2615',
},
sidebar: {
bg: '#1e1f21',
activeBtn: '#242528',
activeBtnPressed: '#1a1a1c',
},
shadow: {
/** 물리 버튼 돌출 */
buttonRaised: '0 2px 4px rgba(0,0,0,0.3), inset 0 1px 1px rgba(255,255,255,0.06)',
/** 물리 버튼 눌림 */
buttonPressed: 'inset 0 2px 4px rgba(0,0,0,0.6)',
/** 인셋 패널 */
inset: 'inset 0 2px 6px rgba(0,0,0,0.6), 0 1px 1px rgba(255,255,255,0.05)',
},
} as const;
```
- 목표: 모든 컴포넌트에서 `'#77797c'`, `'#5c2615'`, `'#1e1f21'`, `'#242528'` 등 매직넘버를 `d3roPalette.*` 참조로 교체
### 8.5 매직넘버 ~40곳 제거
- **대상 파일** (grep 결과 기준):
- `src/renderer/components/AppLayout.tsx` (~15곳)
- `src/renderer/pages/HistoryPage.tsx` (~6곳)
- `src/renderer/pages/DictionaryPage.tsx` (~5곳)
- `src/renderer/pages/CommandsPage.tsx` (~5곳)
- `src/renderer/components/StatusBar.tsx` (~5곳)
- `src/renderer/components/ds/PhysicalButton.tsx` (~5곳)
- `src/renderer/components/ds/MetalCard.tsx` (~4곳)
- `src/renderer/components/ds/CrtDisplay.tsx` (~5곳)
- `src/renderer/components/ds/Led.tsx` (~4곳)
- **규칙**: `theme.ts`에서 `d3roPalette` / `d3roFontMono` export한 값만 사용
- **예외**: DS 컴포넌트 내부의 WebGL 셰이더 uniform 값은 매직넘버 허용 (JS→GLSL 변환 복잡)
- **검증**: `grep -rn '#[0-9a-fA-F]{6}' src/renderer/ --include='*.tsx'` 결과에서 `theme.ts` 외 0건
### 8.6 DashboardPage 기능 중심 재작성
- **파일**: `src/renderer/pages/DashboardPage.tsx`
- 현재 CRT+LED 인스트루먼트 패널 → **기능 중심 레이아웃**으로 교체:
1. **상단: 통계 카드 4개** (MetalCard 사용)
- 총 세션 수 / 오늘 세션 수 / 총 녹음 시간 / 연속 사용 일수
- PhosphorText로 값 표시, 각 카드에 Led 인디케이터
2. **중단: 최근 히스토리** (5~10건)
- 날짜 그룹핑 (오늘/어제/이번 주)
- 원본 텍스트 + 모드 Chip + 녹음 시간
- 클릭 시 히스토리 상세 or 복사
3. **하단: 시스템 상태 패널**
- Ollama 연결 상태 (Led green/red)
- STT 엔진 상태 (모델명 + ready/loading)
- 현재 핫키 바인딩 표시
- **IPC 호출**: `stats:getSummary`, `history:getAll({ page: 0, pageSize: 10 })`, `llm:getStatus`, `stt:getStatus`
- CRT 디스플레이는 제거하지 않고 **축소하여 시스템 상태 영역에 배치** (옵션)
### 8.7 HistoryPage 날짜 그룹핑
- **파일**: `src/renderer/pages/HistoryPage.tsx`
- 히스토리 목록을 날짜별 섹션으로 그룹핑:
- "오늘" / "어제" / "이번 주" / "이번 달" / "YYYY년 M월"
- 각 섹션 헤더: PhosphorText variant="label" + 항목 수
- 그룹핑 유틸 함수:
```typescript
function groupByDate(entries: HistoryEntry[]): Map<string, HistoryEntry[]> {
// createdAt 기준으로 그룹핑
// 오늘/어제/이번주/이번달/그외 구분
}
```
- 날짜 섹션 사이 시각적 구분선 (Divider + 앰버 강조)
- 스크롤 시 현재 날짜 섹션 sticky header (옵션)
### 8.8 recording-tip 색상 버그 수정
- **파일**: `src/renderer/popups/recording-tip/style.css`
- **문제**: 웨이브 바와 프로그레스 바 색상이 Speakly 기본값 `#1F5DF2` (파란색)으로 하드코딩됨
- **수정**: D3RO 앰버 악센트 `#f25b29`로 변경
```css
.wave-bar {
background: #f25b29; /* was: #1F5DF2 */
}
.progress-bar {
background: #f25b29; /* was: #1F5DF2 */
}
```
- **에러 아이콘**: 기존 `#D32F2F``#ef4444` (d3roPalette.tag.red)로 통일
### 8.9 AppConfig 타입 확장
- **파일**: `src/shared/types.ts`
- 음성 모드 토글 3개 필드 추가:
```typescript
export interface AppConfig {
// ...기존 필드 유지...
/** 받아쓰기 모드 활성화 (hold-to-talk) */
dictationEnabled: boolean;
/** Agent 모드 활성화 (더블프레스, dictation 의존) */
agentModeEnabled: boolean;
/** 핸즈프리 모드 활성화 (토글) */
handsFreeEnabled: boolean;
}
```
- ConfigService 기본값 설정: `dictationEnabled: true`, `agentModeEnabled: false`, `handsFreeEnabled: false`
### 8.10 DS 컴포넌트 d3roPalette 참조 전환
- **대상 파일**:
- `src/renderer/components/ds/Led.tsx`: 색상 맵을 `d3roPalette.accent.*`, `d3roPalette.tag.*`에서 가져오기
- `src/renderer/components/ds/PhysicalButton.tsx`: `#242528`, `#1a1a1c`, `#f25b29`, `#77797c` → 팔레트 참조
- `src/renderer/components/ds/MetalCard.tsx`: `#1b1c1e`, `#242427`, `#2a2a2d` → 팔레트 참조
- `src/renderer/components/ds/CrtDisplay.tsx`: `#1a1a1c`, `#050605`, `#f25b29` → 팔레트 참조 (셰이더 uniform 제외)
- DS 컴포넌트가 직접 hex 리터럴을 갖지 않고, 반드시 `d3roPalette` 또는 `theme.palette`에서 읽도록 변경
- `import { d3roPalette } from '../../theme'` 패턴 통일
---
## Speakly RE 참조
- **Settings.js**: General 탭에 음성 모드 토글 3개 (Dictation/Agent/Hands-free), 각 모드별 핫키 Chip, 마이크 테스트
- **HotkeyRecordModal.js**: keydown 리스너 → 조합키 Chip 표시, 시스템 예약키 블랙리스트, platform-specific 키 라벨
- **CustomInstructionPage.js**: 커스텀 명령어별 핫키 바인딩 UI (재활용 패턴)
- **Dashboard.js**: 통계 카드 4개, 최근 히스토리 날짜 그룹핑 (오늘/어제/이전)
- **HotkeyConfig.js**: VK 코드 → 표시 문자열 매핑 테이블, 시스템 예약키 감지
## 완료 조건
- [ ] Settings에서 핫키 변경 가능 (HotkeyRecordModal 작동)
- [ ] 변경된 핫키로 녹음 → 전사 → 삽입 사이클 테스트 가능
- [ ] Settings에 음성 모드 토글 3개 표시 + 설정 저장/로드
- [ ] Dictation OFF → Agent 자동 비활성 연동 작동
- [ ] DashboardPage에 통계 + 최근 히스토리 + 시스템 상태 표시
- [ ] HistoryPage 날짜 그룹핑 (오늘/어제/이번 주 등)
- [ ] recording-tip 웨이브 바/프로그레스 바 색상이 앰버(`#f25b29`)
- [ ] `grep '#[0-9a-fA-F]{6}' src/renderer/**/*.tsx` 결과에서 `theme.ts` 외 매직넘버 0건
- [ ] `npm run typecheck` 통과
- [ ] 모든 DS 컴포넌트가 `d3roPalette` 참조 (hex 리터럴 직접 사용 없음)

View file

@ -0,0 +1,472 @@
# 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:testDevice` IPC (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 키 감지 로직
```typescript
// 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 기준:
```typescript
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 유효성 검증
```typescript
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 표시
```tsx
// 키 조합을 개별 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 내부 처리:**
```typescript
// 더블프레스 감지
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 탭에 음성 모드 섹션을 추가한다:
```tsx
// 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에서 차단할 키 조합:
```typescript
// 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', // 도움말
];
```
---
*끝.*