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