초기 프로젝트 설정: 하네스 시스템 + 설계서 + RE 노하우

- CLAUDE.md: 프로젝트 규칙, 기술 스택, 코딩 규칙, 페이즈 로드맵
- .claude/settings.json: 권한, 강제 훅 (매 프롬프트 설계서 규칙 주입)
- .claude/skills/: implement-phase, review-phase, scaffold, test-commit, debug
- .claude/agents/: electron-architect, voice-pipeline-expert, ui-specialist
- docs/design/00-09: 마스터 아키텍처, 서비스 명세(16개), IPC(113채널),
  DB스키마, UI컴포넌트, 검증리포트, 외부엔진연동, 갭분석,
  VoiceMode패턴, 디자인시스템, 히스토리팝업
- docs/phases/1-7+3.5: 전체 구현 페이즈 문서
- docs/re-findings/: Speakly RE 노하우 5개 문서
This commit is contained in:
Yun Chan 2026-04-05 01:03:03 +09:00
commit e24bb8378c
35 changed files with 11452 additions and 0 deletions

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

1131
docs/design/03-db-and-ui.md Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,392 @@
# 04. 설계서 vs Speakly 소스 대조 검증 리포트
> 검증일: 2026-04-04
> 검증 대상: 설계서 00~03 vs Speakly 추출 소스 (`/tmp/speakly-extracted/dist/src/`)
> 검증자: verifier agent
---
## 요약
| 등급 | 건수 | 설명 |
|------|------|------|
| **Critical** | 5 | 설계서가 잘못 반영하거나 핵심 로직이 누락된 항목 |
| **Major** | 10 | 기능 동작에 영향을 줄 수 있는 불일치/누락 |
| **Minor** | 8 | 정확도 개선이 필요한 세부 사항 |
---
## 1. 상태 머신 검증
### CRITICAL-01: RecognitionState에 CONNECTING, DESTROYED 누락
- **설계서 위치**: `02-ipc-and-types.md` 394-403행, `01-service-specifications.md` 559-569행
- **실제 소스 위치**: `types/recording.js` 34-54행
- **차이점**: Speakly `RecognitionState`는 9개 상태를 정의한다:
- `IDLE`, `PREPARING`, **`CONNECTING`**, `READY`, `RECOGNIZING`, `COMPLETED`, `CANCELLED`, `ERROR`, **`DESTROYED`**
- 설계서 `02`에는 `CONNECTING` 누락, `COMPLETING` 상태가 추가되어 있다 (Speakly에 없음).
- 설계서 `01`에는 `Processing` 상태가 추가되어 있으나, Speakly에는 `Processing`이 없고 `RECOGNIZING` 상태에서 서버 측 처리가 진행된다.
- `DESTROYED`는 dispose 전용 최종 상태로, 설계서에 누락.
- **수정 제안**:
- `CONNECTING` 상태 추가 (WebSocket/sidecar 연결 중)
- `COMPLETING``RECOGNIZING`의 하위 플래그로 변경하거나 제거
- `DESTROYED` 상태 추가 (리소스 정리 완료 전용)
- `Processing`은 로컬에서는 LLM 후처리 시 의미가 있으나, Speakly의 원래 상태와 다름을 명시
### MAJOR-01: AudioState는 일치
- **결과**: 설계서 `AudioState` (`IDLE`, `INITIALIZING`, `STREAMING`, `STOPPED`) = Speakly 소스 완전 일치. 문제 없음.
### MAJOR-02: RecordingTipUIState 누락
- **실제 소스 위치**: `types/recording.js` 60-76행
- **차이점**: Speakly에는 `RecordingTipUIState` 열거형이 별도 존재:
- `OPENING`, `RECORDING`, `THINKING`, `CANCELLED`, `NOTICE`, `ERROR`, `ERROR_WITH_RETRY`
- 설계서에 RecordingTip UI 상태 매핑이 없다.
- **수정 제안**: `02-ipc-and-types.md` 또는 `03-db-and-ui.md`에 RecordingTipUIState 추가
---
## 2. 이벤트 페이로드 검증
### MAJOR-03: VoiceMode 열거형 불일치
- **설계서 위치**: `01-service-specifications.md` 579-585행, `02-ipc-and-types.md` 388행
- **실제 소스 위치**: `VoiceModeService.js` 89-95행
- **차이점**: Speakly `VoiceMode`는 4개 모드:
- `DICTATION`, `HANDS_FREE`, **`CUSTOM_INSTRUCTION`**, **`HANDS_FREE_NO_WAKE`**
- 설계서는 `dictation``hands-free` 2개만 정의.
- **수정 제안**:
- `custom-instruction` 모드는 D3RO의 CustomInstructionService로 매핑 가능, 설계서에 명시 필요
- `hands-free-no-wake` 모드는 검증 항목 3에서 별도 기술
### MAJOR-04: HotkeyService 이벤트 구조 차이
- **설계서 위치**: `00-master-architecture.md` 257-262행
- **실제 소스 위치**: `HotkeyService.js` 35-51행
- **차이점**:
- 설계서: `hotkey:dictation-pressed`, `hotkey:dictation-released`, `hotkey:dictation-double-press`, `hotkey:command-pressed`
- Speakly 실제: 범용 `hotkey:pressed` / `hotkey:released` 이벤트에 `commandId` 페이로드 포함
- 더블 프레스는 HotkeyService가 아니라 VoiceModeService에서 타이밍으로 감지
- **수정 제안**: HotkeyService 이벤트를 범용 `hotkey:pressed(commandId, timestamp)` / `hotkey:released(commandId, timestamp)` 패턴으로 변경. 더블 프레스 감지는 VoiceModeService 책임으로 명시.
### MINOR-01: 오디오 샘플레이트 불일치
- **설계서 위치**: `01-service-specifications.md` 68행 (`sampleRate: 16_000`)
- **실제 소스 위치**: `config/constants.js` 47행 (`SAMPLE_RATE: 24000`)
- **차이점**: 설계서는 Whisper 기본인 16kHz를 기술하나, Speakly는 24kHz로 캡처한다 (Opus 인코딩 후 서버 전송).
- **수정 제안**: D3RO는 로컬 Whisper를 사용하므로 16kHz가 맞을 수 있으나, Speakly 원본이 24kHz임을 각주로 명시. 코덱이 Opus인 경우 24kHz가 필요할 수 있음.
---
## 3. 누락 패턴 검증
### CRITICAL-02: accidentalPress 감지 패턴 미기술
- **실제 소스 위치**: `VoiceModeService.js` 116행, 3251-3293행
- **패턴 설명**:
- `MIN_AUDIO_DURATION_MS = 700ms` 미만 키 누름은 accidental press로 판정
- 두 가지 판정 기준: (1) key press duration < 700ms, (2) session lifetime < 700ms
- 세션에 `accidentalPress = true` 마킹 후 즉시 취소
- `checkAndMarkAccidentalPress()` 메서드로 녹음 정지 전에 조기 체크
- **설계서 상태**: 완전 누락
- **수정 제안**: `01-service-specifications.md` VoiceModeService 섹션에 accidentalPress 감지 로직 추가:
```
키 릴리스 시점에서 duration < MIN_AUDIO_DURATION_MS(700ms) 세션 취소
```
### CRITICAL-03: Audio Mute 연동 패턴 미기술
- **실제 소스 위치**: `VoiceModeService.js` 118-120행, 2726-2810행
- **패턴 설명**:
- 녹음 시작 시 시스템 오디오 음소거 (`NativeService.muteSystemAudio()`)
- `MUTE_DELAY_MS = 500ms` — 사운드 이펙트 재생 후 음소거
- `wasMutedBeforeRecording` — 이미 음소거였으면 녹음 후 언뮤트 안함
- `UNMUTE_SOUND_DELAY_MS = 100ms` — 언뮤트 후 종료 효과음 재생 지연
- `config:getMuteAudioWhenDictating` 설정으로 on/off 가능
- 루프백 마이크 사용 시 음소거 스킵 (`_isLoopbackMic()`)
- **설계서 상태**: 완전 누락
- **수정 제안**: `01-service-specifications.md` VoiceModeService에 mute/unmute 시퀀스 추가
### CRITICAL-04: Retry 로직 (이전 세션 오디오 재전송) 미기술
- **실제 소스 위치**: `VoiceModeService.js` 181-182행, 2563-2594행, 3316-3320행+
- **패턴 설명**:
- `retryRecognition(historyId)` — 이전 세션의 저장된 오디오 파일을 읽어 재전송
- `retryingSession` / `retryingHistoryId` 상태 추적
- `history:retry` IPC 채널로 렌더러에서 호출 가능
- ESC 키로 retry 취소 가능
- No-wake 모드 취소 시 `undoNoWakeCancel()` → 자동 retry
- 오디오 파일이 디스크에 저장된 세션만 retry 가능
- **설계서 상태**: 완전 누락
- **수정 제안**: 별도 섹션으로 Retry 흐름도 추가
### CRITICAL-05: Action Queue (이벤트 직렬화) 패턴 미기술
- **실제 소스 위치**: `VoiceModeService.js` 186-275행
- **패턴 설명**:
- `actionQueue: NXAction[]` — 키보드 이벤트를 큐에 넣고 순차 처리
- `isProcessingActionQueue` 플래그로 동시 실행 방지
- 핫키 press/release 이벤트 간 race condition 방지
- NXAction 클래스: `type`, `timestamp`, `hotkeyId`, `hotkeyTimestamp`
- ESC 시 `clearActionQueue()`로 전체 큐 클리어
- **설계서 상태**: 완전 누락
- **수정 제안**: VoiceModeService 명세에 Action Queue 패턴 추가. 이것은 동시 키 이벤트 안정성의 핵심.
### MAJOR-05: hands-free-no-wake 모드 미기술
- **실제 소스 위치**: `VoiceModeService.js` 94행, 103행, 656-689행
- **패턴 설명**:
- 별도 핫키로 진입하는 핸즈프리 모드
- 녹음 중 취소 시 오디오를 디스크에 보존 (`cancelledSessionId`)
- 나중에 `undoNoWakeCancel()`로 자동 retry 가능
- `getNoWakeModeEnabled()` 설정으로 on/off
- **설계서 상태**: 완전 누락
- **수정 제안**: VoiceModeService VoiceMode 열거형에 추가, 전용 핫키 등록 로직 기술
### MAJOR-06: RecordingTip 2-phase 리사이즈는 기술됨, 하지만 preload 전략 누락
- **설계서 위치**: `02-ipc-and-types.md` 159-166행 (tipMeasured, tipPrepare, tipShow)
- **실제 소스 위치**: `RecordingTipWindow.js` 157행 (`preload()` 메서드)
- **차이점**: 설계서는 2-phase IPC는 기술했으나, RecordingTipWindow의 `preload()` 메서드 — 앱 시작 시 윈도우를 미리 생성해놓고 숨겨두는 전략이 누락.
- **수정 제안**: 초기화 시퀀스(00-master 단계 10)에 "팝업 윈도우 프리로드" 설명 보강
### MAJOR-07: 마이크 디바이스 팁(NewMicrophonePrompt) 미기술
- **실제 소스 위치**: `main/index.js` 74행, 1090-1108행, `main/NewMicrophonePromptWindow.js`
- **패턴 설명**:
- 새 마이크 디바이스 연결 시 전용 프롬프트 윈도우 표시
- "이 마이크로 전환?" + "다시 묻지 않기" 옵션
- `config:getDontPromptNewMicrophone` / `config:setShowMicDeviceTip` 설정
- `NewMicrophonePromptWindow` 클래스 — 독립 윈도우
- **설계서 상태**: 완전 누락
- **수정 제안**: `03-db-and-ui.md`에 NewMicrophonePrompt 윈도우 추가
### MAJOR-08: ResultPopup auto-close 타이밍 미기술
- **실제 소스 위치**: `main/ResultPopupWindow.js` 27-28행, 249-269행
- **패턴 설명**:
- 기본 `autoCloseDelay = 10000ms` (10초)
- 마우스 호버 시 타이머 일시 정지
- 마우스 떠나면 타이머 재개
- `autoCloseDelay: 0`이면 auto-close 비활성화
- `content-ready` 이벤트 시 타이머 시작
- **설계서 상태**: `02-ipc-and-types.md``window:showResultPopup` 채널은 있으나 auto-close 동작 미기술
- **수정 제안**: ResultPopup 동작 명세에 auto-close 타이밍 추가
### MAJOR-09: Typing Nudge 패턴 미기술
- **실제 소스 위치**: `VoiceModeService.js` 196-208행, 706-796행
- **패턴 설명**:
- 사용자가 5초간 연속 타이핑 (10키 이상) 시 "음성 사용해보세요" 넛지 표시
- 하루 최대 3회, 30분 간격
- 오늘 이미 음성 사용했으면 억제
- 자정 넘으면 자동 리셋
- `typing-nudge` 이벤트 emit
- **설계서 상태**: 완전 누락
- **수정 제안**: D3RO에서 이 기능이 필요한지 결정 후, 채택 시 VoiceModeService 명세에 추가
### MAJOR-10: Mode Switch 패턴 (녹음 중 모드 전환) 미기술
- **실제 소스 위치**: `VoiceModeService.js` 626-651행
- **패턴 설명**:
- 녹음 중 다른 모드 핫키 누르면 오디오 청크를 보존하며 모드 전환
- `savedAudioChunks = [...this.audioChunks]` → cancelRecording → startRecording(savedAudioChunks)
- `sessionType: 'mode-switch'` — 시작 사운드 억제, UI 전환 최소화
- **설계서 상태**: 완전 누락
- **수정 제안**: VoiceModeService 시퀀스 다이어그램에 mode-switch 분기 추가
---
## 4. IPC 채널 누락 분석
설계서 IPC 채널 수: **113개** (02-ipc-and-types.md 기준)
Speakly 실제 IPC 채널 수: **~160개** (preload/index.js에서 추출)
### 의도적 제거 (클라우드/인증 관련) — 문제 없음
| 네임스페이스 | 채널 수 | 이유 |
|---|---|---|
| `auth:*` | 11 | 로컬 전용, 인증 불필요 |
| `feedback:*` | 1 | 클라우드 피드백 |
| `userInfo:*` | 6 | 클라우드 사용자 정보 |
| `update:*` | 8 | Phase 7으로 연기 |
| `logUpload:*` | 4 | 클라우드 로그 업로드 |
| `banner:*` | 4 | 클라우드 배너 |
| `report:*` | 1 | 클라우드 오류 보고 |
| `diagnostics:*` | 3 | 디버그 진단 |
| `debug:*` | 1 | 접근성 트리 탐색 |
### 실수 누락 가능성 (검토 필요)
| 채널명 | 설계서 | Speakly | 판정 |
|--------|--------|---------|------|
| `history:retry` | 없음 | 있음 | **누락** — retry 기능에 필수 |
| `history:getLatestId` | 없음 | 있음 | **누락** — undo/retry에 사용 |
| `history:getRecentSessions` | 없음 | 있음 | **누락** — 대시보드에 사용 |
| `history:deleteByDuration` | 없음 | 있음 | 선택적 — 짧은 녹음 일괄 삭제 |
| `history:getStats` / `resetStats` | 없음 | 있음 | `stats:*`로 통합된 것으로 보이나 확인 필요 |
| `config:getMuteAudioWhenDictating` | 없음 | 있음 | **누락** — mute 연동 설정 |
| `config:getSelectedMicrophone` | 없음 | 있음 | **누락** — 마이크 선택 저장 |
| `config:setSelectedMicrophone` | 없음 | 있음 | **누락** |
| `config:getTranslateLanguage` | 없음 | 있음 | **누락** — 번역 언어 설정 |
| `config:getSoundEffectsEnabled` | 없음 | 있음 | **누락**`system:isSoundEnabled`으로 매핑? |
| `config:getDontPromptNewMicrophone` | 없음 | 있음 | **누락** — 마이크 프롬프트 설정 |
| `config:shouldShowHandsFreePromo` | 없음 | 있음 | 선택적 — 프로모션 UI |
| `hotkey:getHandsFreeNoWakeShortcut` | 없음 | 있음 | **누락** — no-wake 모드 핫키 |
| `hotkey:setHandsFreeNoWakeShortcut` | 없음 | 있음 | **누락** |
| `hotkey:getVoiceModeEnabled` | 없음 | 있음 | **누락** — 모드별 활성화 토글 |
| `hotkey:setVoiceModeEnabled` | 없음 | 있음 | **누락** |
| `hotkey:pauseVoiceMode` | 없음 | 있음 | **누락** — 설정 UI 중 핫키 일시정지 |
| `hotkey:resumeVoiceMode` | 없음 | 있음 | **누락** |
| `hotkey:validateHotkey` | 없음 | 있음 | **누락** — 핫키 유효성 검증 |
| `hotkey:resetToDefault` | 없음 | 있음 | **누락** — 기본값 복원 |
| `hotkey:getAvailableKeys` | 없음 | 있음 | **누락** — 사용 가능한 키 목록 |
| `hotkey:setOverrideAppBundleId` | 없음 | 있음 | 선택적 — 온보딩/테스트 전용 |
| `hotkey:refreshRequired` | 없음 | 있음 | **누락** — 핫키 재등록 알림 |
| `clipboard:copy` | 없음 | 있음 | **누락** — 결과 클립보드 복사 |
| `input:insertText` | 없음 | 있음 | `system:insertText`로 매핑됨, 확인 필요 |
| `input:getCursorState` | 없음 | 있음 | 선택적 — postInsertCursorContext |
| `app:getActiveApplication` | 없음 | 있음 | `system:getActiveApp`으로 매핑됨 |
| `app:getAppIcon` | 없음 | 있음 | 선택적 — 앱 아이콘 표시 |
| `voice:startWithCustomInstruction` | 없음 | 있음 | **누락** — Custom Instruction 녹음 |
| `voice:stopForCustomInstruction` | 없음 | 있음 | **누락** |
| `voice:cancel` | 없음 | 있음 | **누락** — 별도 취소 채널 (voice:cancelRecording과 다름) |
| `customInstruction:*` (9개+) | 있음(일부) | 있음(많음) | 키 이벤트 관련 채널 다수 누락 |
---
## 5. 타이밍 상수 검증
| 상수 | 설계서 값 | Speakly 실제 값 | 일치 | 비고 |
|------|----------|-----------------|------|------|
| 오디오 샘플레이트 | 16,000 Hz | **24,000 Hz** | **불일치** | 설계서는 Whisper 기본, Speakly는 Opus 기반 |
| 오디오 프레임 크기 | 60ms | 60ms | 일치 | |
| 바이트/프레임 | 1,920 | 계산 시 **2,880** (24000*2*0.06) | **불일치** | 샘플레이트 차이에 따름 |
| 디바이스 폴링 간격 | 2초 | 확인 필요 | - | |
| ERROR_AUTO_HIDE_DELAY | 미기술 | **3,000ms** | 누락 | |
| ERROR_WITH_RETRY_AUTO_HIDE_DELAY | 미기술 | **10,000ms** | 누락 | |
| NOTICE_AUTO_HIDE_DELAY | 미기술 | **3,000ms** | 누락 | |
| MAX_RETRY_ATTEMPTS | 미기술 | **3** | 누락 | |
| PRESS_HOLD_THRESHOLD | 미기술 | **10ms** | 누락 | Speakly 주석에 "changed from 300ms" |
| SECOND_PRESS_THRESHOLD | 미기술 | **300ms** | 누락 | 더블 프레스 감지 |
| STOP_DELAY_MS | 미기술 | **200ms** | 누락 | 키 릴리스 후 정지 지연 |
| MIN_AUDIO_DURATION_MS | 미기술 | **700ms** | 누락 | accidentalPress 판정 |
| MUTE_DELAY_MS | 미기술 | **500ms** | 누락 | 사운드 이펙트 후 음소거 |
| UNMUTE_SOUND_DELAY_MS | 미기술 | **100ms** | 누락 | 언뮤트 후 효과음 지연 |
| POST_RECORDING_WAIT_TIME | 미기술 | **4,000ms** | 누락 | 녹음 후 연결 대기 |
| RETRY_MAX_CONNECT_WAIT_TIME | 미기술 | **15,000ms** | 누락 | 파일 리플레이 연결 대기 |
| FINISH_IDLE_TIMEOUT | 미기술 | **30,000ms** | 누락 | commit 후 완료 대기 |
| FINISH_MAX_WAIT | 미기술 | **120,000ms** | 누락 | 완료 절대 최대 대기 |
| ResultPopup autoCloseDelay | 미기술 | **10,000ms** | 누락 | |
| HEARTBEAT_INTERVAL | 미기술 | **5,000ms** | 누락 | WebSocket 핑 간격 |
| HEARTBEAT_TIMEOUT | 미기술 | **15,000ms** | 누락 | 서버 응답 타임아웃 |
| SESSION_MONITOR_INTERVAL | 미기술 | **5,000ms** | 누락 | 세션 모니터링 타이머 |
| SYNC_DELAY_MS (HotkeyService) | 미기술 | **500ms** | 누락 | 핫키 동기화 디바운스 |
**수정 제안**: `01-service-specifications.md` 각 서비스 섹션에 타이밍 상수 표 추가. D3RO 고유 값이 다를 경우 주석으로 Speakly 원본 값 명시.
---
## 6. DB 스키마 검증
### MINOR-02: history 테이블 컬럼 차이
| 컬럼 | 설계서 | Speakly 실제 | 판정 |
|------|--------|-------------|------|
| `focused_app_bundle_id` | 제거됨 | **있음** | 설계서에서 의도적 제거 (macOS 전용) → OK |
| `window_web_title` | 제거됨 | **있음** | 설계서에서 의도적 제거 → OK |
| `window_web_domain` | 제거됨 | **있음** | 설계서에서 의도적 제거 → OK |
| `window_web_url` | 제거됨 | **있음** | 설계서에서 의도적 제거 → OK |
| `audio_metadata` | 제거됨 | **있음** (JSON) | 설계서에서 의도적 제거 → OK |
| `mic_device_info` | 제거됨 | **있음** (JSON) | 설계서에서 의도적 제거 → OK |
| `user_id` | 제거됨 | **있음** | 로컬 단일 사용자 → OK |
| `error_code` | **추가됨** | 없음 | D3RO 신규 — OK |
| `stt_model` | **추가됨** | 없음 | D3RO 신규 — OK |
| `llm_model` | **추가됨** | 없음 | D3RO 신규 — OK |
| `stt_latency_ms` | **추가됨** | 없음 | D3RO 신규 — OK |
| `llm_latency_ms` | **추가됨** | 없음 | D3RO 신규 — OK |
| `selected_text` | 미언급 | **없음** | 설계서 "제거" 목록에 있으나 Speakly에도 없음 → 각주 정정 필요 |
### MINOR-03: history 모드 열거형 차이
- 설계서: `'dictation' | 'translate' | 'command'`
- Speakly: `'dictation' | 'hands-free' | 'translation'`
- **수정 제안**: D3RO 고유 모드명 사용은 문제 없으나, Speakly 원본과의 매핑을 각주로 명시
### MINOR-04: stats 테이블 차이
- 설계서에 `streak_days`, `last_session_at` 추가 — Speakly에 없는 D3RO 신규 필드 → OK
- Speakly의 `stats` 테이블은 설계서와 동일한 구조 (id=1 싱글턴)
### MINOR-05: 인덱스 차이
- 설계서: `idx_history_created_at` (단일 컬럼)
- Speakly: `idx_history_user_created_at` (user_id, created_at 복합 인덱스)
- 로컬 단일 사용자이므로 설계서의 단순화가 적절 → OK
---
## 7. 추가 발견 사항
### MINOR-06: NativeService koffi+DLL 대체 방안 명확화 필요
- **실제 소스**: `NativeService.js` 44행 — `koffi` 모듈로 네이티브 DLL 로드
- **설계서 위치**: `00-master-architecture.md` 166행 — "제거, npm 패키지로 대체"
- **이슈**: NativeService는 키보드 모니터링, 마이크 캡처, 시스템 오디오 음소거, 앱 모니터링 등 광범위한 기능 제공. 이를 대체할 npm 패키지 목록이 불완전.
- **수정 제안**: NativeService가 제공하는 기능별 대체 패키지 매핑 표 작성:
- 키보드 모니터링 → `uiohook-napi` (기술됨)
- 마이크 캡처 → 미명시 (Web Audio API? node-audiorecorder?)
- 시스템 오디오 음소거 → 미명시 (`loudness`? `@aspect-build/napi-audio`?)
- 앱 모니터링 → 미명시 (`active-win`?)
- 클립보드 → `@nut-tree/nut` (기술됨)
- 텍스트 삽입 → `@nut-tree/nut` (기술됨)
- 핫키 인터셉트 → `uiohook-napi` (기술됨)
### MINOR-07: TextOperationStrategy의 앱별 삽입 전략 미기술
- **실제 소스**: `TextOperationStrategy.js` 12-36행
- **패턴**: 앱별로 다른 텍스트 삽입 방법 (clipboard vs keyboard), 선택 방법 (clipboard vs ax vs none), 검증 모드 (auto vs skip) 설정
- **설계서**: `TextInsertService`에서 일률적으로 "clipboard save → set → Ctrl+V → restore" 기술
- **수정 제안**: 앱별 규칙(rules) 시스템 최소한으로 기술. D3RO 로컬 환경에서는 원격 config 불필요하나, 기본 fallback 규칙은 유지 필요.
### MINOR-08: 세션 모니터링 패턴
- **실제 소스**: `VoiceModeService.js` 184-185행, 209-210행
- **패턴**: `sessionMonitorTimer` (5초 간격) — 좀비 세션 감지 및 정리
- **설계서 상태**: 누락
- **수정 제안**: VoiceModeService 안정성 패턴으로 추가
---
## 8. 설계서에 추가해야 할 패턴 목록 (우선순위순)
| # | 패턴 | 중요도 | 대상 설계서 |
|---|------|--------|------------|
| 1 | **Action Queue (이벤트 직렬화)** | Critical | 01-service-specifications.md |
| 2 | **accidentalPress 감지** | Critical | 01-service-specifications.md |
| 3 | **Audio Mute 연동** | Critical | 01-service-specifications.md |
| 4 | **Retry 로직 (오디오 파일 재전송)** | Critical | 01-service-specifications.md |
| 5 | **RecognitionState CONNECTING/DESTROYED** | Critical | 02-ipc-and-types.md |
| 6 | **hands-free-no-wake 모드** | Major | 01-service-specifications.md |
| 7 | **Mode Switch (녹음 중 모드 전환)** | Major | 01-service-specifications.md |
| 8 | **ResultPopup auto-close 타이밍** | Major | 03-db-and-ui.md |
| 9 | **NewMicrophonePrompt 윈도우** | Major | 03-db-and-ui.md |
| 10 | **RecordingTipUIState 열거형** | Major | 02-ipc-and-types.md |
| 11 | **타이밍 상수 표** | Major | 01-service-specifications.md |
| 12 | **누락 IPC 채널 (~25개)** | Major | 02-ipc-and-types.md |
| 13 | **NativeService 대체 패키지 매핑** | Minor | 00-master-architecture.md |
| 14 | **앱별 TextOperation 규칙** | Minor | 01-service-specifications.md |
| 15 | **Typing Nudge** | Minor | 결정 후 추가 |
| 16 | **세션 모니터링 타이머** | Minor | 01-service-specifications.md |
| 17 | **RecordingTip preload 전략** | Minor | 00-master-architecture.md |
---
## 9. 결론
설계서는 Speakly의 기본 아키텍처와 주요 서비스 구조를 잘 반영하고 있으나, **VoiceModeService의 핵심 런타임 패턴**(Action Queue, accidentalPress, mute 연동, retry, mode switch)이 거의 완전히 누락되어 있다. 이 패턴들은 사용자 경험의 안정성과 직결되므로 구현 전에 반드시 설계서에 반영해야 한다.
또한 IPC 채널이 약 25개 누락되어 있으며, 특히 hotkey 관련 채널(pause/resume, validate, availableKeys, no-wake 단축키)과 history:retry 채널이 중요하다.
상태 머신은 대체로 일치하나 `CONNECTING``DESTROYED` 상태가 빠져 있어 상태 전이 다이어그램 수정이 필요하다.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,230 @@
# 06. 갭 분석 리포트
> 작성일: 2026-04-04
> 분석 대상: 00-master-architecture.md, 01-service-specifications.md, 02-ipc-and-types.md, 03-db-and-ui.md, CLAUDE.md
---
## 1. 설계서 간 불일치/충돌
### 1.1 서비스 이름 불일치
| 항목 | 00 (마스터 아키텍처) | 01 (서비스 명세) | 02 (IPC 채널) | 수정 제안 |
|------|---------------------|-----------------|--------------|----------|
| TTS 서비스 | `TTSService` | `LocalTTSService` (섹션 3) | `LocalTTSService` (tts:* 담당) | **`LocalTTSService`로 통일** — 00에서 수정 |
| LLM 서비스 | `LLMService` | `LocalLLMService` (섹션 4) | `OllamaService` (llm:* 담당) | **`LocalLLMService`로 통일** — 00, 02에서 수정 |
| STT 서비스 | `LocalSTTService` | `LocalSTTService` | `LocalSTTService` | 일치 (OK) |
### 1.2 서비스 개수 불일치
- **00**: 15개 서비스 (명시적 목록: 표 2.1)
- **01**: 10개 서비스만 상세 명세 (AudioCapture, LocalSTT, LocalTTS, LocalLLM, VoiceMode, TextInsert, Hotkey, Config, History, WindowManager)
- **빠진 서비스 명세**: I18nService, LoggerService, DictionaryService, SoundEffectService, CustomInstructionService, AutoLaunchService (6개)
- **01에만 있는 서비스**: WindowManagerService (00의 WindowManager를 서비스로 격상)
**수정 제안**: 01에 빠진 6개 서비스의 상세 명세를 추가하거나, 별도 문서로 분리
### 1.3 02의 IPC 채널에서만 등장하는 서비스/네임스페이스
| 02 IPC 채널 | 담당 서비스 명 | 00/01 존재 여부 | 비고 |
|-------------|--------------|----------------|------|
| `system:*` | SystemService, PermissionService | 00/01에 없음 | 00에서 제거된 PermissionService가 02에 부활 |
| `stats:*` | StatsService | 00/01에 없음 | 03의 stats 테이블과 관련되지만 서비스 명세 없음 |
| `dictionary:*` | DictionaryService | 00에 있지만 01에 명세 없음 | |
**수정 제안**:
- `system:*` 채널의 담당 서비스를 명확히 정의 (신규 SystemService? 또는 기존 서비스에 분배?)
- StatsService를 00의 서비스 목록에 추가하거나 HistoryService에 통합 명시
- 01에 DictionaryService 명세 추가
### 1.4 IPC 네임스페이스 규칙 불일치
**00의 규칙**: `${feature}:${action}` (예: `hotkey:setDictation`, `history:getRecent`, `app:getVersion`)
**02의 실제 채널**: `hotkey:setDictationShortcut`, `history:getAll`, `system:getVersion`
| 00 예시 | 02 실제 | 차이 |
|---------|---------|------|
| `hotkey:setDictation` | `hotkey:setDictationShortcut` | action 이름 불일치 |
| `history:getRecent` | `history:getAll` | 채널 자체가 다름 (getRecent 없음) |
| `app:getVersion` | `system:getVersion` | 네임스페이스 다름 (`app:` vs `system:`) |
| `dictionary:addWord` | `dictionary:add` | action 이름 불일치 |
| `instruction:*` (00에 명시) | 02에 없음 | CustomInstruction 채널 전체 누락 |
**수정 제안**: 00의 5.1절 예시를 02의 실제 채널명과 일치하도록 갱신
### 1.5 이벤트 타입 이름/구조 불일치 (00/01 vs 02)
| 항목 | 00/01 (서비스 이벤트) | 02 (IPC 타입) | 불일치 |
|------|----------------------|---------------|--------|
| 에러 타입 | `ServiceError` (01 공통타입) | `ErrorCode` enum (00 §6.2) | 01의 ServiceError는 문자열 코드, 00의 ErrorCode는 숫자 enum |
| AudioDevice | `{ id, name, isDefault }` (01) | `{ deviceId, label, isDefault }` (02) | 필드명 불일치: `id`/`deviceId`, `name`/`label` |
| STTModel | `{ id, name, size, language, downloaded }` (01) | `{ id, name, sizeBytes, downloaded, languages, accuracy, speed }` (02) | 02가 더 풍부. `language` (단일) vs `languages` (배열) 차이 |
| RecognitionState | `Idle/Preparing/Ready/Recognizing/Processing/Completing/Completed/Cancelled/Error` (01) | `IDLE/PREPARING/READY/RECOGNIZING/COMPLETING/COMPLETED/CANCELLED/ERROR` (02) | 01에 `Processing` 있음, 02에 없음. 케이싱도 다름 (PascalCase vs UPPER_CASE) |
| VoiceMode | `Dictation/HandsFree` (01, const enum) | `'dictation' / 'hands-free'` (02, string literal) | 타입 표현 방식 차이 |
**수정 제안**: 02의 `shared/types.ts`를 정본(source of truth)으로 하고, 01의 타입을 02에 맞춰 갱신
### 1.6 DB 스키마 vs HistoryService/IPC 필드 불일치
| 03 DB history 컬럼 | 02 HistoryEntry 타입 | 불일치 |
|-------------------|---------------------|--------|
| `original_text` | `originalText` | OK (camelCase 변환) |
| `polished_text` | `processedText` | **이름 불일치**: `polished` vs `processed` |
| `mode` (dictation/translate/command) | `llmAction` (refine/translate/...) | **의미 불일치**: DB의 mode는 녹음 모드, IPC의 llmAction은 LLM 처리 유형 |
| `focused_app`, `focused_app_name`, `focused_app_window_title` | `targetApp` (단일 필드) | **세분화 불일치**: DB는 3개 필드, IPC는 1개 |
| `duration` (초, REAL) | `durationMs` (ms) | **단위 불일치**: 초 vs 밀리초 |
| `stt_model`, `llm_model`, `stt_latency_ms`, `llm_latency_ms` | 해당 필드 없음 | **02에 누락** |
| `error_code`, `status`, `audio_local_path`, `detected_language`, `mic_device`, `app_version` | 해당 필드 없음 | **02에 누락** |
| 해당 컬럼 없음 | `sessionId` | **03에 누락** (id가 sessionId와 동일하다는 주석이 있으나 별도 필드는 없음) |
**수정 제안**: 02의 HistoryEntry 타입을 03의 DB 스키마와 1:1 매핑되도록 확장
### 1.7 Dictionary 스키마 vs IPC 타입 불일치
| 03 DB dictionary 컬럼 | 02 DictionaryEntry 타입 | 불일치 |
|----------------------|------------------------|--------|
| `word`, `pronunciation`, `category`, `usage_count` | `from`, `to`, `caseSensitive`, `enabled`, `useCount` | **완전히 다른 구조**: DB는 "단어+발음" 패턴, IPC는 "교정(from→to)" 패턴 |
**수정 제안**: 사전의 목적을 명확히 한 후 어느 한쪽으로 통일. "커스텀 단어 사전"(03)과 "자동 교정 사전"(02)은 다른 기능이므로, 두 테이블이 필요할 수 있음
### 1.8 IPC 방향 불일치
| 채널 | 00 §5.2 예시 | 02 채널 테이블 | 불일치 |
|------|-------------|---------------|--------|
| `voice:startRecording` | `on` (fire-and-forget) | `handle` (요청→응답) | 00은 fire-and-forget, 02는 양방향 |
| `voice:stopRecording` | `on` (fire-and-forget) | `handle` (요청→응답) | 동일 불일치 |
**수정 제안**: 02의 handle 방식이 더 적절 (sessionId 반환 필요). 00 §5.2를 갱신
---
## 2. 빠진 설계 영역
### 우선순위 High
| # | 영역 | 현황 | 필요한 내용 |
|---|------|------|------------|
| 1 | **빌드 설정** | CLAUDE.md에 `electron-vite` 언급만 있고, `electron.vite.config.ts`의 구체적 설정 없음 | electron-vite 설정 (main/preload/renderer 엔트리, external 모듈, 네이티브 모듈 처리), tsconfig.json (paths, target, module), vite.config.ts (proxy, define, alias) |
| 2 | **패키징 설정** | 00에 `electron-builder.yml` 파일 트리에 없음 | electron-builder.yml (app ID, productName, files, nsis/msi 설정, extraResources: Whisper/Piper 바이너리 번들링, afterSign hook) |
| 3 | **프로젝트 초기화** | CLAUDE.md에 `npm run dev` 등 명령어만 있음 | package.json 의존성 전체 목록, postinstall (네이티브 모듈 리빌드), scripts 정의, electron-rebuild 설정 |
| 4 | **보안 (preload 안전성, IPC 검증)** | 00에 contextIsolation:true 언급만 있음 | preload에서 expose할 채널 화이트리스트, IPC 입력값 검증 (zod schema), webPreferences 전체 설정 (sandbox, webSecurity), CSP 헤더 |
### 우선순위 Medium
| # | 영역 | 현황 | 필요한 내용 |
|---|------|------|------------|
| 5 | **테스트 전략** | CLAUDE.md에 `vitest` 언급만 있음 | 테스트 피라미드 (단위/통합/E2E 비율), 서비스별 테스트 파일 매핑, mock 전략 (Electron IPC, better-sqlite3, child_process), E2E 프레임워크 선택 (Playwright? Spectron?), CI 파이프라인 |
| 6 | **로깅 전략** | 00에 `LoggerService = electron-log 래퍼` 정도만 있음 | 로그 레벨 정책 (info/warn/error 기준), 로그 파일 위치/로테이션/최대 크기, 카테고리별 로거 (audio, stt, llm, ipc), 민감 정보 마스킹, 디버그 모드 활성화 방법 |
| 7 | **성능 기준** | 설계서에 타이밍 상수는 있으나 성능 목표 없음 | STT 지연 목표 (base 모델 기준 < Xms), LLM 응답 시간 목표, UI 응답 시간 (FID < 100ms), 메모리 사용량 상한, CPU 사용량 기준 |
| 8 | **Sidecar 바이너리 관리** | STT/TTS sidecar 언급은 있으나 바이너리 배포 방식 미정의 | faster-whisper Python 환경 번들링 (PyInstaller? embedded Python?), Piper 바이너리 배포 방식, 모델 파일 저장 경로 (userData), 버전 관리, 자동 업데이트 |
### 우선순위 Low
| # | 영역 | 현황 | 필요한 내용 |
|---|------|------|------------|
| 9 | **접근성** | 설계서에 언급 없음 | 키보드 네비게이션 (Tab order, focus trap), aria-label/role, 스크린 리더 호환성, 고대비 모드, 폰트 크기 조정 |
| 10 | **에러 복구 전략** | 에러 코드 정의는 있으나 복구 흐름 미정의 | 사이드카 크래시 시 자동 재시작 정책, 네트워크(Ollama) 끊김 시 재연결 로직, DB 손상 시 복구 절차 |
| 11 | **마이그레이션 전략** | 03에 drizzle-orm migrate 호출만 있음 | 마이그레이션 파일 생성/관리 방법, 스키마 버전 관리, 하위 호환성 정책 |
| 12 | **국제화(i18n) 상세** | 00에 I18nService 서비스 목록만 있음 | 번역 키 관리 방식 (JSON? ts?), 번역 파일 구조, fallback 언어, 날짜/숫자 포맷 |
---
## 3. 실현 가능성 리스크
### 3.1 faster-whisper sidecar의 실시간성
| 항목 | 내용 |
|------|------|
| **리스크** | 01의 설계는 "오디오 버퍼 전체를 한번에 전사"하는 배치 방식. 실시간 스트리밍 전사 불가. |
| **영향도** | **High** — 사용자가 긴 문장을 말할 때 녹음 종료 후 전사 지연이 체감됨 |
| **현재 설계** | stdin으로 base64 인코딩된 오디오를 보내고 stdout으로 결과 수신 (01 §2 Sidecar 통신 프로토콜) |
| **문제점** | (1) base64 인코딩 오버헤드 (~33% 크기 증가), (2) 전체 오디오를 버퍼링 후 전송하므로 first-token latency가 높음, (3) faster-whisper 자체가 파일/버퍼 단위 처리 (진정한 스트리밍 미지원) |
| **대안** | (A) WAV 파일 임시 저장 후 파일 경로 전달 (base64 오버헤드 제거), (B) whisper.cpp의 stream 모드 사용 (진정한 실시간), (C) 청크 분할 전사 + 결합 (VAD 기반 세그먼트 단위), (D) faster-whisper의 `--live` 모드 활용 (커뮤니티 fork) |
| **권장** | 초기에는 WAV 파일 전달 방식(A)으로 구현. 지연이 문제 시 whisper.cpp stream(B)으로 전환 |
### 3.2 uiohook-napi Windows 호환성
| 항목 | 내용 |
|------|------|
| **리스크** | uiohook-napi v1.x는 Electron 33+에서 context-aware 네이티브 모듈로 빌드 필요 |
| **영향도** | **Medium** — 빌드 실패 시 핫키 기능 전체 불가 |
| **문제점** | (1) N-API 버전 호환성 확인 필요, (2) electron-rebuild로 리빌드 시 빌드 도구(MSVC, Python 3) 필요, (3) Windows Defender가 키보드 후킹을 위협으로 감지할 수 있음 |
| **대안** | (A) Electron의 globalShortcut API (제한적이지만 네이티브 모듈 불필요), (B) iohook (더 오래된 포크, 유지보수 우려), (C) PowerShell 스크립트로 키 후킹 (복잡도 높음) |
| **권장** | uiohook-napi를 우선 시도하되, `electron-rebuild` 설정을 빌드 문서에 명시. globalShortcut은 hold-to-talk 미지원이므로 fallback으로 부적합 |
### 3.3 @nut-tree/nut-js 관리자 권한 이슈
| 항목 | 내용 |
|------|------|
| **리스크** | Windows에서 키보드 시뮬레이션(Ctrl+V)에 관리자 권한이 필요할 수 있음 |
| **영향도** | **Medium** — UAC 프롬프트 없이 텍스트 삽입 불가 시 핵심 기능 차질 |
| **문제점** | (1) 일부 앱(관리자 권한으로 실행된 앱)에 키 입력 불가, (2) nut-js v3은 prebuild 바이너리 제공하지만 Electron과의 호환성 미확인, (3) Windows UAC 설정에 따라 동작 불일치 |
| **대안** | (A) Electron의 `clipboard.writeText()` + `robot.js`로 Ctrl+V 시뮬레이션, (B) PowerShell `SendKeys`, (C) Windows Input Simulator (C++ addon), (D) `node-key-sender` |
| **권장** | nut-js로 우선 구현. 관리자 권한 앱 대상 실패 시 `app.setAsDefaultProtocolClient`나 매니페스트에 `uiAccess: true` 설정 검토 |
### 3.4 better-sqlite3 + Electron 네이티브 모듈 빌드
| 항목 | 내용 |
|------|------|
| **리스크** | better-sqlite3는 C++ 네이티브 모듈로 Electron 버전과 Node.js ABI 불일치 시 빌드 실패 |
| **영향도** | **High** — DB 초기화 실패 시 앱 전체 불가 (critical step) |
| **문제점** | (1) electron-rebuild가 필요하지만 MSVC 빌드 도구 필수, (2) Electron 33의 Node.js 버전과 better-sqlite3 prebuild 버전 일치 여부 불확실, (3) asar 패키징 시 .node 파일 제외 필요 |
| **대안** | (A) `@neondatabase/sql.js` (WASM 기반, 빌드 불필요하지만 성능 저하), (B) `sql.js` (WASM), (C) `drizzle-orm/libsql` (libsql WASM 바인딩) |
| **권장** | better-sqlite3 유지. `electron-builder` extraFiles에 .node 파일 포함, `postinstall``electron-rebuild` 스크립트 추가. package.json에 `"build": { "asarUnpack": ["**/better-sqlite3/**"] }` 설정 |
### 3.5 electron-vite vs vite-plugin-electron
| 항목 | 내용 |
|------|------|
| **리스크** | 선택 근거가 문서화되지 않음 |
| **영향도** | **Low** — 둘 다 성숙한 도구이며 전환 비용은 초기에 낮음 |
| **비교** | electron-vite: 공식 Electron 지원 느낌, main/preload/renderer 분리 빌드 기본 지원, 네이티브 모듈 external 자동 처리. vite-plugin-electron: 더 가벼움, Vite 생태계 플러그인, 커스터마이징 유연. |
| **권장** | CLAUDE.md에 `electron-vite` 명시됨. 선택 근거를 설계 문서에 한 줄 추가: "electron-vite 채택 — main/preload/renderer 3-entry 빌드 기본 지원, 네이티브 모듈 external 자동 처리" |
---
## 4. 보강 권장사항 (액션 아이템)
### 4.1 설계서 수정 (기존 문서)
| # | 대상 문서 | 액션 | 우선순위 |
|---|----------|------|---------|
| A1 | 00-master-architecture.md | 서비스명 통일: `TTSService``LocalTTSService`, `LLMService``LocalLLMService` | High |
| A2 | 00-master-architecture.md | §5.1 IPC 예시를 02의 실제 채널명과 동기화 | Medium |
| A3 | 00-master-architecture.md | §5.2 `voice:startRecording`/`stopRecording`의 방향을 `handle`로 수정 | Medium |
| A4 | 01-service-specifications.md | 누락된 6개 서비스 명세 추가 (I18n, Logger, Dictionary, SoundEffect, CustomInstruction, AutoLaunch) | High |
| A5 | 01-service-specifications.md | 01의 타입 정의를 02와 통일 (AudioDevice, STTModel, RecognitionState 등) | High |
| A6 | 02-ipc-and-types.md | `instruction:*` (CustomInstruction) IPC 채널 추가 | Medium |
| A7 | 02-ipc-and-types.md | HistoryEntry 타입을 03 DB 스키마와 매핑되도록 확장 | High |
| A8 | 02-ipc-and-types.md | DictionaryEntry 타입과 03 DB dictionary 스키마 불일치 해결 | High |
| A9 | 02-ipc-and-types.md | SystemService, StatsService를 00 서비스 목록에 반영하거나, 기존 서비스에 역할 배분 | Medium |
| A10 | 03-db-and-ui.md | `polished_text``processed_text`로 변경 (또는 02 타입을 `polishedText`로 변경) | Medium |
| A11 | 03-db-and-ui.md | `duration` 단위를 ms (INTEGER)로 통일 (02의 durationMs와 일치) | Medium |
### 4.2 신규 설계 문서 작성
| # | 문서명 | 내용 | 우선순위 |
|---|--------|------|---------|
| B1 | `04-build-and-packaging.md` | electron-vite 설정, tsconfig, electron-builder.yml, 네이티브 모듈 빌드 설정, extraResources (sidecar 바이너리), scripts 정의, CI/CD 파이프라인 | High |
| B2 | `05-testing-strategy.md` | 테스트 프레임워크 (vitest), 서비스별 테스트 파일, mock 전략, E2E 전략, 커버리지 목표 | Medium |
| B3 | `07-sidecar-management.md` | faster-whisper/Piper 바이너리 배포, Python 환경 관리, 모델 저장 경로, 버전 관리, 헬스체크 상세, 크래시 복구 | High |
| B4 | `08-security-checklist.md` | preload 화이트리스트, IPC 입력 검증 (zod), webPreferences, CSP, sandbox 설정, 외부 URL 열기 제한 | Medium |
### 4.3 CLAUDE.md 보강
| # | 액션 | 우선순위 |
|---|------|---------|
| C1 | electron-vite 선택 근거 한 줄 추가 | Low |
| C2 | 네이티브 모듈 리빌드 명령어 (`npx electron-rebuild`) 추가 | Medium |
| C3 | 환경 요구사항 추가: Node.js 버전, Python 버전 (faster-whisper용), MSVC Build Tools | Medium |
---
## 5. 요약 매트릭스
| 카테고리 | High | Medium | Low | 합계 |
|---------|------|--------|-----|------|
| 설계서 간 불일치 | 4 | 5 | 0 | 9 |
| 빠진 설계 영역 | 4 | 4 | 4 | 12 |
| 실현 가능성 리스크 | 2 | 2 | 1 | 5 |
| 보강 액션 아이템 | 7 | 9 | 2 | 18 |

