초기 프로젝트 설정: 하네스 시스템 + 설계서 + 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:
commit
e24bb8378c
35 changed files with 11452 additions and 0 deletions
1329
docs/design/00-master-architecture.md
Normal file
1329
docs/design/00-master-architecture.md
Normal file
File diff suppressed because it is too large
Load diff
1834
docs/design/01-service-specifications.md
Normal file
1834
docs/design/01-service-specifications.md
Normal file
File diff suppressed because it is too large
Load diff
1786
docs/design/02-ipc-and-types.md
Normal file
1786
docs/design/02-ipc-and-types.md
Normal file
File diff suppressed because it is too large
Load diff
1131
docs/design/03-db-and-ui.md
Normal file
1131
docs/design/03-db-and-ui.md
Normal file
File diff suppressed because it is too large
Load diff
392
docs/design/04-verification-report.md
Normal file
392
docs/design/04-verification-report.md
Normal 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` 상태가 빠져 있어 상태 전이 다이어그램 수정이 필요하다.
|
||||
1158
docs/design/05-external-engine-integration.md
Normal file
1158
docs/design/05-external-engine-integration.md
Normal file
File diff suppressed because it is too large
Load diff
230
docs/design/06-gap-analysis.md
Normal file
230
docs/design/06-gap-analysis.md
Normal 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 |
|
||||
577
docs/design/07-voicemode-patterns.md
Normal file
577
docs/design/07-voicemode-patterns.md
Normal 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 전환 시 기존 오디오 유지
|
||||
1095
docs/design/08-design-system.md
Normal file
1095
docs/design/08-design-system.md
Normal file
File diff suppressed because it is too large
Load diff
334
docs/design/09-history-popup.md
Normal file
334
docs/design/09-history-popup.md
Normal 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 필터
|
||||
- **핀 고정**: 자주 쓰는 항목 상단 고정
|
||||
- **미리보기**: 선택된 항목의 전체 텍스트를 팝업 확장으로 표시
|
||||
- **즐겨찾기**: 별표 표시 후 즐겨찾기만 보기
|
||||
Loading…
Add table
Add a link
Reference in a new issue