d3ro-voice/docs/re-findings/speakly-settings-ui.md
Yun Chan 28371a9d1a 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
2026-04-05 09:54:55 +09:00

472 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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', // 도움말
];
```
---
*끝.*