초기 프로젝트 설정: 하네스 시스템 + 설계서 + 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

View file

@ -0,0 +1,26 @@
# D3RO-VOICE 디자인 레퍼런스
## 시안 A: Meteorological Instrument (레트로 인스트루먼트)
파일: `instrument-panel.html`
- CRT 디스플레이 (WebGL 셰이더, 스캔라인, 비네팅, 글리치)
- 앰버 인광색 (#f25b29), 다크 섀시 (#242528)
- 물리 다이얼 (드래그 회전), LED 상태등
- 메탈 텍스처 (반복 방사형 그라디언트)
- 각인 텍스트 (METEOROLOGICAL SYS.)
## 시안 B: VoiceOps Dashboard (모던 다크 대시보드)
파일: `voiceops-dashboard.html`
- 다크 카드 레이아웃 (#242427, border-radius: 22px)
- 태그 시스템 (STT, TTS, LIVE, COMPLETE, PROCESSING)
- 실시간 웨이브폼 바 (CSS 애니메이션)
- 라이브 전사 텍스트 (faded 부분 결과)
- 서비스 상태 패널
- 블러 처리 + 스피너 (처리 중)
## D3RO-VOICE 디자인 방향
두 시안을 융합:
- **전체 레이아웃**: 시안 B의 카드 그리드 + 다크 테마
- **녹음 상태 UI**: 시안 A의 CRT 느낌 + 시안 B의 웨이브폼 바
- **색상**: 시안 A의 앰버(#f25b29) + 시안 B의 다크(#19191b, #242427)
- **인터랙션**: 시안 A의 다이얼(설정), 시안 B의 카드 hover/태그
- **타이포그래피**: 모노스페이스(상태 표시) + 시스템 폰트(본문)

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 필터
- **핀 고정**: 자주 쓰는 항목 상단 고정
- **미리보기**: 선택된 항목의 전체 텍스트를 팝업 확장으로 표시
- **즐겨찾기**: 별표 표시 후 즐겨찾기만 보기

59
docs/phases/phase-1.md Normal file
View file

@ -0,0 +1,59 @@
# Phase 1: 프로젝트 초기화 + Electron 뼈대 + 마이크 캡처
## 목표
프로젝트 기본 구조를 세우고, Electron 앱이 실행되며, 마이크에서 오디오를 캡처할 수 있는 상태까지.
## 태스크
### 1.1 프로젝트 초기화
- `npm init` + package.json 설정
- TypeScript 설정 (tsconfig.json, strict mode)
- Vite + Electron 개발 환경 설정 (electron-vite 또는 vite-plugin-electron)
- ESLint + Prettier 설정
- vitest 테스트 환경 설정
- .gitignore, README.md
### 1.2 Electron 기본 구조
- `src/main/index.ts` — 앱 진입점
- Speakly 패턴: app.whenReady → 서비스 초기화 → createWindow → IPC 등록
- 단일 인스턴스 잠금
- before-quit / will-quit 리소스 정리
- `src/preload/index.ts` — contextBridge 기본 구조
- `src/renderer/main.tsx` — React 앱 진입점
- `src/shared/ipc-channels.ts` — IPC 채널 중앙 정의
- `src/shared/types.ts` — 공통 타입
- `src/shared/errors.ts` — 에러 코드 체계 (Speakly NXError 패턴)
### 1.3 메인 윈도우
- BrowserWindow 생성 (1104x816, hiddenInset titleBar)
- contextIsolation: true, nodeIntegration: false
- 기본 React App 컴포넌트 (빈 대시보드)
### 1.4 시스템 트레이
- Tray 아이콘 + 메뉴 (표시/숨기기, 종료)
- Windows: closeToTray 옵션
### 1.5 마이크 캡처 서비스
- `src/main/services/AudioCaptureService.ts`
- Web Audio API 또는 node-record-lpcm16
- 24kHz mono PCM16 (또는 16kHz for Whisper)
- noiseSuppression: false, echoCancellation: false
- EventEmitter 패턴: 'audio-data', 'audio-level', 'started', 'stopped'
- IPC: `audio:startCapture`, `audio:stopCapture`, `audio:getDevices`
### 1.6 오디오 디바이스 관리
- 마이크 디바이스 목록 조회
- 디바이스 변경 감지
- 선택된 디바이스 저장 (electron-store)
## Speakly RE 참조
- NativeHelper.dll MicrophoneCapture: WASAPI 이벤트 구동, 24kHz mono
- AudioService: EventEmitter 패턴, 구독자 레퍼런스 카운팅
- main/index.js: 22단계 초기화 순서
## 완료 조건
- [ ] `npm run dev` 로 Electron 앱 실행됨
- [ ] 마이크에서 오디오 데이터 캡처 가능
- [ ] 시스템 트레이에 아이콘 표시
- [ ] `npm run typecheck` 통과
- [ ] `npm run test` 통과

46
docs/phases/phase-2.md Normal file
View file

@ -0,0 +1,46 @@
# Phase 2: 로컬 STT 연동 (Whisper) + 핫키
## 목표
마이크 캡처된 오디오를 로컬 Whisper 모델로 전사하고, 글로벌 핫키로 녹음을 제어할 수 있는 상태.
## 태스크
### 2.1 LocalSTTService
- `src/main/services/LocalSTTService.ts`
- faster-whisper Python sidecar 관리 (spawn/kill)
- 또는 whisper.cpp Node addon 직접 호출
- PCM 16kHz mono → Whisper → 텍스트
- Speakly 패턴: 이중 조건 플러시 (모델 로딩 + 오디오 버퍼링 병렬)
- EventEmitter: 'transcription-delta', 'transcription-complete'
### 2.2 VoiceModeService (오케스트레이터)
- `src/main/services/VoiceModeService.ts`
- Speakly 패턴 적용:
- RecognitionState 상태 머신 (IDLE→PREPARING→READY→RECOGNIZING→COMPLETED)
- AudioState 별도 추적
- _isInTerminalState() 체크
- errorEmitted 플래그
- 오디오 무손실 버퍼링 (STT 준비 전 버퍼)
- 모드: dictation (hold-to-talk), hands-free (toggle)
### 2.3 글로벌 핫키
- uiohook-napi로 글로벌 키보드 후킹
- 기본 트리거: Right Alt (Windows)
- Speakly 패턴: pressed/released 이벤트, 더블프레스 감지 (300ms), 최소 700ms
- HotkeyService + HotkeyConfig
### 2.4 IPC 채널 추가
- `voice:startRecording`, `voice:stopRecording`
- `stt:getStatus`, `stt:getModels`
- `hotkey:getDictationShortcut`, `hotkey:setDictationShortcut`
## Speakly RE 참조
- VoiceRecognitionService: 상태 머신, 이중 조건 플러시, 재연결 3계층
- VoiceModeService: 오케스트레이션, 모드별 핫키 처리
- HotkeyConfig: 키코드 맵, 시스템 예약 단축키 블랙리스트
## 완료 조건
- [ ] 핫키로 녹음 시작/종료 가능
- [ ] Whisper로 한국어 음성 전사 작동
- [ ] 상태 머신 정상 전이
- [ ] 오디오 버퍼링/플러시 정상 작동

54
docs/phases/phase-3.5.md Normal file
View file

@ -0,0 +1,54 @@
# Phase 3.5: 커서 위치 히스토리 팝업
## 목표
핫키(Ctrl+Shift+V)로 커서 근처에 최근 전사 히스토리 팝업을 띄우고, Arrow 키로 선택하여 즉시 붙여넣기.
Speakly에 없는 D3RO-VOICE 고유 편의 기능.
## 전제 조건
Phase 3 완료 (TextInsertService, HistoryService, HotkeyService, WindowManager 모두 동작)
## 태스크
### 3.5.1 HistoryPopupWindow (Vanilla JS)
- 별도 HTML 엔트리포인트 (`src/renderer/popups/history-popup/`)
- 08-design-system.md 준수: 다크 카드(#242427), 앰버 악센트(#f25b29), 22px radius
- 최근 10건 표시, 텍스트 50자 truncate, 상대 시간 표시
- 선택 항목: 좌측 앰버 바 + hover 배경
- 하단 키보드 힌트 (↑↓ SELECT / ⏎ PASTE / ESC ×)
- 등장/퇴장 애니메이션 (0.15s ease-out / 0.1s ease-in)
### 3.5.2 위치 계산
- mouse.getPosition()으로 커서 좌표
- 기본: 커서 위에 표시, 화면 밖이면 아래로
- 멀티모니터: screen.getDisplayNearestPoint()
### 3.5.3 글로벌 키 인터셉트
- HotkeyService에 historyPopupMode 추가
- 팝업 열림 중: Arrow↑↓, Enter, Escape, 1-9 키 소비
- focusable: false로 활성 앱 포커스 유지
### 3.5.4 IPC 채널 추가
- `history:showPopup`, `history:hidePopup`
- `history:showItems` (send), `history:selectItem` (send)
- `history:itemSelected` (on), `history:popupDismissed` (on)
### 3.5.5 설정 연동
- ConfigService에 historyPopup 섹션 추가 (hotkey, maxItems, autoCloseMs)
- Settings UI에 핫키 설정 추가
## Speakly RE 참조
- ResultPopupWindow: Vanilla JS 팝업 패턴, 프리로딩, 2-phase 리사이즈
- AskGensparkWindow: 키보드 인터랙션 (Enter/Escape)
- HotkeyService: 키 인터셉트 모드 (keyRecordingMode 패턴 응용)
## 설계 문서 참조
- `docs/design/09-history-popup.md` — 전체 설계
- `docs/design/08-design-system.md` — 디자인 시스템
## 완료 조건
- [ ] Ctrl+Shift+V로 커서 위에 팝업 표시
- [ ] Arrow↑↓로 항목 선택, Enter로 붙여넣기
- [ ] 숫자 1-9로 직접 선택+삽입
- [ ] Escape로 닫기
- [ ] 활성 앱 포커스 유지 (focusable: false)
- [ ] 디자인 시스템 준수 (앰버 악센트, 다크 카드)

49
docs/phases/phase-3.md Normal file
View file

@ -0,0 +1,49 @@
# Phase 3: 텍스트 삽입 + 기본 UI
## 목표
전사된 텍스트를 활성 앱에 삽입하고, 녹음 상태/결과를 표시하는 UI 구축.
## 태스크
### 3.1 TextInsertService
- `src/main/services/TextInsertService.ts`
- Speakly ClipboardPaste 패턴:
1. 클립보드 전체 백업
2. 텍스트를 클립보드에 설정
3. @nut-tree/nut-js로 Ctrl+V 시뮬레이션
4. 클립보드 복원
- 삽입 검증 (옵션)
### 3.2 RecordingTipWindow (Vanilla JS 팝업)
- 녹음 상태 표시 (opening → recording → thinking → result/error)
- 9개 웨이브 바 애니메이션 (Speakly 패턴)
- 2-phase 리사이즈
- 프리로딩 방식
### 3.3 ResultPopupWindow (Vanilla JS 팝업)
- 전사 결과 텍스트 표시
- 복사 버튼, 닫기 버튼
- 자동 숨김 + 마우스 호버 시 유지
### 3.4 Dashboard (React)
- 통계: 총 시간, 단어 수, 세션 수
- 최근 세션 목록
- Speakly 패턴: MUI Drawer(240px) + Content Area
### 3.5 Settings (React Modal)
- 언어, 테마(light/dark/auto), 핫키 설정
- 마이크 선택 + 테스트
- Ollama 서버 URL 설정
## Speakly RE 참조
- ClipboardPaste 의사코드: save→set→SendInput(Ctrl+V)→restore
- RecordingTip: wave-bar cos 분포, thinking 점근 수렴
- ResultPopup: requestAnimationFrame 높이 측정, mouseenter/leave
- App.js: MUI 테마, Drawer 네비게이션, 라우팅 패턴
- Settings: HotkeyRecordModal, 마이크 선택 UI
## 완료 조건
- [ ] 전사된 텍스트가 활성 앱(메모장 등)에 삽입됨
- [ ] RecordingTip 웨이브 애니메이션 작동
- [ ] Dashboard에 통계 표시
- [ ] Settings에서 핫키/마이크 변경 가능

115
docs/phases/phase-4.md Normal file
View file

@ -0,0 +1,115 @@
# Phase 4: Ollama LLM 연동 (텍스트 다듬기, 번역)
## 목표
Ollama REST API를 통해 로컬 LLM과 연동하여, 전사된 텍스트를 다듬기(polishing)하거나 번역(translate)할 수 있는 상태. 스트리밍 응답을 UI에 실시간 표시하고, 커스텀 명령어의 기본 구조를 갖춘다.
## 태스크
### 4.1 LocalLLMService 구현
- `src/main/services/LocalLLMService.ts`
- Ollama REST API 연동 (`/api/generate`, `/api/chat`, `/api/tags`)
- 싱글톤 + EventEmitter 패턴
- 상태 머신: `Unavailable → Available → Generating → Available` (서비스 명세 LLMState 참조)
- Ollama 가용성 폴링 (5초 간격, `OLLAMA_CONFIG.pollIntervalMs`)
- `availability-changed` 이벤트로 UI에 상태 전파
- ConfigService에서 `llm.serverUrl`, `llm.defaultModel` 읽기
### 4.2 비스트리밍 생성 (`generate`)
- `POST /api/generate` 호출 (stream: false)
- GenerateOptions 지원: model, temperature, maxTokens, topP, topK, systemPrompt
- GenerateResult 반환: text, model, promptTokens, completionTokens, totalDuration
- 타임아웃 처리 (AbortController, 기본 120초)
### 4.3 스트리밍 생성 (`stream`)
- `POST /api/generate` (stream: true) → NDJSON 파싱
- fetch + ReadableStream으로 청크 처리
- `token` 이벤트를 통해 점진적 토큰 전달
- AbortController로 중간 취소 지원
- 버퍼 관리: 불완전한 JSON 라인 처리
### 4.4 텍스트 다듬기 모드 (Polishing)
- PostProcessCommand `{ type: 'polish', style?: 'formal' | 'casual' }` 처리
- 시스템 프롬프트 설계:
```
[formal] 다음 음성 전사 텍스트를 자연스럽고 격식 있는 문어체로 다듬어주세요.
원래 의미를 유지하면서 문법 오류를 수정하고, 불필요한 반복이나 필러를 제거하세요.
다듬어진 텍스트만 출력하세요. 설명이나 부가 문구를 붙이지 마세요.
[casual] 다음 음성 전사 텍스트를 자연스러운 구어체로 다듬어주세요.
원래 의미와 톤을 유지하면서 문법 오류만 수정하세요.
다듬어진 텍스트만 출력하세요.
```
- VoiceModeService의 Processing 상태에서 호출
### 4.5 번역 모드 (Translate)
- PostProcessCommand `{ type: 'translate', targetLanguage: string }` 처리
- 시스템 프롬프트 설계:
```
다음 텍스트를 {targetLanguage}로 번역해주세요.
자연스럽고 정확한 번역만 출력하세요. 원문이나 설명을 붙이지 마세요.
```
- 감지 언어 → 대상 언어 자동 전환 (한국어 감지 시 영어로, 영어 감지 시 한국어로)
- ConfigService에서 기본 대상 언어 설정 지원
### 4.6 커스텀 명령어 기본 구조
- PostProcessCommand `{ type: 'custom', prompt: string }` 처리
- 사용자가 직접 시스템 프롬프트를 지정하여 LLM에 전달
- CustomInstruction 인터페이스 초기 정의:
```typescript
interface CustomInstruction {
id: string;
name: string;
prompt: string;
isBuiltin: boolean;
createdAt: number;
}
```
- Phase 6에서 완전 구현 (CRUD, 프리셋, 핫키 바인딩)
### 4.7 스트리밍 응답 UI 표시
- RecordingTipWindow에 `processing` 상태 추가
- Thinking 프로그레스 바 (점근 수렴 패턴: `min(95, (1 - 1/(1+1.5*t)) * 100)%`)
- 스트리밍 토큰이 도착하면 텍스트 실시간 표시
- ResultPopupWindow에 스트리밍 텍스트 영역 추가
- 토큰 도착 시 점진적 텍스트 추가
- 완료 시 복사 버튼 활성화
- IPC 채널:
- `llm:streamToken` (main→renderer, 스트리밍 토큰)
- `llm:streamComplete` (main→renderer, 생성 완료)
- `llm:streamError` (main→renderer, 에러)
### 4.8 VoiceModeService LLM 통합
- RecognitionState.Processing 상태에서 LLM 호출
- 전사 완료 → postProcessCommand에 따라 분기:
- `none`: 바로 텍스트 삽입
- `polish` / `translate` / `custom`: LLM 스트리밍 호출 → 완료 후 텍스트 삽입
- `processing-update` 이벤트를 통해 UI에 진행 상황 전달
- LLM 미가용 시 postProcess를 `none`으로 폴백 + 사용자 알림
### 4.9 에러 처리
- Ollama 서버 미실행: `LLM_CONNECTION_FAILED` 에러, UI에 "Ollama가 실행 중이 아닙니다" 표시
- 모델 미설치: `LLM_MODEL_NOT_FOUND` 에러, UI에 모델 설치 안내 표시
- 생성 실패/타임아웃: `LLM_GENERATION_FAILED`, 원본 텍스트로 폴백 옵션 제공
- 네트워크 에러: 재시도 없이 즉시 에러 표시 (로컬이므로 재시도 불필요)
### 4.10 IPC 채널 추가
- `llm:getStatus` — LLM 가용 상태 조회
- `llm:getModels` — 설치된 Ollama 모델 목록
- `llm:generate` — 비스트리밍 텍스트 생성
- `llm:stream` — 스트리밍 텍스트 생성 시작
- `llm:cancelStream` — 스트리밍 취소
- `llm:checkConnection` — Ollama 연결 테스트
## Speakly RE 참조
- GensparkService: 클라우드 LLM 호출 패턴 (D3RO는 로컬 Ollama로 대체)
- CustomInstructionService: 명령어 구조, 프롬프트 템플릿
- VoiceModeService: Processing 상태 전이, 후처리 파이프라인
- RecordingTip: thinking 상태 프로그레스 바, 점근 수렴 패턴
## 완료 조건
- [ ] Ollama 가용성 자동 감지 및 UI 표시
- [ ] 전사된 텍스트를 다듬기(polish) 모드로 LLM 처리 가능
- [ ] 전사된 텍스트를 번역(translate) 모드로 LLM 처리 가능
- [ ] 커스텀 프롬프트로 자유 LLM 처리 가능
- [ ] 스트리밍 토큰이 UI에 실시간 표시됨
- [ ] Ollama 미실행/모델 미설치 시 적절한 에러 메시지 표시
- [ ] LLM 미가용 시 원본 텍스트 삽입으로 폴백

148
docs/phases/phase-5.md Normal file
View file

@ -0,0 +1,148 @@
# Phase 5: TTS + 히스토리/사전 DB
## 목표
TTS 엔진을 연동하여 텍스트를 음성으로 재생하고, 히스토리와 사전 DB를 완전 구현하여 사용자 데이터를 체계적으로 관리한다. Dashboard 통계 UI를 완성한다.
## 태스크
### 5.1 LocalTTSService 구현
- `src/main/services/LocalTTSService.ts`
- Piper-TTS 또는 Kokoro sidecar 프로세스 관리 (spawn/kill)
- 상태 머신: `Idle → Loading → Speaking → Idle` (서비스 명세 TTSState 참조)
- 싱글톤 + EventEmitter 패턴
- Sidecar 통신 프로토콜:
```
Main Process piper-tts sidecar
│── stdin: text ────────►│
│◄── stdout: WAV data ───│ (또는 PCM 스트리밍)
```
- 음성 모델 디렉토리 관리 (`userData/tts-models/`)
- TTSVoice 목록 조회: id, name, language, gender, sampleRate, downloaded
### 5.2 TTS 오디오 재생
- Node.js 측에서 PCM/WAV 데이터를 renderer로 전달
- renderer에서 Web Audio API로 재생
- IPC 채널:
- `tts:speak` — 텍스트 합성 및 재생 시작
- `tts:stop` — 재생 중지
- `tts:getVoices` — 사용 가능한 음성 목록
- `tts:audioChunk` (main→renderer) — 오디오 청크 스트리밍
- `tts:finished` (main→renderer) — 재생 완료
- TTSOptions: speed (0.5~2.0), format (pcm/wav)
### 5.3 TTS 재생 UI
- ResultPopupWindow에 재생/중지 버튼 추가
- 재생 아이콘 (▶) → 클릭 시 TTS 재생, 아이콘 중지(■)로 변경
- 재생 중 상태 표시 (파형 또는 진행 바)
- History UI 각 항목에 재생 버튼 추가
- ConfigService의 `tts.enabled` 설정에 따라 버튼 표시/숨김
### 5.4 HistoryService 완전 구현
- `src/main/services/HistoryService.ts`
- better-sqlite3 + drizzle-orm (DB 스키마: `03-db-and-ui.md` 참조)
- CRUD 메서드:
- `create(input: CreateHistoryInput): Promise<History>` — nanoid로 ID 생성
- `getById(id: string): Promise<History | null>`
- `getList(filter: HistoryFilter): Promise<{ entries: History[]; total: number }>` — 페이지네이션
- `update(id: string, data: Partial<History>): Promise<void>`
- `softDelete(id: string): Promise<void>` — deleted 플래그 설정
- `hardDelete(id: string): Promise<void>` — 물리 삭제
- `bulkDelete(ids: string[]): Promise<void>`
- 검색: originalText, polishedText에 대한 LIKE 검색, 날짜 범위, 모드, 상태 필터
- 통계 집계:
- `getStats(): Promise<HistoryStats>` — 총 항목, 총 녹음 시간, 언어별 통계 등
- stats 테이블 싱글턴 업데이트 (세션 완료 시 자동 갱신)
- 연속 사용 일수(streakDays) 계산
- 보존 정책:
- `cleanupOldEntries()`: retentionDays(30일) 초과 + softDelete된 항목 물리 삭제
- 앱 시작 시 + 24시간 주기로 실행
- maxEntries 초과 시 오래된 항목부터 softDelete
### 5.5 DictionaryService 완전 구현
- `src/main/services/DictionaryService.ts`
- better-sqlite3 + drizzle-orm (DB 스키마: `03-db-and-ui.md` 참조)
- CRUD 메서드:
- `add(word: string, pronunciation?: string, category?: string): Promise<Dictionary>`
- `update(id: string, data: Partial<Dictionary>): Promise<void>`
- `delete(id: string): Promise<void>`
- `getAll(filter?: DictionaryFilter): Promise<Dictionary[]>`
- `search(query: string): Promise<Dictionary[]>` — word 검색
- `incrementUsage(id: string): Promise<void>` — usageCount 증가 + lastUsedAt 갱신
- 카테고리 관리: `user`, `auto`, `technical`
- STT 연동: Whisper initialPrompt에 사전 단어 목록 주입
- 전사 시작 전 DictionaryService에서 상위 N개 단어 조회
- initialPrompt 형태: `"단어1, 단어2, 단어3"` (Whisper 컨텍스트 힌트)
- 사용 횟수 자동 갱신: 전사 결과에 사전 단어가 포함되면 incrementUsage 호출
### 5.6 History UI (React)
- `src/renderer/components/History.tsx`
- 목록 표시:
- MUI DataGrid 또는 커스텀 리스트
- 원본 텍스트, 다듬어진 텍스트, 모드, 상태, 날짜, 녹음 시간 표시
- 무한 스크롤 또는 페이지네이션 (기본 50개씩)
- 검색: 텍스트 검색 입력 + 필터 (날짜 범위, 모드, 언어)
- 항목별 액션:
- 복사 (원본/다듬은 텍스트)
- 재시도 (같은 텍스트를 다시 LLM 처리)
- TTS 재생 (Phase 5.3 연동)
- 삭제 (softDelete + 확인 다이얼로그)
- IPC 채널:
- `history:getList` — 목록 조회
- `history:getById` — 상세 조회
- `history:delete` — 삭제
- `history:bulkDelete` — 일괄 삭제
- `history:search` — 검색
- `history:getStats` — 통계
### 5.7 Dictionary UI (React)
- `src/renderer/components/Dictionary.tsx`
- 단어 목록: word, pronunciation, category, usageCount 표시
- 추가: 단어 + 발음 힌트 + 카테고리 입력 다이얼로그
- 편집: 인라인 편집 또는 모달
- 삭제: 확인 후 삭제
- 가져오기/내보내기:
- JSON 파일로 내보내기 (`[{ word, pronunciation, category }]`)
- JSON 파일에서 가져오기 (중복 word+category 시 스킵 또는 덮어쓰기 옵션)
- Electron dialog.showOpenDialog / dialog.showSaveDialog 사용
- IPC 채널:
- `dictionary:getAll` — 전체 목록
- `dictionary:add` — 추가
- `dictionary:update` — 수정
- `dictionary:delete` — 삭제
- `dictionary:import` — 파일에서 가져오기
- `dictionary:export` — 파일로 내보내기
### 5.8 Dashboard 통계 UI 완성
- `src/renderer/components/Dashboard.tsx`
- 통계 카드:
- 총 녹음 시간 (시:분:초 형식)
- 총 단어 수
- 총 세션 수
- 연속 사용 일수 (streakDays)
- 최근 7일 / 30일 활동 그래프 (간단한 바 차트, MUI 또는 커스텀 SVG)
- 최근 세션 목록 (5~10개, History UI로 이동 링크)
- 언어별/모드별 사용 비율 (파이 차트 또는 비율 바)
- stats 테이블에서 데이터 조회 + history 테이블에서 최근 데이터 집계
### 5.9 Drawer 네비게이션 업데이트
- Speakly 패턴: MUI Drawer (240px, permanent)
- 네비게이션 항목 추가: Dashboard, History, Dictionary
- 아이콘 + 텍스트 라벨
- 현재 라우트 하이라이트
## Speakly RE 참조
- HistoryService: SQLite 스키마 (history 테이블), CRUD 패턴, 검색 쿼리
- DictionaryService: 단어 사전 관리, 카테고리 분류
- RecordStatsService: 싱글턴 통계 테이블, 누적 집계 패턴
- App.js: MUI Drawer 네비게이션, 라우팅 패턴 (useState 기반)
- ResultPopup: 결과 표시 + 액션 버튼 패턴
## 완료 조건
- [ ] TTS로 텍스트 음성 재생 가능
- [ ] 재생/중지 버튼 UI 작동
- [ ] 히스토리 CRUD + 검색 + 페이지네이션 작동
- [ ] 히스토리 보존 정책 (30일 초과 자동 정리) 작동
- [ ] 사전 CRUD + 가져오기/내보내기 작동
- [ ] 사전 단어가 STT initialPrompt에 주입됨
- [ ] Dashboard 통계 카드 및 최근 세션 표시
- [ ] Drawer 네비게이션으로 각 화면 이동 가능

183
docs/phases/phase-6.md Normal file
View file

@ -0,0 +1,183 @@
# Phase 6: 커스텀 명령어 + 설정 UI 고도화
## 목표
사용자 정의 LLM 명령어 시스템을 완전 구현하고, 설정 UI를 고도화하여 STT/LLM/TTS 모델 선택, 온보딩, 다국어(i18n)를 지원한다.
## 태스크
### 6.1 CustomInstructionService 완전 구현
- `src/main/services/CustomInstructionService.ts`
- electron-store에 명령어 목록 저장 (DB 아닌 설정 파일)
- CustomInstruction 타입:
```typescript
interface CustomInstruction {
id: string; // nanoid
name: string; // 표시 이름 (예: "번역 (한→영)")
description: string; // 설명
prompt: string; // 시스템 프롬프트 템플릿
icon: string; // MUI 아이콘 이름 또는 이모지
isBuiltin: boolean; // 프리셋 여부 (삭제 불가)
hotkeyId: string | null; // 바인딩된 핫키 ID (null이면 미설정)
order: number; // 표시 순서
createdAt: number;
updatedAt: number;
}
```
- CRUD 메서드:
- `getAll(): CustomInstruction[]`
- `getById(id: string): CustomInstruction | null`
- `create(input: CreateCustomInstructionInput): CustomInstruction`
- `update(id: string, data: Partial<CustomInstruction>): void`
- `delete(id: string): void` — isBuiltin은 삭제 불가
- `reorder(ids: string[]): void` — 순서 변경
- `resetBuiltins(): void` — 프리셋을 기본값으로 초기화
### 6.2 프리셋 명령어 5개
- 앱 최초 실행 시 자동 생성 (isBuiltin: true)
1. **번역** (`translate`)
```
다음 텍스트를 {{targetLanguage}}로 번역해주세요.
자연스럽고 정확한 번역만 출력하세요.
```
2. **요약** (`summarize`)
```
다음 텍스트의 핵심 내용을 3줄 이내로 요약해주세요.
요약문만 출력하세요.
```
3. **전문 리라이트** (`formal-rewrite`)
```
다음 텍스트를 격식 있는 비즈니스 문체로 다시 작성해주세요.
원래 의미를 유지하면서 전문적인 톤으로 변환하세요.
다시 작성된 텍스트만 출력하세요.
```
4. **코드 설명** (`explain-code`)
```
다음 코드를 한국어로 설명해주세요.
각 부분이 무엇을 하는지 간결하게 설명하세요.
```
5. **자유 프롬프트** (`free-prompt`)
```
{{userPrompt}}
```
- 사용자가 매번 프롬프트를 직접 입력하는 특수 모드
- UI에서 프롬프트 입력 필드 표시
### 6.3 사용자 정의 명령어 CRUD UI
- `src/renderer/components/CustomCommands.tsx`
- 명령어 목록: 이름, 설명, 프리셋 여부 표시
- 추가: 이름, 설명, 프롬프트 템플릿 입력 다이얼로그
- 프롬프트 템플릿에 `{{text}}` 변수 자동 삽입 안내
- 프롬프트 미리보기 (예시 텍스트로 치환 결과 표시)
- 편집: 프리셋은 프롬프트만 수정 가능, 사용자 정의는 전체 수정 가능
- 삭제: 프리셋은 삭제 불가 (리셋만 가능), 사용자 정의는 확인 후 삭제
- 드래그 앤 드롭으로 순서 변경
- IPC 채널:
- `customInstruction:getAll`
- `customInstruction:create`
- `customInstruction:update`
- `customInstruction:delete`
- `customInstruction:reorder`
### 6.4 핫키 바인딩 (명령어별)
- 각 커스텀 명령어에 개별 핫키 설정 가능
- HotkeyService에 동적 핫키 등록/해제 연동
- 핫키 녹화 UI (Speakly HotkeyRecordModal 참조):
- 모달에서 키 조합 누르면 감지 → 표시 → 저장
- 시스템 예약 키 블랙리스트 (Ctrl+C, Ctrl+V, Alt+F4 등)
- 중복 핫키 충돌 감지 + 경고
- 명령어별 핫키 → VoiceModeService에 postProcess 자동 설정
- 핫키 감지 시: 해당 명령어의 prompt로 postProcess 설정 후 녹음 시작
### 6.5 설정 UI 고도화
- `src/renderer/components/Settings.tsx` — MUI Modal 기반
- 섹션별 탭 구성:
**일반 탭:**
- 테마 선택 (light/dark/system)
- 시작 시 최소화
- 닫기 시 트레이로 최소화
**음성 입력(STT) 탭:**
- STT 모델 선택 드롭다운 (base, small, medium, large-v3)
- 모델별 크기, 정확도 설명 표시
- 미다운로드 모델 표시 + 다운로드 버튼 (선택)
- 기본 언어 선택 (auto, ko, en, ja, zh 등)
- VAD 필터 토글
- 초기 프롬프트 입력
- 마이크 디바이스 선택 + 테스트 (Phase 3에서 이관)
**LLM 탭:**
- Ollama 서버 URL 입력 + 연결 테스트 버튼
- 기본 LLM 모델 선택 드롭다운 (Ollama에서 설치된 모델 목록 조회)
- 기본 온도, 최대 토큰 설정
- 기본 후처리 명령 선택
**TTS 탭:**
- TTS 활성화/비활성화 토글
- 기본 음성 선택 (설치된 음성 목록)
- 말하기 속도 슬라이더 (0.5x ~ 2.0x)
- 미리듣기 버튼
**핫키 탭:**
- Dictation 모드 핫키 설정
- Hands-free 모드 핫키 설정
- 커스텀 명령어별 핫키 설정 (6.4 연동)
- 핫키 녹화 모달
### 6.6 온보딩 플로우 (첫 실행 안내)
- 앱 최초 실행 감지: ConfigService에 `onboarding.completed` 플래그
- 4단계 온보딩 위저드:
1. **환영**: 앱 소개, 주요 기능 설명
2. **마이크 설정**: 디바이스 선택 + 테스트 녹음
3. **핫키 설정**: 기본 핫키 안내 + 커스텀 설정
4. **Ollama 설정**: Ollama 설치 안내 + 연결 테스트, 모델 선택
- 건너뛰기 가능, 설정에서 다시 실행 가능
- Speakly FlowOnboarding 참조: 단계별 UI, 진행 표시기
### 6.7 i18n (한국어/영어)
- `src/shared/i18n/` 디렉토리
- 리소스 파일 구조:
```
i18n/
├── ko.json # 한국어 (기본)
└── en.json # 영어
```
- 간단한 i18n 유틸리티 (라이브러리 없이 직접 구현):
```typescript
type I18nKey = string;
function t(key: I18nKey, params?: Record<string, string>): string;
function setLocale(locale: 'ko' | 'en'): void;
function getLocale(): string;
```
- React 컨텍스트: `I18nProvider` + `useI18n()`
- ConfigService `ui.language` 연동
- 번역 대상: UI 라벨, 에러 메시지, 프리셋 명령어 이름/설명, 온보딩 텍스트
- Speakly I18nService 참조: 키-값 리소스 파일, 런타임 언어 전환
### 6.8 IPC 채널 추가
- `customInstruction:getAll`, `customInstruction:create`, `customInstruction:update`, `customInstruction:delete`, `customInstruction:reorder`
- `config:getSection`, `config:setSection`, `config:resetSection`
- `i18n:getLocale`, `i18n:setLocale`, `i18n:getTranslations`
- `onboarding:getStatus`, `onboarding:complete`
## Speakly RE 참조
- CustomInstructionConfigService: 명령어 CRUD, 프리셋 관리, electron-store 저장
- HotkeyConfig: 키코드 맵, 시스템 예약 단축키 블랙리스트, HotkeyRecordModal
- FlowOnboarding: 단계별 온보딩 UI, 진행 표시, 건너뛰기
- I18nService: 키-값 리소스 파일, 런타임 언어 전환, React 컨텍스트 연동
- Settings: 섹션별 탭 UI, 디바이스 선택, 모델 선택
## 완료 조건
- [ ] 프리셋 명령어 5개 기본 제공
- [ ] 사용자 정의 명령어 추가/편집/삭제/순서변경 가능
- [ ] 명령어별 핫키 바인딩 및 핫키로 녹음+명령어 실행 가능
- [ ] 설정에서 STT 모델, LLM 모델, TTS 음성 선택 가능
- [ ] Ollama 연결 테스트 작동
- [ ] 온보딩 위저드가 첫 실행 시 표시됨
- [ ] 한국어/영어 UI 전환 가능
- [ ] 모든 UI 텍스트가 i18n 리소스에서 로드됨

191
docs/phases/phase-7.md Normal file
View file

@ -0,0 +1,191 @@
# Phase 7: 테스트 + 빌드 + 배포
## 목표
테스트 커버리지를 확보하고, 프로덕션 빌드/패키징 파이프라인을 구축하여 Windows 설치 파일을 생성한다. CI/CD를 설정하여 자동 빌드 및 배포를 가능하게 한다.
## 태스크
### 7.1 테스트 전략 수립
- 3계층 테스트 피라미드:
- **단위 테스트** (vitest): 서비스 로직, 유틸리티, 상태 머신
- **통합 테스트** (vitest): IPC 핸들러, DB 쿼리, 서비스 간 상호작용
- **E2E 테스트** (Playwright + electron): 전체 사용자 시나리오
- 커버리지 목표: 단위 70%+, 통합 50%+, E2E 핵심 플로우
### 7.2 단위 테스트 — 최우선 대상
- **VoiceModeService 상태 머신**:
- 모든 RecognitionState 전이 경로 테스트
- 모든 AudioState 전이 경로 테스트
- 잘못된 전이 시도 시 에러 처리
- 이중 조건 플러시 (sttReady + audioBuffer) 시나리오
- 타이밍 상수 검증 (minAudioDurationMs, doublePressMs)
- 세션 취소/타임아웃 시나리오
- **LocalSTTService**:
- sidecar 프로토콜 파싱 (JSON stdout)
- 이중 조건 플러시 로직
- 모델 로딩 상태 전이
- 에러 전파 (sidecar crash, transcription fail)
- **LocalLLMService**:
- Ollama API 응답 파싱
- NDJSON 스트리밍 파싱 (불완전 라인 처리 포함)
- 가용성 폴링 로직
- 에러 코드별 처리 (연결 실패, 모델 미설치)
- **HotkeyService**:
- 키코드 매칭 + 수정자 키 조합
- 더블프레스 감지 (300ms 윈도우)
- holdMode vs toggleMode 동작
- **TextInsertService**:
- 클립보드 백업/복원 흐름
- 삽입 실패 시 클립보드 복원 보장
- **HistoryService / DictionaryService**:
- CRUD 메서드 (in-memory SQLite 사용)
- 검색/필터 쿼리
- 보존 정책 (cleanup)
- 통계 집계 정확성
- **CustomInstructionService**:
- CRUD + 프리셋 보호 (삭제 불가)
- 순서 변경
- 프롬프트 템플릿 변수 치환
### 7.3 통합 테스트
- **IPC 핸들러 테스트**:
- 각 IPC 채널이 올바른 서비스 메서드를 호출하는지 검증
- 인자 검증 + 에러 응답 형식
- preload bridge ↔ main handler 매핑 정합성
- **DB 쿼리 통합 테스트**:
- drizzle-orm 쿼리가 실제 SQLite에서 올바르게 실행되는지 검증
- 마이그레이션 적용 후 스키마 일관성
- 동시 접근 시나리오 (WAL 모드)
- **서비스 간 상호작용**:
- VoiceModeService → AudioCaptureService → LocalSTTService → LocalLLMService → TextInsertService 파이프라인
- ConfigService 변경 → 서비스 반영 (설정 핫리로드)
### 7.4 E2E 테스트 (Playwright)
- `@playwright/test` + `electron` fixture 사용
- 핵심 시나리오:
1. 앱 실행 → 메인 윈도우 표시
2. Dashboard 통계 로드
3. Settings 열기 → 설정 변경 → 저장
4. History 목록 표시 → 검색 → 삭제
5. Dictionary 추가 → 편집 → 삭제
6. 온보딩 플로우 완주 (첫 실행 시뮬레이션)
- 외부 의존성 모킹:
- Ollama API: MSW(Mock Service Worker) 또는 로컬 HTTP 서버
- faster-whisper sidecar: 모킹된 stdout 응답
- piper-tts sidecar: 모킹된 WAV 출력
### 7.5 테스트 유틸리티
- `tests/helpers/`:
- `createTestDb()` — in-memory SQLite + 스키마 적용
- `mockElectronStore()` — electron-store 모킹
- `mockSidecar()` — sidecar stdout/stdin 모킹
- `createMockAudioBuffer()` — 테스트용 PCM16 버퍼 생성
- `waitForState()` — 상태 머신 전이 대기 유틸
- vitest 설정:
- `vitest.config.ts`: 경로 별칭, 환경변수, 타임아웃
- `vitest.workspace.ts`: unit / integration / e2e 워크스페이스 분리
### 7.6 electron-vite 빌드 설정
- `electron.vite.config.ts`:
- main: TypeScript → CJS, externals (better-sqlite3, uiohook-napi, @nut-tree/nut-js)
- preload: TypeScript → CJS, contextIsolation 호환
- renderer: React + MUI → 번들 (코드 스플리팅, 트리 셰이킹)
- 환경 분리: `MAIN_VITE_*`, `RENDERER_VITE_*`
- native 모듈 빌드: electron-rebuild 또는 prebuild-install
- 팝업 HTML 복사: recording-tip.html, result-popup.html → output
### 7.7 electron-builder 패키징
- `electron-builder.yml` 설정:
```yaml
appId: com.d3ro.voice
productName: D3RO Voice
directories:
output: release
win:
target:
- target: nsis
arch: [x64]
icon: resources/icon.ico
nsis:
oneClick: false
allowToChangeInstallationDirectory: true
createDesktopShortcut: true
createStartMenuShortcut: true
extraResources:
- from: sidecar/
to: sidecar/
filter: ["**/*"]
```
- sidecar 번들링:
- faster-whisper Python 환경 (embedded Python 또는 PyInstaller 빌드)
- piper-tts 바이너리 + 음성 모델
- Whisper 모델 파일 (선택: 초기 번들 또는 첫 실행 시 다운로드)
- native 모듈 리빌드: better-sqlite3, uiohook-napi, @nut-tree/nut-js
- asar 설정: native 모듈은 asar 외부 (`asarUnpack`)
### 7.8 자동 업데이트 (electron-updater, 선택)
- `electron-updater` 라이브러리 통합
- GitHub Releases를 업데이트 서버로 사용
- 업데이트 체크: 앱 시작 시 + 24시간 주기
- 업데이트 플로우:
1. `autoUpdater.checkForUpdates()`
2. 업데이트 발견 시 알림 (강제 아님)
3. 사용자 승인 시 백그라운드 다운로드
4. 다운로드 완료 → 재시작 안내
- 버전 관리: semver, `package.json` version 필드
### 7.9 CI/CD (GitHub Actions)
- `.github/workflows/ci.yml`:
```yaml
on: [push, pull_request]
jobs:
lint-and-typecheck:
- npm run lint
- npm run typecheck
unit-test:
- npm run test:unit
integration-test:
- npm run test:integration
build:
- npm run build
- 빌드 산출물 아티팩트 업로드
```
- `.github/workflows/release.yml`:
```yaml
on:
push:
tags: ['v*']
jobs:
build-and-release:
- npm run build
- electron-builder --win --x64
- GitHub Release 생성 + 설치파일 업로드
```
- Windows 러너 사용 (`runs-on: windows-latest`)
- 캐시: node_modules, electron 바이너리
### 7.10 코드 서명 (선택)
- Windows: Authenticode 서명 (EV 코드 서명 인증서)
- electron-builder 설정:
```yaml
win:
sign: ./scripts/sign.js
signingHashAlgorithms: [sha256]
```
- CI에서 서명: GitHub Secrets에 인증서 저장
- 서명 없이도 실행 가능 (SmartScreen 경고 표시)
## Speakly RE 참조
- Speakly 패키징: electron-builder, NSIS 인스톨러, Squirrel.Windows 기반 업데이트
- Speakly 빌드: NativeHelper.dll → extraResources, asar 외부 배치
- Speakly CI: 자동 빌드/배포 파이프라인 (D3RO는 GitHub Actions로 대체)
## 완료 조건
- [ ] `npm run test:unit` — VoiceModeService, HistoryService 등 핵심 서비스 테스트 통과
- [ ] `npm run test:integration` — IPC 핸들러, DB 쿼리 테스트 통과
- [ ] `npm run test:e2e` — 핵심 사용자 시나리오 테스트 통과
- [ ] `npm run build` — 프로덕션 빌드 성공
- [ ] electron-builder로 Windows NSIS 인스톨러 생성
- [ ] 인스톨러로 설치 → 실행 → 기본 기능 작동 확인
- [ ] GitHub Actions CI 파이프라인 작동 (lint, typecheck, test, build)
- [ ] (선택) 자동 업데이트 체크 작동

View file

@ -0,0 +1,40 @@
# NativeHelper.dll 디컴파일 결과 핵심
## Ghidra 분석: 2,692개 함수, 196,683줄 디컴파일 C코드
전체 결과: C:/tmp/ghidra_decompile_output/_all_decompiled.c
## 37개 Action 디스패치 테이블
ping, insertText, getCursorState, clipboardPaste, getActiveApp,
isAppRunning, activateApp, getClipboardText, setClipboardText, clearClipboard,
captureScreenshot, startKeyMonitoring, stopKeyMonitoring, startAppMonitoring,
stopAppMonitoring, setKeyRecordingMode, setFnKeySuppression, startEditMonitor,
stopEditMonitor, updateHotkeys, simulateKeyPress, simulateKeys,
muteSystemAudio, unmuteSystemAudio, isSystemAudioMuted,
showNotification, removeNotification, initializeNotifications, getNotificationPermission,
checkAccessibilityPermission, requestAccessibilityPermission, enableSelfAccessibility,
checkMicrophonePermission, requestMicrophonePermission,
getMicrophoneDevices, startMicrophoneCapture, stopMicrophoneCapture,
setTextOperationConfig, exploreAccessibilityTree
## 키보드 후킹 (D3RO-VOICE에서 uiohook-napi로 대체)
- WH_KEYBOARD_LL 전역 후크
- LLKHF_INJECTED 플래그로 자체 이벤트 바이패스
- Win키 핫키 시 합성 keyup 주입 (시작 메뉴 방지)
- 8초 stale key 정리
## WASAPI 캡처 (D3RO-VOICE에서 Web Audio API로 대체)
- 이벤트 구동: WaitForMultipleObjects(captureEvent, exitEvent)
- 리샘플링: 디바이스 포맷 → 24kHz mono
- XAudioProcessor: Opus 인코딩 (로컬 앱에서는 불필요)
## 대체 매핑
| Speakly (NativeHelper.dll) | D3RO-VOICE (npm 패키지) |
|---------------------------|------------------------|
| WASAPI 마이크 캡처 | Web Audio API / node-record-lpcm16 |
| Opus 인코딩 | 불필요 (PCM 직접 전달) |
| WH_KEYBOARD_LL | uiohook-napi |
| SendInput (Ctrl+V) | @nut-tree/nut-js |
| Win32 Clipboard API | electron clipboard API |
| UI Automation | 초기 미구현, 추후 추가 |
| IAudioEndpointVolume | loudness npm |
| Toast Notification | electron Notification API |

View file

@ -0,0 +1,33 @@
# Speakly 아키텍처 분석 결과
## 전체 구조
```
Renderer (React+MUI) ↔ Preload (IPC Bridge) ↔ Main Process
↓ koffi FFI
NativeHelper.dll
Genspark Cloud (WSS)
```
## 서비스 목록 (25개)
VoiceModeService(오케스트레이터), VoiceRecognitionService(WebSocket STT),
AudioService, MicNativeService, NativeService(FFI), AuthService,
ContextService, CustomInstructionService, HotkeyService, HotkeyConfig,
HistoryService(SQLite), DictionaryService, TextOperationStrategy,
UserConfigService, DeviceConfigService, UserInfoService, GensparkService,
I18nService, FeedbackService, ReportService, UpdateService,
AutoLaunchService, PermissionService, SoundEffectService, DebugProvider
## IPC 채널 수
- ipcMain.handle: ~130개 (양방향)
- ipcMain.on: ~30개 (단방향 renderer→main)
- webContents.send: ~40개 (단방향 main→renderer)
## 초기화 순서 (22단계)
1. Logger → 2. User-Agent/CSP → 3. Accessibility → 4. DeepLink →
5. UserConfigService → 6. I18nService → 7. AuthService → 8. ReportService →
9. UserInfoService → 10. DebugProvider → 11. TextOperationStrategy →
12. RecordStatsService → 13. HistoryService → 14. SoundEffectService →
15. CustomInstructionConfigService → 16. AutoLaunchService → 17. HotkeyConfig →
18. createWindow → 19. HotkeyService 포워딩 → 20. Tray → 21. IPC 핸들러 →
22. UpdateService (3초 지연)

View file

@ -0,0 +1,39 @@
# 텍스트 삽입 핵심 패턴
## ClipboardPaste 의사코드 (NativeHelper.dll에서 추출)
```pseudocode
function clipboardPaste(text):
// 1. 기존 클립보드 저장 (모든 포맷 열거)
savedClipboard = saveClipboard() // EnumClipboardFormats → GetClipboardData
// 2. 클립보드에 텍스트 설정
OpenClipboard(NULL)
EmptyClipboard()
hMem = GlobalAlloc(GMEM_MOVEABLE, size)
memcpy(GlobalLock(hMem), text, size)
SetClipboardData(CF_UNICODETEXT, hMem)
CloseClipboard()
// 3. Ctrl+V 시뮬레이션
SendInput([KeyDown(VK_CONTROL), KeyDown(VK_V), KeyUp(VK_V), KeyUp(VK_CONTROL)])
Sleep(delay)
// 4. 클립보드 복원
restoreClipboard(savedClipboard)
```
## 삽입 전략 (TextOperationStrategy)
- insertMethod: 'clipboard' (기본) | 'keyboard'
- selectMethod: 'clipboard' | 'ax' | 'none'
- verifyMode: 'auto' | 'skip'
- 앱별 레벨(level0-3): bundleId 기반 오버라이드
## EditMonitor (삽입 검증)
- 삽입 후 5초 대기 → 커서 주변 텍스트 재캡처
- before_input / system_input / after_input 비교
- 서버 corrections → 사전 자동 추가
## D3RO-VOICE 적용
- @nut-tree/nut-js의 keyboard.type() 또는 clipboard + keyboard.pressKey(Key.LeftControl, Key.V)
- electron clipboard API로 save/restore
- 검증은 초기에는 skip, 추후 UI Automation 추가 가능

View file

@ -0,0 +1,55 @@
# UI 구현 핵심 패턴
## 1. 메인 앱 = React + MUI, 팝업 = Vanilla JS
- 메인 윈도우만 React 번들 (781KB)
- 팝업들은 개별 HTML + 순수 JS (빠른 로딩)
- 각 팝업: recording-tip, result-popup, info-tip, mic-tip, mode-tip, ask-window
## 2. MUI 테마 설정
```javascript
createTheme({
palette: {
mode: 'light' | 'dark',
primary: { main: 'rgb(31, 93, 242)' },
background: { default: '#F9F9F9'/'#121212', paper: '#FFFFFF'/'#1E1E1E' }
},
typography: { fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI"' },
shape: { borderRadius: 12 },
components: { MuiButton: { root: { textTransform: 'none', fontWeight: 500 } } }
})
```
## 3. RecordingTip 웨이브 바
- 9개 바, 코사인 분포: `cos(n * PI / 2)` 가중치
- 100ms 간격 setInterval
- 오디오 레벨 → targetAmplitude, smoothing: `v += (S - v) * 0.5`
- 최소 2px, 최대 28px + 랜덤 변동 ±35%
## 4. Thinking 프로그레스 바
- 시간 기반: `min(95, (1 - 1/(1 + 1.5*t)) * 100)%` — 95%에 점근 수렴
- requestAnimationFrame 루프
## 5. 2-Phase 윈도우 리사이즈 (깜빡임 방지)
```
prepare(state, params) → 숨겨진 span으로 폭 측정 → measured(width) IPC
→ 메인 프로세스에서 윈도우 리사이즈
→ show(state) → CSS 클래스 적용 → 보이기
```
## 6. 앱 구조 (React 컴포넌트 트리)
```
App
├── ThemeProvider (light/dark/auto, localStorage)
├── Drawer (240px, permanent)
│ ├── NavItems [Dashboard, Dictionary, CustomCommand]
│ └── BottomBar [Account, Settings]
└── Content Area
├── Dashboard (통계 + 최근 세션)
├── History (검색 + 재시도)
├── Dictionary
└── Settings (Modal)
```
## 7. 라우팅
- React Router 미사용, 순수 useState 기반
- `currentRoute` state → switch 문으로 렌더링

View file

@ -0,0 +1,44 @@
# 음성 파이프라인 핵심 패턴
## 1. 상태 머신 (RecognitionState)
```
IDLE → PREPARING → CONNECTING → READY → RECOGNIZING → COMPLETED/CANCELLED/ERROR/DESTROYED
```
- AudioState는 별도: IDLE → INITIALIZING → STREAMING → STOPPED
- 모든 진입점에 `_isInTerminalState()` 가드
- `errorEmitted` 플래그: error 이벤트 후 finish 중복 방지
- `settled` boolean: Promise 이중 resolve/reject 방지
## 2. 이중 조건 플러시 (가장 영리한 패턴)
```
_connect() ← 비동기 (모델 로딩 대체)
_captureContextInBackground() ← 비동기 (오디오 버퍼링 대체)
각 완료 시 → _tryFlushAll() 호출
if (isReady AND isConnected):
flushMessageQueue() // 설정 먼저
flushAudioBuffer() // 오디오 후
```
- 오디오 손실 방지가 최우선
## 3. 재연결 3계층
```
_connect (고수준, 리포팅)
→ _connectWithRetry (1초 간격, 60초 제한)
→ _connectOnce (5초 하드 타임아웃, DNS/TCP/TLS 계측)
```
- 403 인증 실패: 즉시 포기
- 오프라인: 즉시 포기
- 기타: 1초 후 재시도
## 4. 타이밍 상수
- 하트비트: 5초 ping, 15초 타임아웃 (3x 규칙)
- 녹음 후 대기: 4초 (버퍼 있으면 6초)
- 완료 아이들 타임아웃: 30초
- 절대 최대 대기: 120초
- 최소 오디오: 700ms (이하 취소)
- 더블프레스: 300ms 이내
## 5. 오디오 포맷
- Speakly: 24kHz mono, Opus 24kbps, 60ms 프레임
- D3RO-VOICE: 16kHz mono, PCM16 (Whisper 기본, Opus 불필요)