초기 프로젝트 설정: 하네스 시스템 + 설계서 + 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
37
.claude/agents/electron-architect.md
Normal file
37
.claude/agents/electron-architect.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
---
|
||||
name: electron-architect
|
||||
description: Electron 앱 아키텍처 전문가. 메인 프로세스 설계, IPC 패턴, 윈도우 관리. Speakly RE 노하우 기반.
|
||||
model: claude-opus-4-6
|
||||
tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
- Write
|
||||
- Edit
|
||||
- Bash
|
||||
---
|
||||
|
||||
# Electron Architect
|
||||
|
||||
Speakly 리버스엔지니어링에서 추출한 패턴을 적용하는 Electron 아키텍처 전문가.
|
||||
|
||||
## 핵심 원칙 (Speakly에서 학습)
|
||||
|
||||
### 메인 프로세스 설계
|
||||
- 서비스는 싱글톤 + EventEmitter 패턴
|
||||
- 초기화 순서 엄격 관리 (Speakly: 22단계 순차 초기화)
|
||||
- before-quit / will-quit에서 리소스 정리
|
||||
- 단일 인스턴스 잠금 (app.requestSingleInstanceLock)
|
||||
|
||||
### IPC 설계
|
||||
- `ipcMain.handle` (양방향, 반환값 있음) vs `ipcMain.on` (단방향)
|
||||
- 채널명: `${feature}:${action}` 패턴
|
||||
- preload에서 contextBridge로만 노출
|
||||
- 오디오 청크는 fire-and-forget (await 안 함)
|
||||
|
||||
### 윈도우 관리
|
||||
- 메인 윈도우: React + MUI (1104x816, hiddenInset titleBar)
|
||||
- 팝업: Vanilla JS, 프리로딩 방식
|
||||
- 2-phase 리사이즈: prepare(숨겨서 측정) → resize → show
|
||||
- 멀티모니터: displayId 기반 타겟 디스플레이
|
||||
- mouseenter/leave → setIgnoreMouseEvents
|
||||
47
.claude/agents/ui-specialist.md
Normal file
47
.claude/agents/ui-specialist.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
---
|
||||
name: ui-specialist
|
||||
description: React + MUI UI 전문가. Speakly UI 패턴(Dashboard, RecordingTip, ResultPopup) 구현.
|
||||
model: claude-opus-4-6
|
||||
tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
- Write
|
||||
- Edit
|
||||
---
|
||||
|
||||
# UI Specialist
|
||||
|
||||
Speakly 렌더러 분석(781KB 번들)에서 추출한 UI 패턴을 적용.
|
||||
|
||||
## 컴포넌트 구조 (Speakly 패턴)
|
||||
```
|
||||
App (root)
|
||||
├── ThemeProvider (MUI, light/dark/auto)
|
||||
├── Drawer (240px 사이드바)
|
||||
│ ├── NavItems
|
||||
│ └── BottomBar
|
||||
└── Content Area
|
||||
├── Dashboard (통계, 최근 세션)
|
||||
├── History (검색, 재시도)
|
||||
├── Dictionary (사전 관리)
|
||||
└── Settings (Modal)
|
||||
```
|
||||
|
||||
## MUI 테마 (Speakly 참조)
|
||||
- primary: rgb(31, 93, 242)
|
||||
- borderRadius: 12
|
||||
- fontFamily: -apple-system, BlinkMacSystemFont, "Segoe UI"
|
||||
- textTransform: 'none'
|
||||
- WebkitAppRegion: 'drag' (타이틀바)
|
||||
|
||||
## 녹음 UI 상태 머신
|
||||
opening → recording → thinking → result/error/cancelled
|
||||
- 9개 wave-bar, cos(n*PI/2) 분포 가중치
|
||||
- 100ms 간격 setInterval, smoothing: v += (S-v)*0.5
|
||||
- thinking: `min(95, (1 - 1/(1+1.5*t)) * 100)%` 점근 수렴
|
||||
|
||||
## 팝업 윈도우 (Vanilla JS)
|
||||
- 별도 HTML 엔트리포인트, React 미사용
|
||||
- 2-phase 리사이즈: prepare → measured → show
|
||||
- CSS transition + transitionend + 200ms setTimeout 폴백
|
||||
43
.claude/agents/voice-pipeline-expert.md
Normal file
43
.claude/agents/voice-pipeline-expert.md
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
---
|
||||
name: voice-pipeline-expert
|
||||
description: 음성 파이프라인 전문가. STT/TTS/오디오 캡처/상태 머신 설계. Speakly 오디오 파이프라인 노하우 기반.
|
||||
model: claude-opus-4-6
|
||||
tools:
|
||||
- Read
|
||||
- Glob
|
||||
- Grep
|
||||
- Write
|
||||
- Edit
|
||||
- Bash
|
||||
---
|
||||
|
||||
# Voice Pipeline Expert
|
||||
|
||||
Speakly VoiceRecognitionService(2191줄)에서 추출한 음성 파이프라인 노하우를 적용.
|
||||
|
||||
## 상태 머신 설계 (Speakly 패턴)
|
||||
```
|
||||
RecognitionState: IDLE → PREPARING → CONNECTING → READY → RECOGNIZING → COMPLETED
|
||||
AudioState: IDLE → INITIALIZING → STREAMING → STOPPED (별도 추적)
|
||||
```
|
||||
- `_isInTerminalState()` 체크가 모든 진입점에
|
||||
- errorEmitted 플래그로 이벤트 중복 방지
|
||||
- settled boolean으로 Promise 이중 resolve/reject 방지
|
||||
|
||||
## 오디오 버퍼링 (핵심 패턴)
|
||||
- 이중 조건 플러시: 모델 로딩 + 오디오 캡처 병렬
|
||||
- `_tryFlushAll()`: 양쪽 조건 모두 true 시 flush
|
||||
- 순서 보장: 설정 메시지 → 오디오 데이터
|
||||
- "오디오 손실 방지 > 컨텍스트 품질" 원칙
|
||||
|
||||
## 오디오 설정
|
||||
- sampleRate: 24000Hz → 16000Hz (Whisper 기본)
|
||||
- channels: 1 (mono)
|
||||
- noiseSuppression: false (초기 프레임 손실 방지)
|
||||
- echoCancellation: false (일방향 입력)
|
||||
- autoGainControl: false
|
||||
|
||||
## 로컬 STT 연동
|
||||
- Whisper에 PCM 16-bit 16kHz mono 직접 전달
|
||||
- Opus 인코딩 불필요 (로컬이므로)
|
||||
- faster-whisper Python sidecar 또는 whisper.cpp addon
|
||||
88
.claude/settings.json
Normal file
88
.claude/settings.json
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Read",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"Bash(npm *)",
|
||||
"Bash(npx *)",
|
||||
"Bash(node *)",
|
||||
"Bash(git *)",
|
||||
"Bash(ls *)",
|
||||
"Bash(mkdir *)",
|
||||
"Bash(cat *)",
|
||||
"Bash(which *)",
|
||||
"Bash(python *)",
|
||||
"Bash(pip *)",
|
||||
"Edit(src/**)",
|
||||
"Edit(tests/**)",
|
||||
"Edit(docs/**)",
|
||||
"Edit(*.md)",
|
||||
"Edit(*.json)",
|
||||
"Edit(*.ts)",
|
||||
"Edit(*.tsx)",
|
||||
"Edit(*.js)",
|
||||
"Edit(*.css)",
|
||||
"Edit(*.html)",
|
||||
"Write(src/**)",
|
||||
"Write(tests/**)",
|
||||
"Write(docs/**)",
|
||||
"Write(scripts/**)",
|
||||
"Write(*.md)",
|
||||
"Write(*.json)",
|
||||
"Write(*.ts)",
|
||||
"Write(*.tsx)"
|
||||
],
|
||||
"deny": [
|
||||
"Bash(rm -rf /)",
|
||||
"Bash(git push --force*)",
|
||||
"Bash(npm publish*)",
|
||||
"Edit(.env*)",
|
||||
"Edit(secrets.*)"
|
||||
]
|
||||
},
|
||||
"hooks": {
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"matcher": "",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "cat <<'INJECT'\n[D3RO-VOICE 강제 규칙]\n1. 구현 전 반드시 설계서 참조: docs/design/00~03 (아키텍처, 서비스 명세, IPC 타입, DB/UI)\n2. 서비스: 싱글톤 + EventEmitter. 설계서 01의 인터페이스를 그대로 구현\n3. IPC: 설계서 02의 ipc-channels.ts 채널명/타입을 그대로 사용\n4. 에러: 설계서 02의 D3ROError + ErrorCode enum 사용\n5. DB: 설계서 03의 drizzle-orm 스키마를 그대로 사용\n6. UI: 설계서 03의 컴포넌트 Props/상태/IPC 명세를 그대로 따름\n7. 윈도우: 프리로딩 + 2-phase 리사이즈 (설계서 01 WindowManagerService)\n8. 상태머신: RecognitionState + AudioState 분리 (설계서 01 VoiceModeService)\n9. 팝업: Vanilla JS, 설계서 03의 HTML/CSS/애니메이션 스펙 준수\n10. any 타입 절대 금지, console.log 절대 금지\nINJECT"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Write|Edit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "FILE=$(cat /dev/stdin | python -c \"import sys,json; d=json.load(sys.stdin); print(d.get('tool_input',{}).get('file_path',''))\" 2>/dev/null); if echo \"$FILE\" | grep -qE '\\.(ts|tsx)$'; then cd 'D:/workspace/D3ROVoice' && npx tsc --noEmit 2>&1 | head -15 || true; fi"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "FILE=$(cat /dev/stdin | python -c \"import sys,json; d=json.load(sys.stdin); print(d.get('tool_input',{}).get('file_path',''))\" 2>/dev/null); if echo \"$FILE\" | grep -qE '\\.(ts|tsx)$'; then grep -n 'any' \"$FILE\" 2>/dev/null | grep -v '// eslint-disable' | grep -v 'import' | head -5 && echo '[WARN] any 타입 발견 - 수정 필요' || true; fi"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Write|Edit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "FILE=$(cat /dev/stdin | python -c \"import sys,json; d=json.load(sys.stdin); print(d.get('tool_input',{}).get('file_path',''))\" 2>/dev/null); if echo \"$FILE\" | grep -qE '\\.env|\\.secret|credentials'; then echo 'BLOCKED: 시크릿 파일 수정 금지' >&2; exit 2; fi; exit 0"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
29
.claude/skills/debug-electron/SKILL.md
Normal file
29
.claude/skills/debug-electron/SKILL.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
---
|
||||
name: debug-electron
|
||||
description: Electron 앱 디버깅. IPC 문제, 메모리 누수, 렌더러 크래시 등 진단.
|
||||
allowed-tools: Read Grep Glob Bash
|
||||
---
|
||||
|
||||
# Electron Debug
|
||||
|
||||
## 일반 디버깅
|
||||
1. 로그 확인 (`electron-log` 출력)
|
||||
2. IPC 채널 매칭 검증 (`shared/ipc-channels.ts` vs 실제 핸들러)
|
||||
3. 프리로드 스크립트 노출 API 확인
|
||||
|
||||
## IPC 문제
|
||||
- `shared/ipc-channels.ts`에 채널 정의 확인
|
||||
- `src/main/ipc/`에 핸들러 등록 확인
|
||||
- `src/preload/index.ts`에 브릿지 노출 확인
|
||||
- 렌더러에서 `window.electronAPI.*` 호출 확인
|
||||
|
||||
## 오디오 문제
|
||||
- Web Audio API 초기화 상태
|
||||
- 마이크 권한 확인
|
||||
- AudioContext sampleRate (24000Hz)
|
||||
- noiseSuppression: false 확인
|
||||
|
||||
## 메모리 누수
|
||||
- useEffect cleanup 함수 확인
|
||||
- IPC 이벤트 리스너 제거 확인
|
||||
- BrowserWindow destroy 확인
|
||||
45
.claude/skills/implement-phase/SKILL.md
Normal file
45
.claude/skills/implement-phase/SKILL.md
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
---
|
||||
name: implement-phase
|
||||
description: Speakly RE 노하우를 기반으로 D3RO-VOICE의 특정 페이즈를 구현. 페이즈 번호를 인자로 전달.
|
||||
argument-hint: "[phase number, e.g. 1]"
|
||||
allowed-tools: Read Write Edit Bash Glob Grep Agent
|
||||
---
|
||||
|
||||
# Phase Implementation
|
||||
|
||||
지정된 페이즈를 구현합니다.
|
||||
|
||||
## 실행 순서
|
||||
|
||||
1. **설계서 로드** (필수, 반드시 먼저):
|
||||
- `docs/design/00-master-architecture.md` — 파일 트리, 초기화 순서, 서비스 의존관계
|
||||
- `docs/design/01-service-specifications.md` — 서비스 인터페이스, 상태 머신, 이벤트
|
||||
- `docs/design/02-ipc-and-types.md` — IPC 채널, TypeScript 타입, 에러 코드
|
||||
- `docs/design/03-db-and-ui.md` — DB 스키마, 컴포넌트 명세, MUI 테마
|
||||
2. **페이즈 문서 로드**: `docs/phases/phase-$ARGUMENTS.md` 읽기
|
||||
3. **RE 참조 문서 확인**: `docs/re-findings/` 에서 관련 노하우 확인
|
||||
4. **현재 코드 상태 파악**: 기존 구현된 코드 검토
|
||||
4. **구현 계획 수립**: TaskCreate로 세부 태스크 생성
|
||||
5. **구현**: 태스크별로 순차 구현
|
||||
- 타입 정의 (shared/) 먼저
|
||||
- 메인 프로세스 서비스 구현
|
||||
- IPC 핸들러 등록
|
||||
- 렌더러 UI 구현
|
||||
- 프리로드 브릿지 업데이트
|
||||
6. **테스트**: 각 모듈 단위 테스트
|
||||
7. **CLAUDE.md 업데이트**: 현재 상태 갱신
|
||||
|
||||
## 구현 규칙 (위반 불가)
|
||||
- 서비스는 **설계서 01**의 인터페이스를 그대로 구현 (메서드 시그니처, 이벤트 페이로드 일치)
|
||||
- IPC 채널은 **설계서 02**의 `IPC_CHANNELS` 상수를 그대로 사용 (임의 채널명 금지)
|
||||
- 타입은 **설계서 02**의 `shared/types.ts` 정의를 그대로 사용 (임의 타입 금지)
|
||||
- 에러는 **설계서 02**의 `D3ROError` + `ErrorCode` enum 사용
|
||||
- DB는 **설계서 03**의 drizzle-orm 스키마 그대로 사용
|
||||
- UI는 **설계서 03**의 컴포넌트 Props/상태 명세 준수
|
||||
- 팝업은 **설계서 03**의 HTML/CSS/애니메이션 수학 공식 준수
|
||||
|
||||
## 완료 조건
|
||||
- 모든 태스크 completed
|
||||
- `npm run typecheck` 통과
|
||||
- `npm run test` 통과
|
||||
- CLAUDE.md "현재 상태" 업데이트됨
|
||||
27
.claude/skills/review-phase/SKILL.md
Normal file
27
.claude/skills/review-phase/SKILL.md
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
---
|
||||
name: review-phase
|
||||
description: 완료된 페이즈를 검토하고 다음 페이즈 준비 상태를 판단. 페이즈 번호를 인자로 전달.
|
||||
argument-hint: "[phase number, e.g. 1]"
|
||||
allowed-tools: Read Glob Grep Bash Agent
|
||||
---
|
||||
|
||||
# Phase Review
|
||||
|
||||
완료된 페이즈를 검토합니다.
|
||||
|
||||
## 검토 항목
|
||||
|
||||
1. **요구사항 충족**: `docs/phases/phase-$ARGUMENTS.md` 대비 구현 완성도
|
||||
2. **Speakly 패턴 적용**: RE 노하우가 제대로 반영되었는지
|
||||
- 상태 머신 설계가 Speakly 수준인가?
|
||||
- 에러 처리가 포괄적인가?
|
||||
- 윈도우 관리가 프리로딩 + 2-phase 리사이즈인가?
|
||||
3. **코드 품질**: TypeScript strict, 일관된 네이밍, 중복 없음
|
||||
4. **테스트 커버리지**: 핵심 서비스에 단위 테스트 존재
|
||||
5. **타입 안전성**: `npm run typecheck` 통과
|
||||
6. **빌드**: `npm run build` 통과
|
||||
|
||||
## 출력
|
||||
- 발견된 이슈 목록 (심각도: critical/major/minor)
|
||||
- 다음 페이즈 진행 가능 여부 (GO/NO-GO)
|
||||
- CLAUDE.md 업데이트 제안
|
||||
31
.claude/skills/scaffold-component/SKILL.md
Normal file
31
.claude/skills/scaffold-component/SKILL.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
---
|
||||
name: scaffold-component
|
||||
description: D3RO-VOICE 프로젝트 컨벤션에 맞게 새 컴포넌트/서비스를 스캐폴딩. 이름을 인자로 전달.
|
||||
argument-hint: "[component or service name]"
|
||||
allowed-tools: Read Write Glob
|
||||
---
|
||||
|
||||
# Scaffold
|
||||
|
||||
프로젝트 컨벤션에 맞는 새 모듈을 생성합니다.
|
||||
|
||||
## 서비스 스캐폴딩 (메인 프로세스)
|
||||
Speakly 패턴 적용:
|
||||
- 싱글톤 + EventEmitter
|
||||
- 로거 태그 `[ServiceName]`
|
||||
- 초기화/종료 메서드
|
||||
- IPC 채널 `shared/ipc-channels.ts`에 추가
|
||||
|
||||
## React 컴포넌트 스캐폴딩 (렌더러)
|
||||
- 함수형 컴포넌트 + TypeScript Props 인터페이스
|
||||
- MUI sx prop 기반 스타일링
|
||||
- electronAPI IPC 호출 패턴
|
||||
|
||||
## Vanilla JS 팝업 스캐폴딩
|
||||
Speakly 패턴:
|
||||
- 별도 HTML 엔트리포인트
|
||||
- 순수 DOM 조작 (React 미사용)
|
||||
- 2-phase 리사이즈: prepare(측정) → resize → show
|
||||
- mouseenter/leave 이벤트 메인 프로세스 전달
|
||||
|
||||
모듈명: $ARGUMENTS
|
||||
32
.claude/skills/test-and-commit/SKILL.md
Normal file
32
.claude/skills/test-and-commit/SKILL.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
---
|
||||
name: test-and-commit
|
||||
description: 테스트 실행 후 통과하면 커밋. 코드 변경 완료 후 사용.
|
||||
allowed-tools: Bash Read
|
||||
---
|
||||
|
||||
# Test & Commit
|
||||
|
||||
1. **타입 체크**
|
||||
```bash
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
2. **린트**
|
||||
```bash
|
||||
npm run lint
|
||||
```
|
||||
|
||||
3. **테스트**
|
||||
```bash
|
||||
npm run test
|
||||
```
|
||||
|
||||
4. **모두 통과 시 커밋**
|
||||
- git status로 변경 파일 확인
|
||||
- git diff로 변경 내용 확인
|
||||
- 적절한 커밋 메시지 작성 (한글, Conventional Commits)
|
||||
- Co-Authored-By 절대 추가 금지
|
||||
|
||||
5. **실패 시**
|
||||
- 에러 내용 분석
|
||||
- 수정 후 재시도
|
||||
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
node_modules/
|
||||
dist/
|
||||
out/
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.claude/settings.local.json
|
||||
116
CLAUDE.md
Normal file
116
CLAUDE.md
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
# D3RO-VOICE — 로컬 AI 음성 어시스턴트
|
||||
|
||||
Genspark Speakly를 리버스엔지니어링하여 얻은 노하우를 기반으로 만드는 **완전 로컬** 음성 어시스턴트.
|
||||
클라우드 의존성 없이 Ollama + Whisper + TTS를 사용한다.
|
||||
|
||||
## 기술 스택 (고정)
|
||||
- **Framework**: Electron 33+ (contextIsolation: true, nodeIntegration: false)
|
||||
- **Frontend**: React 19 + MUI 7 + Vite
|
||||
- **Language**: TypeScript 5.7+ (strict mode)
|
||||
- **DB**: better-sqlite3 + drizzle-orm
|
||||
- **STT**: faster-whisper (Python sidecar) 또는 whisper.cpp
|
||||
- **TTS**: piper-tts 또는 kokoro
|
||||
- **LLM**: Ollama REST API (localhost:11434)
|
||||
- **Audio**: Web Audio API (renderer) + node-record-lpcm16 (main)
|
||||
- **Hotkey**: uiohook-napi (글로벌 키보드 후킹)
|
||||
- **Text Insert**: @nut-tree/nut-js (클립보드 + Ctrl+V)
|
||||
- **Config**: electron-store
|
||||
|
||||
## 프로젝트 구조
|
||||
```
|
||||
D3ROVoice/
|
||||
├── src/
|
||||
│ ├── main/ # Electron 메인 프로세스
|
||||
│ │ ├── services/ # STT, TTS, LLM, Audio, Hotkey, TextInsert
|
||||
│ │ ├── windows/ # 윈도우 매니저 (팁, 팝업, 메인)
|
||||
│ │ ├── ipc/ # IPC 핸들러 등록
|
||||
│ │ ├── db/ # SQLite (history, dictionary)
|
||||
│ │ └── index.ts # 앱 진입점
|
||||
│ ├── renderer/ # React 앱
|
||||
│ │ ├── components/ # React 컴포넌트
|
||||
│ │ ├── popups/ # Vanilla JS 팝업 (recording-tip, result-popup)
|
||||
│ │ └── main.tsx # 렌더러 진입점
|
||||
│ ├── preload/ # contextBridge IPC 브릿지
|
||||
│ │ └── index.ts
|
||||
│ └── shared/ # 공유 타입, IPC 채널 정의, 에러 코드
|
||||
│ ├── ipc-channels.ts
|
||||
│ ├── types.ts
|
||||
│ └── errors.ts
|
||||
├── docs/ # 설계 문서
|
||||
│ ├── phases/ # 페이즈별 요구사항
|
||||
│ └── re-findings/ # Speakly 리버스엔지니어링 결과
|
||||
├── scripts/ # 빌드/유틸 스크립트
|
||||
└── tests/
|
||||
```
|
||||
|
||||
## 빌드 & 실행
|
||||
```bash
|
||||
npm run dev # Electron + Vite dev server
|
||||
npm run build # 프로덕션 빌드
|
||||
npm run test # vitest 테스트
|
||||
npm run lint # eslint + prettier
|
||||
npm run typecheck # tsc --noEmit
|
||||
```
|
||||
|
||||
## 코딩 규칙
|
||||
- TypeScript strict mode 필수 (noImplicitAny, strictNullChecks)
|
||||
- 함수형 React 컴포넌트 + hooks만 사용
|
||||
- 메인 앱 = React + MUI, 팝업 윈도우 = Vanilla JS (Speakly 패턴)
|
||||
- IPC 채널명: `${feature}:${action}` (예: `voice:startRecording`, `config:getLanguage`)
|
||||
- 에러 처리: NXError 패턴 (에러 코드 + 메시지)
|
||||
- 로거: electron-log 사용, console.log 금지
|
||||
|
||||
## 절대 하지 말 것
|
||||
- any 타입 사용 금지
|
||||
- renderer에서 Node.js API 직접 import 금지
|
||||
- electron의 remote 모듈 사용 금지
|
||||
- console.log 남기지 말 것 (logger 사용)
|
||||
- 하드코딩된 시크릿/비밀번호 금지
|
||||
- Co-Authored-By, Claude 관련 문구 커밋 메시지에 추가 금지
|
||||
|
||||
## Speakly에서 채택한 핵심 패턴
|
||||
1. **상태 머신**: RecognitionState + AudioState 분리 추적
|
||||
2. **이중 조건 플러시**: 모델 로딩 + 오디오 버퍼링 동시 진행, 둘 다 준비 시 플러시
|
||||
3. **텍스트 삽입**: 클립보드 save → set → Ctrl+V → restore
|
||||
4. **윈도우 관리**: 프리로딩 + 2-phase 리사이즈 (측정→resize→show)
|
||||
5. **팝업**: 메인 앱=React, 경량 팝업=Vanilla JS
|
||||
6. **녹음 UI**: 9개 웨이브 바, cos 분포 가중치, 100ms 애니메이션
|
||||
|
||||
## 현재 상태
|
||||
Phase: 0 (하네스 설정 완료, 구현 시작 전)
|
||||
마지막 완료: 하네스 시스템 구축
|
||||
다음 작업: Phase 1 — 프로젝트 초기화 + Electron 뼈대
|
||||
차단 이슈: 없음
|
||||
|
||||
## 페이즈 로드맵
|
||||
- Phase 1: 프로젝트 초기화 + Electron 뼈대 + 마이크 캡처
|
||||
- Phase 2: 로컬 STT 연동 (Whisper) + 핫키
|
||||
- Phase 3: 텍스트 삽입 + 기본 UI (Dashboard, RecordingTip)
|
||||
- Phase 3.5: 커서 위치 히스토리 팝업 (D3RO 고유 기능)
|
||||
- Phase 4: Ollama LLM 연동 (텍스트 다듬기, 번역)
|
||||
- Phase 5: TTS + 히스토리/사전 DB
|
||||
- Phase 6: 커스텀 명령어 + 설정 UI 고도화
|
||||
- Phase 7: 테스트 + 빌드 + 배포
|
||||
|
||||
## 설계 문서 (구현 시 반드시 참조)
|
||||
@docs/design/00-master-architecture.md
|
||||
@docs/design/01-service-specifications.md
|
||||
@docs/design/02-ipc-and-types.md
|
||||
@docs/design/03-db-and-ui.md
|
||||
|
||||
## 페이즈 문서
|
||||
@docs/phases/phase-1.md
|
||||
@docs/phases/phase-2.md
|
||||
@docs/phases/phase-3.md
|
||||
@docs/phases/phase-3.5.md
|
||||
@docs/phases/phase-4.md
|
||||
@docs/phases/phase-5.md
|
||||
@docs/phases/phase-6.md
|
||||
@docs/phases/phase-7.md
|
||||
|
||||
## RE 노하우 (패턴 적용 근거)
|
||||
@docs/re-findings/speakly-architecture.md
|
||||
@docs/re-findings/voice-pipeline-patterns.md
|
||||
@docs/re-findings/text-insertion-patterns.md
|
||||
@docs/re-findings/ui-patterns.md
|
||||
@docs/re-findings/native-dll-patterns.md
|
||||
26
docs/design-refs/README.md
Normal file
26
docs/design-refs/README.md
Normal 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/태그
|
||||
- **타이포그래피**: 모노스페이스(상태 표시) + 시스템 폰트(본문)
|
||||
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 필터
|
||||
- **핀 고정**: 자주 쓰는 항목 상단 고정
|
||||
- **미리보기**: 선택된 항목의 전체 텍스트를 팝업 확장으로 표시
|
||||
- **즐겨찾기**: 별표 표시 후 즐겨찾기만 보기
|
||||
59
docs/phases/phase-1.md
Normal file
59
docs/phases/phase-1.md
Normal 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
46
docs/phases/phase-2.md
Normal 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
54
docs/phases/phase-3.5.md
Normal 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
49
docs/phases/phase-3.md
Normal 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
115
docs/phases/phase-4.md
Normal 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
148
docs/phases/phase-5.md
Normal 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
183
docs/phases/phase-6.md
Normal 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
191
docs/phases/phase-7.md
Normal 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)
|
||||
- [ ] (선택) 자동 업데이트 체크 작동
|
||||
40
docs/re-findings/native-dll-patterns.md
Normal file
40
docs/re-findings/native-dll-patterns.md
Normal 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 |
|
||||
33
docs/re-findings/speakly-architecture.md
Normal file
33
docs/re-findings/speakly-architecture.md
Normal 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초 지연)
|
||||
39
docs/re-findings/text-insertion-patterns.md
Normal file
39
docs/re-findings/text-insertion-patterns.md
Normal 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 추가 가능
|
||||
55
docs/re-findings/ui-patterns.md
Normal file
55
docs/re-findings/ui-patterns.md
Normal 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 문으로 렌더링
|
||||
44
docs/re-findings/voice-pipeline-patterns.md
Normal file
44
docs/re-findings/voice-pipeline-patterns.md
Normal 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 불필요)
|
||||
Loading…
Add table
Add a link
Reference in a new issue