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

16 KiB
Raw Blame History

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 키 감지 로직

// 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',                                                    // 도움말
];

끝.