View file

@ -0,0 +1,577 @@
# 07. VoiceModeService 핵심 패턴 분석
> Speakly 실제 소스 기반 분석 (2026-04-04)
> 대상: `dist/src/services/VoiceModeService.js`, `VoiceRecognitionService.js`, `AudioService.js`, `main/index.js`
---
## 1. Accidental Press (실수 누름) 감지
### Speakly 원본 위치
- **상수 정의**: `VoiceModeService.js:116``MIN_AUDIO_DURATION_MS = 700`
- **검사 함수**: `VoiceModeService.js:3251-3280``isAccidentalKeyPress(session)`
- **마킹 함수**: `VoiceModeService.js:3282-3293``checkAndMarkAccidentalPress(session)`
- **호출 지점 (early)**: `VoiceModeService.js:604` — dictation released에서 delay 전 조기 검사
- **호출 지점 (stop)**: `VoiceModeService.js:1971``stopAndProcess()`에서 최종 검사
### 동작 원리
키 릴리스 시점에서 두 가지 조건을 OR로 판단:
```
isAccidental = (keyPressDuration > 0 && keyPressDuration < 700ms)
|| (sessionLifetime < 700ms)
```
- `keyPressDuration`: native 타임스탬프 기반 (pressed → released)
- `sessionLifetime`: 세션 생성 시점부터 현재까지의 시간
### 감지 시점과 처리 흐름
1. **DICTATION released** (`handleDictationHotkeyReleased`, 줄 604):
- `checkAndMarkAccidentalPress(session)` 호출
- accidental이면 세션에 마킹 (`session.markAsAccidental()`)
- 이후 `stopAndProcess()`에서 200ms delay 후 처리
2. **stopAndProcess** (줄 1971):
- `session.isAccidentalPress() || this.isAccidentalKeyPress(session)` 확인
- accidental이면:
- `session.markErrorEmitted()` — 에러 팁 표시 방지
- `createCancelledBySystemError()` 에러 생성 (코드: `VoiceCancelledBySystem`)
- `recognition-error` 이벤트 emit (UI는 이 에러 코드를 보고 에러 팁을 표시하지 않음)
- `session.userCancel()` → 서버에 cancel 전송
- 세션 정리 후 **조용히 종료** (사용자에게 에러 표시 없음)
### D3RO-VOICE 적용 의사코드
```typescript
// VoicePipelineService
private readonly MIN_AUDIO_DURATION_MS = 700;
private isAccidentalPress(session: VoiceSession): boolean {
const keyPressDuration = session.getKeyPressDuration();
const sessionLifetime = session.getSessionLifetime();
const hasKeyTiming = keyPressDuration > 0;
const keyTooShort = hasKeyTiming && keyPressDuration < this.MIN_AUDIO_DURATION_MS;
const sessionTooShort = sessionLifetime < this.MIN_AUDIO_DURATION_MS;
return keyTooShort || sessionTooShort;
}
// stopAndProcess() 내부
if (this.isAccidentalPress(session)) {
session.cancel(); // 서버에 cancel 전송
this.emit('session:cancelled-silent'); // UI에 에러 표시 없이 조용히 닫기
return;
}
```
---
## 2. Audio Mute 연동
### Speakly 원본 위치
- **상수**: `VoiceModeService.js:118-119``MUTE_DELAY_MS = 500`, `UNMUTE_SOUND_DELAY_MS = 100`
- **상태**: `VoiceModeService.js:120``wasMutedBeforeRecording = false`
- **Mute 로직**: `VoiceModeService.js:2726-2773``onBeforeRecording()`
- **Unmute 로직**: `VoiceModeService.js:2776-2820``onAfterRecording()`
- **Loopback 검사**: `VoiceModeService.js:2707``_isLoopbackMic()`
### 동작 원리
#### 녹음 시작 시 (onBeforeRecording)
1. `UserConfigService.getMuteAudioWhenDictating()` 설정 확인
2. `_isLoopbackMic()` — 가상 오디오 장치(BlackHole 등)면 mute 스킵 (loopback 입력이 끊김 방지)
3. 사운드 이펙트가 활성화된 경우:
- 녹음 시작음 먼저 재생
- **500ms 딜레이**`NativeService.muteSystemAudio()` (시작음이 끝나도록)
- fire-and-forget (비동기, 녹음 시작을 차단하지 않음)
4. 사운드 이펙트 비활성화 시: 즉시 mute
5. `wasMutedBeforeRecording` 저장 — 이미 음소거였으면 unmute 스킵
#### 녹음 종료 시 (onAfterRecording)
1. `muteAudioWhenDictating && !_isLoopbackMic() && !wasMutedBeforeRecording` 일 때만 unmute
2. `NativeService.unmuteSystemAudio()` 호출
3. unmute 완료 후 **100ms 딜레이** → 종료음 재생
4. unmute 실패 시에도 종료음은 재생 (`.catch()` 안에서)
#### 핵심 설계: fire-and-forget 패턴
- mute/unmute는 녹음 흐름을 **절대 블로킹하지 않음**
- `onBeforeRecording()``onAfterRecording()` 모두 즉시 리턴
- mute 실패는 warn 로그만 남기고 녹음은 계속 진행
### D3RO-VOICE 적용 의사코드
```typescript
// AudioMuteService (별도 서비스로 분리)
class AudioMuteService {
private wasMutedBefore = false;
private readonly MUTE_DELAY_MS = 500;
private readonly UNMUTE_SOUND_DELAY_MS = 100;
async muteForRecording(sessionId: string, playSoundFirst: boolean): Promise<void> {
if (!this.config.muteAudioWhenDictating) return;
if (this.isLoopbackDevice()) return;
const doMute = async () => {
try {
const { wasMuted } = await nativeAudio.muteSystem();
this.wasMutedBefore = wasMuted;
} catch (e) {
log.warn('Mute failed, continuing recording', e);
}
};
if (playSoundFirst) {
setTimeout(doMute, this.MUTE_DELAY_MS); // fire-and-forget
} else {
doMute(); // fire-and-forget (no await)
}
}
async unmuteAfterRecording(onComplete?: () => void): Promise<void> {
if (!this.config.muteAudioWhenDictating || this.wasMutedBefore) {
onComplete?.();
return;
}
nativeAudio.unmuteSystem()
.then(() => setTimeout(onComplete, this.UNMUTE_SOUND_DELAY_MS))
.catch(() => onComplete?.()); // 실패해도 종료음 재생
}
}
```
---
## 3. Retry 로직
### Speakly 원본 위치
- **IPC 핸들러**: `main/index.js:1298-1400``history:retry`
- **sessionRetryCount**: `main/index.js:126-128``Map<sessionId, retryCount>`
- **MAX_RETRY_ATTEMPTS**: `config/constants.js:18``3`
- **retryRecognition()**: `VoiceModeService.js:3319-3605`
- **OggOpusReader**: `VoiceModeService.js:3361` — 파일 포맷 감지 및 프레임 추출
### 전체 흐름
```
renderer → IPC 'history:retry' → main/index.js
→ canRetrySession(id) 확인
→ sessionRetryCount 증가
→ VoiceModeService.retryRecognition(id)
→ HistoryService.getById(id) // DB에서 기록 조회
→ fs.readFileSync(audioLocalPath) // 디스크에서 오디오 파일 읽기
→ 파일 포맷 감지 (OGG/WAV)
→ 새 VoiceRecognitionSession 생성
→ session.start() → 서버 연결
→ audioFrames 순차 전송 (sendAudio)
→ session.stop() → commit + wait
→ HistoryService.upsert() → DB 업데이트
→ ResultPopupWindow.show() → 결과 표시
```
### 오디오 파일 처리 (줄 3361-3393)
```javascript
// OGG 파일인 경우
if (isOggFile(audioBuffer)) {
retryCodec = 'opus';
const parsed = parseOggOpus(audioBuffer); // 프레임 + 메타데이터 추출
audioFrames = parsed.frames;
}
// WAV 파일인 경우
else if (isWavFile(audioBuffer)) {
retryCodec = 'pcm';
const pcmData = audioBuffer.subarray(44); // WAV 헤더(44바이트) 제거
// 8192 바이트씩 분할
for (let i = 0; i < pcmData.length; i += PCM_CHUNK_SIZE) {
audioFrames.push(Buffer.from(pcmData.subarray(i, ...)));
}
}
```
### Retry Count 관리 (main/index.js)
```javascript
const sessionRetryCount = new Map(); // 전역
// 매 retry 시
sessionRetryCount.set(id, (sessionRetryCount.get(id) || 0) + 1);
// retry 횟수 >= MAX_RETRY_ATTEMPTS(3) 이면
// → 'error.retry.maxRetriesReached' 메시지, retry 버튼 없음
// → sessionRetryCount.delete(id)
// retryable 에러 (네트워크/InternalError)이고 횟수 남으면
// → 'error-with-retry' UI 표시
// 성공 시
// → sessionRetryCount.delete(id) // 카운트 초기화
```
### ESC 키 취소 (VoiceModeService.js:813-818)
```javascript
// ESC 핸들러에서
if (this.retryingSession) {
await this.retryingSession.userCancel();
this.retryingSession = null;
this.retryingHistoryId = null;
}
```
### D3RO-VOICE 적용 의사코드
```typescript
// RetryService
class RetryService {
private retryCount = new Map<string, number>();
private readonly MAX_RETRY = 3;
async retrySession(historyId: string): Promise<RetryResult> {
// 1. Retry count 확인
const count = (this.retryCount.get(historyId) || 0) + 1;
if (count > this.MAX_RETRY) {
return { success: false, error: 'max_retries_reached' };
}
this.retryCount.set(historyId, count);
// 2. 히스토리에서 오디오 경로 조회
const record = await historyDB.getById(historyId);
if (!record?.audioLocalPath) throw new Error('no_audio');
// 3. 오디오 파일 읽기 + 포맷 감지
const buffer = fs.readFileSync(record.audioLocalPath);
const frames = this.parseAudioFile(buffer); // OGG or WAV
// 4. 새 세션 생성 → 연결 → 오디오 전송
const session = new RecognitionSession({ source: 'file', ... });
await session.start();
for (const frame of frames) {
if (session.isCancelled()) break;
await session.sendAudio(frame);
}
// 5. 결과 대기
const result = await session.stop();
// 6. 성공 시 retry count 초기화
this.retryCount.delete(historyId);
return { success: true, data: result };
}
}
```
---
## 4. Action Queue 직렬화
### Speakly 원본 위치
- **NXAction 클래스**: `VoiceModeService.js:78-87`
- **큐 선언**: `VoiceModeService.js:186-189`
- **enqueueAction()**: `VoiceModeService.js:213-219`
- **processActionQueue()**: `VoiceModeService.js:222-242`
- **handleAction()**: `VoiceModeService.js:245-274`
- **clearActionQueue()**: `VoiceModeService.js:277-285` (ESC 시 호출)
### 패턴: async 직렬화 큐 (Mutex 아님)
```javascript
class NXAction {
constructor(type, options) {
this.type = type; // 'dictation:pressed', 'hands-free:released', ...
this.timestamp = Date.now();
this.hotkeyId = options?.hotkeyId;
this.hotkeyTimestamp = options?.hotkeyTimestamp; // native 타임스탬프
}
}
// 큐 상태
actionQueue = []; // NXAction[]
isProcessingActionQueue = false; // boolean flag (lock 역할)
enqueueAction(action) {
this.actionQueue.push(action);
this.processActionQueue(); // 처리 시작 시도
}
async processActionQueue() {
if (this.isProcessingActionQueue) return; // 이미 처리 중이면 스킵
this.isProcessingActionQueue = true;
while (this.actionQueue.length > 0) {
const action = this.actionQueue.shift();
await this.handleAction(action); // await로 직렬 처리
}
this.isProcessingActionQueue = false;
}
```
### 핵심 메커니즘
- **Lock 패턴이 아닌 async loop**: `isProcessingActionQueue` 플래그로 재진입 방지
- 새 이벤트가 들어오면 큐에 push하고 `processActionQueue()` 호출 → 이미 처리 중이면 즉시 리턴
- 현재 action의 `await handleAction()` 완료 후 다음 action 처리
- **ESC 키**: `clearActionQueue()`로 대기 중인 모든 action 즉시 삭제 (줄 808-809)
### 녹음 중 다른 핫키 입력 시
- 새 핫키 이벤트가 큐에 들어감
- 현재 처리 중인 action이 완료될 때까지 대기
- 각 핸들러 내부에서 `this.isRecording` 상태를 검사하여 모드 전환/거부 결정:
- 같은 모드 핫키 → 녹음 중지 (토글)
- 다른 모드 핫키 → 모드 전환 (오디오 보존)
- processing 중 → `showProcessingInfoTip()` (줄 481-484)
### D3RO-VOICE 적용 의사코드
```typescript
// ActionQueue (VoicePipelineService 내장)
interface PipelineAction {
type: 'push-to-talk:start' | 'push-to-talk:stop' | 'toggle:start' | 'toggle:stop';
timestamp: number;
}
class ActionQueue {
private queue: PipelineAction[] = [];
private processing = false;
enqueue(action: PipelineAction): void {
this.queue.push(action);
this.process();
}
clear(): void {
this.queue = [];
}
private async process(): Promise<void> {
if (this.processing) return;
this.processing = true;
while (this.queue.length > 0) {
const action = this.queue.shift()!;
await this.handleAction(action);
}
this.processing = false;
}
private async handleAction(action: PipelineAction): Promise<void> {
// dispatch to VoicePipelineService handlers
}
}
```
---
## 5. Hands-Free No-Wake 모드
### Speakly 원본 위치
- **모드 정의**: `VoiceModeService.js:94``HANDS_FREE_NO_WAKE = "hands-free-no-wake"`
- **핫키 등록**: `VoiceModeService.js:415-426`
- **pressed 핸들러**: `VoiceModeService.js:653-687`
- **cancelNoWakeRecording()**: `VoiceModeService.js:2481-2550` (X 버튼)
- **confirmNoWakeRecording()**: `VoiceModeService.js:2551-2558` (V 버튼)
- **undoNoWakeCancel()**: `VoiceModeService.js:2562-2610` (Undo 버튼)
### 일반 Hands-Free와의 차이
| 기능 | Hands-Free | Hands-Free No-Wake |
|------|-----------|-------------------|
| 시작 | 핫키 토글 | 핫키 토글 |
| 종료 | 핫키 토글 | 핫키 토글 **또는** UI 버튼 |
| 웨이크워드 | 해당 없음 (둘 다 없음) | 해당 없음 |
| Cancel (X) | ESC만 가능 | **UI X 버튼** → 오디오 저장 후 취소 |
| Confirm (V) | 핫키로만 | **UI V 버튼** = 핫키 재누름과 동일 |
| Undo | 없음 | **Undo 버튼** → retry로 복원 |
| 오디오 보존 | 없음 | 취소 시 디스크 저장 (undo 용) |
### 동작 흐름
#### 시작 (핫키 누름)
```
handleHandsFreeNoWakeHotkeyPressed(timestamp)
→ 다른 모드 녹음 중이면: 오디오 보존 + 모드 전환
→ 같은 모드 녹음 중이면: 녹음 중지 (confirm)
→ 녹음 안 하고 있으면: 녹음 시작
```
#### Cancel — X 버튼 (줄 2481-2550)
```
cancelNoWakeRecording()
→ 마이크 중지
→ finalizeSession(status='error') → 오디오 OGG/WAV 파일 디스크 저장
→ session.userCancel() → 서버에 cancel
→ cancelledSessionId = sessionId ← Undo용 저장
→ emit('recording-tip:show-cancelled') → UI에 Undo 버튼 표시
```
#### Confirm — V 버튼 (줄 2551-2558)
```
confirmNoWakeRecording()
→ stopAndProcess(false) // 일반 핫키 재누름과 동일
```
#### Undo — Undo 버튼 (줄 2562-2610)
```
undoNoWakeCancel()
→ cancelledSessionId 가져오기
→ isProcessing = true → UI thinking 상태
→ retryRecognition(sessionId, { showResultPopup: false })
→ 디스크의 오디오 파일 읽기 → 서버 재전송 → 결과 수신
→ insertTextCallback(result.text) → 텍스트 삽입
→ isProcessing = false
```
### D3RO-VOICE 적용 판단
**No-Wake 모드는 D3RO-VOICE에 불필요** — 근거:
1. "No-Wake"라는 이름과 달리 웨이크워드와 무관. 실제로는 **UI 버튼이 있는 hands-free 변형**
2. Speakly의 3가지 버튼(X, V, Undo)은 RecordingTipWindow(플로팅 캡슐)에 의존
3. D3RO-VOICE는 시스템 트레이 기반이므로 플로팅 캡슐 UI가 없음
4. Cancel+Undo 패턴은 retry 인프라 위에 구축 — retry만 있으면 같은 효과
**대신 구현할 것**: Toggle 모드(= Hands-Free)만 지원 + 히스토리에서 retry 가능
```typescript
// D3RO-VOICE에서는 hands-free-no-wake를 별도 모드로 구현하지 않음.
// Toggle 모드가 이미 동일한 기본 기능을 제공.
// Undo 기능은 히스토리 패널의 retry 버튼으로 대체.
```
---
## 6. VoiceMode 4종 완전 분석
### 모드 정의 (줄 89-94)
```typescript
enum VoiceMode {
DICTATION = "dictation", // Hold-to-talk
HANDS_FREE = "hands-free", // Toggle (press once → speak → press again)
CUSTOM_INSTRUCTION = "custom-instruction", // Hold-to-talk + AI 지시
HANDS_FREE_NO_WAKE = "hands-free-no-wake", // Toggle + UI 버튼
}
```
### Mode 1: DICTATION (Hold-to-talk)
**시작**: 트리거 키 길게 누름 (>10ms, `PRESS_HOLD_THRESHOLD`)
```
handleDictationHotkeyPressed(timestamp)
→ lastPressTimestamp = timestamp
→ waitingForSecondPress = true
→ secondPressTimer 시작 (300ms)
→ pressHoldTimer 시작 (10ms)
→ onPressHoldTimeout()
→ recordingSource = 'fn'
→ setMode(DICTATION)
→ dictationEnteredByLongPress = true
→ startRecording(undefined, pressedTimestamp)
```
**종료**: 트리거 키 놓기
```
handleDictationHotkeyReleased(timestamp)
→ checkAndMarkAccidentalPress(session) // 700ms 미만이면 마킹
→ stopDelayTimer = setTimeout(200ms)
→ stopAndProcess(false)
```
**더블 프레스**: 300ms 이내 두 번 누름 → Ask Genspark (agent mode 활성화 시)
### Mode 2: HANDS_FREE (Toggle)
**시작**: 핫키 누름 (녹음 중 아닐 때)
```
handleHandsFreeHotkeyPressed(timestamp)
→ setMode(HANDS_FREE)
→ startRecording(undefined, timestamp)
```
**종료**: 핫키 다시 누름 (녹음 중일 때)
```
handleHandsFreeHotkeyPressed(timestamp)
→ [녹음 중이고 같은 모드] → stopAndProcess(false)
```
**모드 전환**: 다른 모드 녹음 중 핫키 → 오디오 보존 후 모드 전환
```
→ savedAudioChunks = [...this.audioChunks]
→ cancelRecording({ isSwitching: true })
→ setMode(HANDS_FREE)
→ startRecording(savedAudioChunks, undefined, 'mode-switch')
```
**Released**: 무시됨 (토글 모드, 줄 258-260)
### Mode 3: CUSTOM_INSTRUCTION (Hold-to-talk + AI 지시)
**시작**: Custom Instruction 핫키 누름
```
handleCustomInstructionHotkeyPressed(commandId)
→ instructionId 변환
→ customInstructionPressed = true
→ activeCustomInstructionId = instructionId
→ executeCustomInstructionPressed(commandId) // fire-and-forget
→ Context capture (100ms timeout race)
→ show-mode-tip 이벤트 (지시 이름 표시)
→ setMode(CUSTOM_INSTRUCTION)
→ startRecordingWithCustomInstruction(instruction, id)
```
**종료**: 핫키 놓기
```
handleCustomInstructionHotkeyReleased(commandId, timestamp)
→ customInstructionPressed = false
→ [녹음 중이면] stopAndProcess(false)
```
**특징**:
- Hold-to-talk 방식 (DICTATION과 동일하게 눌러서 말하고 놓으면 종료)
- 세션에 `pendingCustomInstruction` 첨부 → 서버가 AI 지시에 따라 텍스트 처리
- 녹음 중 모드 전환 대상이 아님 (독립적 핫키)
### Mode 4: HANDS_FREE_NO_WAKE (Toggle + UI 버튼)
상세 분석은 위 섹션 5 참조.
**시작/종료**: HANDS_FREE와 동일한 토글 패턴
**추가 기능**: X/V/Undo UI 버튼, 취소 시 오디오 디스크 보존
### 모드 전환 매트릭스
| 현재 모드 → 입력 | DICTATION | HANDS_FREE | NO_WAKE | CUSTOM |
|---|---|---|---|---|
| **DICTATION pressed** | - | 오디오보존→전환 | 오디오보존→전환 | 독립 |
| **HANDS_FREE pressed** | 오디오보존→전환 | 토글 중지 | 오디오보존→전환 | 독립 |
| **NO_WAKE pressed** | 오디오보존→전환 | 오디오보존→전환 | 토글 중지 | 독립 |
| **ESC** | 취소 | 취소→DICTATION | 취소→DICTATION | 취소→DICTATION |
녹음 종료 후: HANDS_FREE, CUSTOM_INSTRUCTION, HANDS_FREE_NO_WAKE 모두 → DICTATION으로 리셋 (줄 2147-2151)
---
## 7. D3RO-VOICE에 필요한 모드 (결론)
D3RO-VOICE는 다음 2개 모드만 지원:
| D3RO-VOICE 모드 | Speakly 대응 | 설명 |
|---|---|---|
| **Push-to-Talk** | DICTATION | 키 누르고 있는 동안 녹음 |
| **Toggle** | HANDS_FREE | 키 한번 → 녹음 시작, 다시 한번 → 종료 |
**제거 대상**:
- `HANDS_FREE_NO_WAKE` — 플로팅 캡슐 UI 전제. 히스토리 retry로 대체
- `CUSTOM_INSTRUCTION` — Phase 2 이후 검토
- `Ask Genspark` (더블 프레스) — Genspark 특화 기능, 불필요
**반드시 포함할 패턴**:
1. Action Queue 직렬화 — race condition 방지 (핫키 이벤트 직렬 처리)
2. Accidental Press 감지 — 700ms 미만 자동 취소
3. Audio Mute 연동 — 설정 기반 시스템 음소거
4. Retry 로직 — 히스토리에서 오디오 파일 재전송
5. 모드 전환 시 오디오 보존 — Push-to-Talk ↔ Toggle 전환 시 기존 오디오 유지

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,334 @@
# 09. 커서 위치 히스토리 팝업 설계
> 핫키로 커서 근처에 최근 전사 히스토리를 띄우고, Arrow 키로 선택하여 즉시 붙여넣는 기능.
> Speakly에 없는 D3RO-VOICE 고유 기능.
---
## 1. 개요
```
사용자: Ctrl+Shift+V 누름
1. mouse.getPosition()으로 현재 커서 좌표 획득
2. HistoryService에서 최근 10건 조회
3. 커서 위에 HistoryPopupWindow 표시 (focusable: false)
4. uiohook-napi로 Arrow↑↓/Enter/Escape 글로벌 인터셉트
5. Enter → 선택된 항목의 텍스트를 TextInsertService로 삽입
6. 팝업 닫기
```
---
## 2. 비주얼 디자인 (08-design-system 준수)
```
┌──────────────────────────────┐
│ RECENT TRANSCRIPTS × │ ← label-uppercase, 앰버(#f25b29)
├──────────────────────────────┤
│ ▸ 주식회사 트렌티원스라는... │ ← 선택됨: bg #2a2a2d, left-bar 앰버
│ 이거 다 영어로 번역해 │ ← text-secondary #8e8e93
│ 바보라고 다시 고쳐줘 │
│ 시뮬레이션 다시 돌려보니.. │
│ UI 팀 다시 소집해 │
├──────────────────────────────┤
│ ↑↓ SELECT ⏎ PASTE ESC × │ ← 하단 힌트, label 스타일
└──────────────────────────────┘
```
### CSS 변수 (08-design-system 기반)
```css
.history-popup {
background: var(--bg-card); /* #242427 */
border-radius: var(--radius-card); /* 22px */
border-top: 1px solid rgba(255, 255, 255, 0.04);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4),
0 0 1px rgba(255, 255, 255, 0.1);
backdrop-filter: blur(20px);
width: 340px;
max-height: 320px;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.history-popup-header {
padding: 12px 16px 8px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--accent-amber); /* #f25b29 */
display: flex;
justify-content: space-between;
align-items: center;
}
.history-item {
padding: 10px 16px;
cursor: default;
transition: background-color 0.1s;
border-left: 3px solid transparent;
display: flex;
flex-direction: column;
gap: 2px;
}
.history-item.selected {
background: var(--bg-card-hover); /* #2a2a2d */
border-left-color: var(--accent-amber);
}
.history-item-text {
font-size: 13px;
color: var(--text-primary); /* #ffffff */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 300px;
}
.history-item-meta {
font-size: 10px;
color: var(--text-label); /* #7c7c82 */
font-family: ui-monospace, SFMono-Regular, monospace;
}
.history-popup-footer {
padding: 8px 16px;
border-top: 1px solid rgba(255, 255, 255, 0.04);
font-size: 10px;
color: var(--text-label);
font-family: ui-monospace, monospace;
display: flex;
gap: 12px;
}
.history-popup-footer kbd {
background: rgba(255, 255, 255, 0.08);
padding: 1px 5px;
border-radius: 4px;
font-size: 10px;
}
```
---
## 3. 윈도우 설정
```typescript
const historyPopup = new BrowserWindow({
width: 340,
height: 320, // max, 실제는 콘텐츠에 맞춤
frame: false,
transparent: true,
alwaysOnTop: true,
skipTaskbar: true,
focusable: false, // 핵심: 활성 앱 포커스 유지
resizable: false,
show: false,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
preload: path.join(__dirname, '../preload/popup.js'),
},
});
```
**focusable: false가 핵심** — 팝업이 포커스를 뺏지 않으므로:
- 원래 앱의 커서 위치가 유지됨
- 텍스트 삽입 시 원래 앱으로 전환할 필요 없음
- 키 입력은 uiohook-napi 글로벌 후킹으로 캡처
---
## 4. 키보드 인터랙션 (글로벌 후킹)
```typescript
// HotkeyService에 historyPopup 전용 모드 추가
interface HistoryPopupKeyHandler {
// 팝업 열림 중에만 활성화
onArrowUp(): void; // 이전 항목 선택
onArrowDown(): void; // 다음 항목 선택
onEnter(): void; // 선택된 항목 삽입 + 팝업 닫기
onEscape(): void; // 팝업 닫기 (삽입 안 함)
onNumberKey(n: number): void; // 1-9 직접 선택 + 삽입
}
// uiohook-napi에서 키 인터셉트
// 팝업 열림 중: Arrow, Enter, Escape, 1-9 키를 소비 (앱에 전달 안 함)
// 팝업 닫힘: 키 인터셉트 해제
```
### 숫자 키 단축 선택
```
1 주식회사 트렌티원스라는... ← 숫자 1 누르면 바로 삽입
2 이거 다 영어로 번역해
3 바보라고 다시 고쳐줘
...
```
---
## 5. 위치 계산
```typescript
async function calculatePopupPosition(): Promise<{ x: number; y: number }> {
const { x: mouseX, y: mouseY } = await mouse.getPosition();
const display = screen.getDisplayNearestPoint({ x: mouseX, y: mouseY });
const { width: dw, height: dh } = display.workArea;
const popupW = 340;
const popupH = 320;
const margin = 8;
// 기본: 커서 위에 표시
let x = mouseX - popupW / 2;
let y = mouseY - popupH - margin;
// 화면 밖 보정
if (x < display.workArea.x) x = display.workArea.x + margin;
if (x + popupW > display.workArea.x + dw) x = display.workArea.x + dw - popupW - margin;
if (y < display.workArea.y) {
// 위에 공간 없으면 아래에 표시
y = mouseY + margin;
}
return { x: Math.round(x), y: Math.round(y) };
}
```
---
## 6. 데이터 흐름
```
1. 핫키 Ctrl+Shift+V
→ HotkeyService.emit('history-popup-trigger')
2. VoiceModeService / Main index.js에서 수신
→ HistoryService.getRecent(10) // 최근 10건
→ mouse.getPosition()
→ calculatePopupPosition()
→ historyPopup.setBounds({ x, y, width, height })
→ historyPopup.webContents.send('history:showItems', items)
→ historyPopup.show()
→ HotkeyService.enterHistoryPopupMode() // 키 인터셉트 시작
3. Arrow↑↓
→ HotkeyService가 인터셉트
→ historyPopup.webContents.send('history:selectItem', direction)
4. Enter (또는 숫자 1-9)
→ HotkeyService가 인터셉트
→ historyPopup.hide()
→ HotkeyService.exitHistoryPopupMode() // 키 인터셉트 종료
→ TextInsertService.insertText(selectedText)
5. Escape
→ historyPopup.hide()
→ HotkeyService.exitHistoryPopupMode()
```
---
## 7. IPC 채널 추가
| 채널명 | 방향 | 타입 | 설명 |
|--------|------|------|------|
| `history:showPopup` | handle | `void → void` | 히스토리 팝업 표시 |
| `history:hidePopup` | handle | `void → void` | 히스토리 팝업 숨김 |
| `history:showItems` | send | `HistoryPopupItem[]` | 팝업에 항목 전달 |
| `history:selectItem` | send | `{ direction: 'up' \| 'down' } \| { index: number }` | 항목 선택 |
| `history:itemSelected` | on | `{ id: string; text: string }` | 선택 확정 (Enter) |
| `history:popupDismissed` | on | `void` | 팝업 닫힘 (Escape) |
---
## 8. 타입 정의
```typescript
interface HistoryPopupItem {
id: string;
text: string; // 전사 텍스트 (truncate 50자)
fullText: string; // 전체 텍스트 (삽입용)
mode: 'dictation' | 'translate' | 'command';
timestamp: number; // 상대 시간 표시용 ("2분 전", "어제")
duration: number; // 녹음 시간
}
interface HistoryPopupConfig {
maxItems: number; // 기본 10
hotkey: HotkeyBinding; // 기본 Ctrl+Shift+V (Settings > 핫키 탭에서 변경 가능)
showDuration: boolean; // 녹음 시간 표시 여부
autoClose: boolean; // 포커스 잃으면 자동 닫기
autoCloseMs: number; // 자동 닫기 타임아웃 (기본 10초)
}
// ConfigService의 hotkey 섹션에 통합 관리됨:
// config.hotkey.dictation — 받아쓰기 (기본: Right Alt)
// config.hotkey.handsFree — 핸즈프리 토글
// config.hotkey.command — 명령 모드
// config.hotkey.historyPopup — 히스토리 팝업 (기본: Ctrl+Shift+V)
// → Settings > 핫키 탭에서 모두 한곳에서 설정
```
---
## 9. 애니메이션 (08-design-system 준수)
```css
/* 팝업 등장 */
.history-popup {
animation: popup-enter 0.15s ease-out;
}
@keyframes popup-enter {
from {
opacity: 0;
transform: translateY(8px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* 항목 선택 전환 */
.history-item {
transition: background-color 0.08s ease, border-left-color 0.08s ease;
}
/* 팝업 퇴장 */
.history-popup.hiding {
animation: popup-exit 0.1s ease-in forwards;
}
@keyframes popup-exit {
to {
opacity: 0;
transform: translateY(4px) scale(0.98);
}
}
```
---
## 10. 구현 페이즈
**Phase 3.5** (텍스트 삽입 완료 후, LLM 연동 전):
- Phase 3에서 TextInsertService + HistoryService + HotkeyService 완성
- Phase 3.5에서 이 팝업만 추가 (1-2일 규모)
- Phase 4 (LLM)와 독립적이므로 순서 유연
---
## 11. 향후 확장
- **검색**: 팝업 상단에 인라인 검색 필드 (타이핑 시 필터)
- **카테고리 탭**: dictation / translate / command 필터
- **핀 고정**: 자주 쓰는 항목 상단 고정
- **미리보기**: 선택된 항목의 전체 텍스트를 팝업 확장으로 표시
- **즐겨찾기**: 별표 표시 후 즐겨찾기만 보기