From e24bb8378c54e760d08a5ee5873cbc2fd97d403f Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Sun, 5 Apr 2026 01:03:03 +0900 Subject: [PATCH] =?UTF-8?q?=EC=B4=88=EA=B8=B0=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EC=A0=9D=ED=8A=B8=20=EC=84=A4=EC=A0=95:=20=ED=95=98=EB=84=A4?= =?UTF-8?q?=EC=8A=A4=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20+=20=EC=84=A4?= =?UTF-8?q?=EA=B3=84=EC=84=9C=20+=20RE=20=EB=85=B8=ED=95=98=EC=9A=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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개 문서 --- .claude/agents/electron-architect.md | 37 + .claude/agents/ui-specialist.md | 47 + .claude/agents/voice-pipeline-expert.md | 43 + .claude/settings.json | 88 + .claude/skills/debug-electron/SKILL.md | 29 + .claude/skills/implement-phase/SKILL.md | 45 + .claude/skills/review-phase/SKILL.md | 27 + .claude/skills/scaffold-component/SKILL.md | 31 + .claude/skills/test-and-commit/SKILL.md | 32 + .gitignore | 9 + CLAUDE.md | 116 ++ docs/design-refs/README.md | 26 + docs/design/00-master-architecture.md | 1329 ++++++++++++ docs/design/01-service-specifications.md | 1834 +++++++++++++++++ docs/design/02-ipc-and-types.md | 1786 ++++++++++++++++ docs/design/03-db-and-ui.md | 1131 ++++++++++ docs/design/04-verification-report.md | 392 ++++ docs/design/05-external-engine-integration.md | 1158 +++++++++++ docs/design/06-gap-analysis.md | 230 +++ docs/design/07-voicemode-patterns.md | 577 ++++++ docs/design/08-design-system.md | 1095 ++++++++++ docs/design/09-history-popup.md | 334 +++ docs/phases/phase-1.md | 59 + docs/phases/phase-2.md | 46 + docs/phases/phase-3.5.md | 54 + docs/phases/phase-3.md | 49 + docs/phases/phase-4.md | 115 ++ docs/phases/phase-5.md | 148 ++ docs/phases/phase-6.md | 183 ++ docs/phases/phase-7.md | 191 ++ docs/re-findings/native-dll-patterns.md | 40 + docs/re-findings/speakly-architecture.md | 33 + docs/re-findings/text-insertion-patterns.md | 39 + docs/re-findings/ui-patterns.md | 55 + docs/re-findings/voice-pipeline-patterns.md | 44 + 35 files changed, 11452 insertions(+) create mode 100644 .claude/agents/electron-architect.md create mode 100644 .claude/agents/ui-specialist.md create mode 100644 .claude/agents/voice-pipeline-expert.md create mode 100644 .claude/settings.json create mode 100644 .claude/skills/debug-electron/SKILL.md create mode 100644 .claude/skills/implement-phase/SKILL.md create mode 100644 .claude/skills/review-phase/SKILL.md create mode 100644 .claude/skills/scaffold-component/SKILL.md create mode 100644 .claude/skills/test-and-commit/SKILL.md create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 docs/design-refs/README.md create mode 100644 docs/design/00-master-architecture.md create mode 100644 docs/design/01-service-specifications.md create mode 100644 docs/design/02-ipc-and-types.md create mode 100644 docs/design/03-db-and-ui.md create mode 100644 docs/design/04-verification-report.md create mode 100644 docs/design/05-external-engine-integration.md create mode 100644 docs/design/06-gap-analysis.md create mode 100644 docs/design/07-voicemode-patterns.md create mode 100644 docs/design/08-design-system.md create mode 100644 docs/design/09-history-popup.md create mode 100644 docs/phases/phase-1.md create mode 100644 docs/phases/phase-2.md create mode 100644 docs/phases/phase-3.5.md create mode 100644 docs/phases/phase-3.md create mode 100644 docs/phases/phase-4.md create mode 100644 docs/phases/phase-5.md create mode 100644 docs/phases/phase-6.md create mode 100644 docs/phases/phase-7.md create mode 100644 docs/re-findings/native-dll-patterns.md create mode 100644 docs/re-findings/speakly-architecture.md create mode 100644 docs/re-findings/text-insertion-patterns.md create mode 100644 docs/re-findings/ui-patterns.md create mode 100644 docs/re-findings/voice-pipeline-patterns.md diff --git a/.claude/agents/electron-architect.md b/.claude/agents/electron-architect.md new file mode 100644 index 0000000..3e85187 --- /dev/null +++ b/.claude/agents/electron-architect.md @@ -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 diff --git a/.claude/agents/ui-specialist.md b/.claude/agents/ui-specialist.md new file mode 100644 index 0000000..c809c58 --- /dev/null +++ b/.claude/agents/ui-specialist.md @@ -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 폴백 diff --git a/.claude/agents/voice-pipeline-expert.md b/.claude/agents/voice-pipeline-expert.md new file mode 100644 index 0000000..2bf0e7b --- /dev/null +++ b/.claude/agents/voice-pipeline-expert.md @@ -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 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..8925faf --- /dev/null +++ b/.claude/settings.json @@ -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" + } + ] + } + ] + } +} diff --git a/.claude/skills/debug-electron/SKILL.md b/.claude/skills/debug-electron/SKILL.md new file mode 100644 index 0000000..720c87a --- /dev/null +++ b/.claude/skills/debug-electron/SKILL.md @@ -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 확인 diff --git a/.claude/skills/implement-phase/SKILL.md b/.claude/skills/implement-phase/SKILL.md new file mode 100644 index 0000000..356abff --- /dev/null +++ b/.claude/skills/implement-phase/SKILL.md @@ -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 "현재 상태" 업데이트됨 diff --git a/.claude/skills/review-phase/SKILL.md b/.claude/skills/review-phase/SKILL.md new file mode 100644 index 0000000..99dfd60 --- /dev/null +++ b/.claude/skills/review-phase/SKILL.md @@ -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 업데이트 제안 diff --git a/.claude/skills/scaffold-component/SKILL.md b/.claude/skills/scaffold-component/SKILL.md new file mode 100644 index 0000000..dbab035 --- /dev/null +++ b/.claude/skills/scaffold-component/SKILL.md @@ -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 diff --git a/.claude/skills/test-and-commit/SKILL.md b/.claude/skills/test-and-commit/SKILL.md new file mode 100644 index 0000000..917d4c3 --- /dev/null +++ b/.claude/skills/test-and-commit/SKILL.md @@ -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. **실패 시** + - 에러 내용 분석 + - 수정 후 재시도 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e9fc968 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +dist/ +out/ +.env +.env.* +*.log +.DS_Store +Thumbs.db +.claude/settings.local.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ad7142b --- /dev/null +++ b/CLAUDE.md @@ -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 diff --git a/docs/design-refs/README.md b/docs/design-refs/README.md new file mode 100644 index 0000000..d7e7cae --- /dev/null +++ b/docs/design-refs/README.md @@ -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/태그 +- **타이포그래피**: 모노스페이스(상태 표시) + 시스템 폰트(본문) diff --git a/docs/design/00-master-architecture.md b/docs/design/00-master-architecture.md new file mode 100644 index 0000000..ad18aad --- /dev/null +++ b/docs/design/00-master-architecture.md @@ -0,0 +1,1329 @@ +# D3RO-VOICE 마스터 아키텍처 설계서 + +> 버전: 1.0 +> 기반: Genspark Speakly 리버스엔지니어링 결과 +> 목표: 완전 로컬 AI 음성 어시스턴트 (클라우드 의존성 제로) + +--- + +## 1. 시스템 아키텍처 다이어그램 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ D3RO-VOICE Application │ +│ │ +│ ┌──────────────────────────────────────────────────────────────────┐ │ +│ │ MAIN PROCESS (Node.js) │ │ +│ │ │ │ +│ │ ┌─────────────┐ ┌──────────────┐ ┌────────────────────────┐ │ │ +│ │ │ App Lifecycle│ │ WindowManager│ │ ServiceRegistry │ │ │ +│ │ │ (index.ts) │ │ │ │ │ │ │ +│ │ │ - whenReady │ │ - MainWindow │ │ ┌──────────────────┐ │ │ │ +│ │ │ - single- │ │ - TipWindow │ │ │ VoiceModeService │ │ │ │ +│ │ │ instance │ │ - PopupWindow│ │ │ (오케스트레이터) │ │ │ │ +│ │ │ - before-quit│ │ │ │ └────────┬─────────┘ │ │ │ +│ │ │ - will-quit │ └──────┬───────┘ │ │ │ │ │ +│ │ └──────────────┘ │ │ ┌────────┴─────────┐ │ │ │ +│ │ │ │ │ AudioCapture │ │ │ │ +│ │ │ │ │ Service │ │ │ │ +│ │ ┌─────────────────┐ │ │ └────────┬─────────┘ │ │ │ +│ │ │ IPC Handlers │ │ │ │ │ │ │ +│ │ │ (ipc/*.ts) │◄────┤ │ ┌────────┴─────────┐ │ │ │ +│ │ │ │ │ │ │ LocalSTTService │ │ │ │ +│ │ │ - handle (req/ │ │ │ │ (Whisper sidecar) │ │ │ │ +│ │ │ res) │ │ │ └────────┬─────────┘ │ │ │ +│ │ │ - on (fire & │ │ │ │ │ │ │ +│ │ │ forget) │ │ │ ┌────────┴─────────┐ │ │ │ +│ │ └─────────────────┘ │ │ │ LocalLLMService │ │ │ │ +│ │ ▲ │ │ │ (Ollama REST) │ │ │ │ +│ │ │ IPC │ │ └────────┬─────────┘ │ │ │ +│ │ │ │ │ │ │ │ │ +│ │ │ │ │ ┌────────┴─────────┐ │ │ │ +│ │ │ │ │ │ LocalTTSService │ │ │ │ +│ │ │ │ │ │ (Kokoro/edge-tts)│ │ │ │ +│ │ │ │ │ └──────────────────┘ │ │ │ +│ │ │ │ │ │ │ │ +│ │ │ │ │ ┌──────────────────┐ │ │ │ +│ │ │ │ │ │ HotkeyService │ │ │ │ +│ │ │ │ │ │ (uiohook-napi) │ │ │ │ +│ │ │ │ │ └──────────────────┘ │ │ │ +│ │ │ │ │ │ │ │ +│ │ │ │ │ ┌──────────────────┐ │ │ │ +│ │ │ │ │ │ TextInsertService│ │ │ │ +│ │ │ │ │ │ (@nut-tree/nut) │ │ │ │ +│ │ │ │ │ └──────────────────┘ │ │ │ +│ │ │ │ │ │ │ │ +│ │ │ │ │ ┌──────────────────┐ │ │ │ +│ │ │ │ │ │ ConfigService │ │ │ │ +│ │ │ │ │ │ (electron-store) │ │ │ │ +│ │ │ │ │ └──────────────────┘ │ │ │ +│ │ │ │ │ │ │ │ +│ │ │ │ │ ┌──────────────────┐ │ │ │ +│ │ │ │ │ │ HistoryService │ │ │ │ +│ │ │ │ │ │ (better-sqlite3) │ │ │ │ +│ │ │ │ │ └──────────────────┘ │ │ │ +│ │ │ │ └────────────────────────┘ │ │ +│ └─────────┼────────────────────────────────────────────────────────┘ │ +│ │ │ │ +│ ┌─────────┼────────────┐ │ │ +│ │ PRELOAD (Bridge) │ │ │ +│ │ contextBridge. │ │ │ +│ │ exposeInMainWorld │ │ │ +│ │ → window.d3ro │ │ │ +│ └─────────┼────────────┘ │ │ +│ │ │ webContents.send │ +│ ┌─────────┴────────────┐ │ (main→renderer) │ +│ │ RENDERER (React 19) │◄─┘ │ +│ │ │ │ +│ │ ┌─────────────────┐ │ ┌──────────────────────────────────────┐ │ +│ │ │ Dashboard │ │ │ POPUP WINDOWS (Vanilla JS) │ │ +│ │ │ Settings │ │ │ │ │ +│ │ │ History │ │ │ ┌────────────┐ ┌────────────────┐ │ │ +│ │ │ (MUI 7 + React) │ │ │ │RecordingTip│ │ ResultPopup │ │ │ +│ │ └─────────────────┘ │ │ │(wave bars) │ │ (전사 결과) │ │ │ +│ └───────────────────────┘ │ └────────────┘ └────────────────┘ │ │ +│ └──────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ + + 외부 프로세스 (사이드카) +┌─────────────────────────────────────────────────────────────────────────┐ +│ │ +│ ┌───────────────────┐ ┌──────────────────┐ ┌─────────────────────┐ │ +│ ┌──────────────────────────────────────┐ ┌──────────────────┐ │ +│ │ STT+TTS 통합 Python Sidecar │ │ Ollama │ │ +│ │ (FastAPI HTTP 서버) │ │ (localhost:11434)│ │ +│ │ │ │ │ │ +│ │ ┌─ faster-whisper ──┐ ┌─ Kokoro ──┐ │ │ REST API │ │ +│ │ │ PCM 16kHz mono │ │ 텍스트 │ │ │ 스트리밍 응답 │ │ +│ │ │ → 텍스트 │ │ → PCM │ │ │ │ │ +│ │ └──────────────────┘ └───────────┘ │ │ │ │ +│ │ + edge-tts 폴백 (온라인) │ │ │ │ +│ └──────────────────────────────────────┘ └──────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 데이터 흐름 (녹음 → 텍스트 삽입) + +``` +[마이크] ──PCM 16kHz──► AudioCaptureService + │ + ┌─────────┴──────────┐ + │ VoiceModeService │ (오케스트레이터) + │ 상태: RECOGNIZING │ + └─────────┬──────────┘ + │ PCM 버퍼 + ▼ + ┌─────────────────────┐ + │ LocalSTTService │ ──HTTP──► faster-whisper + │ (이중 조건 플러시) │ ◄─JSON── (FastAPI sidecar) + └─────────┬──────────┘ + │ 전사 텍스트 + ▼ + ┌─────────────────────┐ + │ LocalLLMService │ ──HTTP──► Ollama + │ 다듬기/번역/명령어 │ ◄─stream── localhost:11434 + └─────────┬──────────┘ + │ 최종 텍스트 + ▼ + ┌─────────────────────┐ + │ TextInsertService │ + │ clipboard save │ + │ → set → Ctrl+V │ + │ → clipboard restore │ + └─────────────────────┘ +``` + +--- + +## 2. 서비스 목록 & 의존관계 그래프 + +### 2.1 서비스 전체 목록 + +D3RO-VOICE는 Speakly의 25개 서비스를 **15개 핵심 서비스**로 재설계한다. 클라우드 전용 서비스(Auth, Genspark, Report, UserInfo 등)를 제거하고, 로컬 전용 서비스(LocalSTT, LLM, TTS)를 추가한다. + +| # | 서비스명 | 파일 경로 | 역할 | Speakly 원본 매핑 | +|---|---------|----------|------|-------------------| +| 1 | **ConfigService** | `services/ConfigService.ts` | electron-store 기반 전역 설정 관리 | UserConfigService + DeviceConfigService | +| 2 | **I18nService** | `services/I18nService.ts` | 다국어 지원 (ko, en) | I18nService | +| 3 | **LoggerService** | `services/LoggerService.ts` | electron-log 래퍼, 카테고리별 로깅 | Logger (built-in) | +| 4 | **HistoryService** | `services/HistoryService.ts` | SQLite 기반 전사/명령 이력 저장 | HistoryService | +| 5 | **DictionaryService** | `services/DictionaryService.ts` | 사용자 사전 (커스텀 단어/약어) | DictionaryService | +| 6 | **SoundEffectService** | `services/SoundEffectService.ts` | 녹음 시작/종료/에러 효과음 재생 | SoundEffectService | +| 7 | **HotkeyService** | `services/HotkeyService.ts` | uiohook-napi 글로벌 핫키 등록/해제 | HotkeyService + HotkeyConfig + NativeService(키보드) | +| 8 | **AudioCaptureService** | `services/AudioCaptureService.ts` | 마이크 PCM 캡처 (16kHz mono) | AudioService + MicNativeService | +| 9 | **LocalSTTService** | `services/LocalSTTService.ts` | faster-whisper/whisper.cpp 사이드카 관리 | VoiceRecognitionService (WebSocket→로컬) | +| 10 | **LocalLLMService** | `services/LocalLLMService.ts` | Ollama REST API 통신 (다듬기/번역/명령) | GensparkService (클라우드→로컬) | +| 11 | **LocalTTSService** | `services/LocalTTSService.ts` | Kokoro TTS (+edge-tts 폴백) 사이드카 관리 | 신규 (Speakly에 없음) | +| 12 | **TextInsertService** | `services/TextInsertService.ts` | 클립보드+Ctrl+V 텍스트 삽입 | TextOperationStrategy + NativeService(클립보드) | +| 13 | **VoiceModeService** | `services/VoiceModeService.ts` | 전체 음성 파이프라인 오케스트레이션 | VoiceModeService | +| 14 | **CustomInstructionService** | `services/CustomInstructionService.ts` | 사용자 정의 LLM 명령어 관리 | CustomInstructionConfigService | +| 15 | **AutoLaunchService** | `services/AutoLaunchService.ts` | 시스템 시작 시 자동 실행 | AutoLaunchService | + +### 2.2 Speakly → D3RO-VOICE 서비스 매핑 (제거 항목) + +| Speakly 서비스 | D3RO-VOICE 처리 | 이유 | +|---------------|-----------------|------| +| AuthService | **제거** | 로컬 전용, 인증 불필요 | +| GensparkService | LocalLLMService로 대체 | Ollama REST API | +| VoiceRecognitionService | LocalSTTService로 대체 | WebSocket→로컬 Whisper | +| NativeService (FFI) | **제거** | koffi+DLL 대신 npm 패키지 | +| MicNativeService | AudioCaptureService에 흡수 | WASAPI→Web Audio API | +| ContextService | **제거** | 활성 앱 컨텍스트 (Phase 6+) | +| UserInfoService | **제거** | 클라우드 사용자 정보 | +| FeedbackService | **제거** | 클라우드 피드백 제출 | +| ReportService | **제거** | 클라우드 오류 보고 | +| UpdateService | **제거 (Phase 7)** | 자동 업데이트 추후 | +| PermissionService | **제거** | OS 권한 (DLL 전용) | +| DebugProvider | **제거** | 디버그 모드는 환경변수로 | +| RecordStatsService | HistoryService에 흡수 | 통계 쿼리로 대체 | + +### 2.3 의존관계 그래프 + +``` +Level 0 (의존 없음): + LoggerService + ConfigService + +Level 1 (Level 0에 의존): + I18nService → [ConfigService] + SoundEffectService → [ConfigService] + AutoLaunchService → [ConfigService] + HotkeyService → [ConfigService] + +Level 2 (Level 0~1에 의존): + HistoryService → [LoggerService] + DictionaryService → [LoggerService] + AudioCaptureService → [ConfigService, LoggerService] + LocalSTTService → [ConfigService, LoggerService] + LocalLLMService → [ConfigService, LoggerService] + LocalTTSService → [ConfigService, LoggerService] + TextInsertService → [ConfigService, LoggerService] + CustomInstructionService → [ConfigService, LoggerService] + +Level 3 (오케스트레이터): + VoiceModeService → [AudioCaptureService, LocalSTTService, LocalLLMService, + LocalTTSService, TextInsertService, HotkeyService, + HistoryService, SoundEffectService, ConfigService, + CustomInstructionService, LoggerService] +``` + +### 2.4 서비스별 이벤트 목록 + +```typescript +// LoggerService — 이벤트 없음 (동기 API) + +// ConfigService +interface ConfigServiceEvents { + 'config:changed': (key: string, value: unknown, oldValue: unknown) => void; +} + +// I18nService +interface I18nServiceEvents { + 'locale:changed': (locale: 'ko' | 'en') => void; +} + +// AudioCaptureService +interface AudioCaptureServiceEvents { + 'audio:data': (buffer: Buffer, sampleRate: number) => void; + 'audio:level': (level: number) => void; // 0.0~1.0 RMS + 'audio:started': (deviceId: string) => void; + 'audio:stopped': () => void; + 'audio:error': (error: NXError) => void; + 'audio:device-changed': (devices: AudioDevice[]) => void; +} + +// LocalSTTService +interface LocalSTTServiceEvents { + 'stt:ready': () => void; + 'stt:transcription-delta': (text: string) => void; + 'stt:transcription-complete': (result: TranscriptionResult) => void; + 'stt:error': (error: NXError) => void; + 'stt:model-loading': (progress: number) => void; +} + +// LocalLLMService +interface LocalLLMServiceEvents { + 'llm:response-delta': (text: string) => void; + 'llm:response-complete': (result: LLMResult) => void; + 'llm:error': (error: NXError) => void; +} + +// LocalTTSService +interface LocalTTSServiceEvents { + 'tts:audio-data': (buffer: Buffer) => void; + 'tts:started': () => void; + 'tts:complete': () => void; + 'tts:error': (error: NXError) => void; +} + +// HotkeyService +interface HotkeyServiceEvents { + 'hotkey:dictation-pressed': () => void; + 'hotkey:dictation-released': () => void; + 'hotkey:dictation-double-press': () => void; + 'hotkey:command-pressed': () => void; +} + +// VoiceModeService +interface VoiceModeServiceEvents { + 'voice:state-changed': (state: RecognitionState, prev: RecognitionState) => void; + 'voice:audio-state-changed': (state: AudioState) => void; + 'voice:transcription-delta': (text: string) => void; + 'voice:result': (result: VoiceResult) => void; + 'voice:error': (error: NXError) => void; + 'voice:cancelled': () => void; +} + +// TextInsertService +interface TextInsertServiceEvents { + 'text-insert:success': (text: string) => void; + 'text-insert:error': (error: NXError) => void; +} + +// SoundEffectService — 이벤트 없음 (fire-and-forget 재생) +// HistoryService — 이벤트 없음 (동기/async DB 쿼리) +// DictionaryService — 이벤트 없음 (동기/async DB 쿼리) +// CustomInstructionService — 이벤트 없음 (CRUD API) +// AutoLaunchService — 이벤트 없음 (설정 API) +``` + +--- + +## 3. 초기화 순서 + +Speakly의 22단계를 로컬 전용으로 재설계하여 **14단계**로 축소한다. + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 앱 초기화 시퀀스 (14단계) │ +├─────┬───────────────────────────────┬───────────────────────────┤ +│ 단계 │ 작업 │ 실패 시 동작 │ +├─────┼───────────────────────────────┼───────────────────────────┤ +│ 1 │ 단일 인스턴스 잠금 │ 기존 인스턴스 활성화 후 │ +│ │ app.requestSingleInstanceLock │ app.quit() │ +├─────┼───────────────────────────────┼───────────────────────────┤ +│ 2 │ LoggerService 초기화 │ console 폴백, 계속 진행 │ +│ │ electron-log 설정 │ │ +├─────┼───────────────────────────────┼───────────────────────────┤ +│ 3 │ ConfigService 초기화 │ 기본값으로 폴백, 계속 │ +│ │ electron-store 로드 │ │ +├─────┼───────────────────────────────┼───────────────────────────┤ +│ 4 │ I18nService 초기화 │ 'ko' 기본값, 계속 │ +│ │ 시스템 언어 감지 + 설정 로드 │ │ +├─────┼───────────────────────────────┼───────────────────────────┤ +│ 5 │ DB 초기화 (better-sqlite3) │ 에러 다이얼로그 → 종료 │ +│ │ 마이그레이션 실행 │ (데이터 손상 가능) │ +├─────┼───────────────────────────────┼───────────────────────────┤ +│ 6 │ HistoryService 초기화 │ 로그 경고, 계속 │ +│ │ DictionaryService 초기화 │ (기능 제한 모드) │ +├─────┼───────────────────────────────┼───────────────────────────┤ +│ 7 │ SoundEffectService 초기화 │ 무음 모드, 계속 │ +│ │ 효과음 파일 프리로드 │ │ +├─────┼───────────────────────────────┼───────────────────────────┤ +│ 8 │ CustomInstructionService │ 빈 목록, 계속 │ +│ │ 저장된 명령어 로드 │ │ +├─────┼───────────────────────────────┼───────────────────────────┤ +│ 9 │ AutoLaunchService 초기화 │ 로그 경고, 계속 │ +├─────┼───────────────────────────────┼───────────────────────────┤ +│ 10 │ createWindow (메인 윈도우) │ 치명 에러 → 종료 │ +│ │ + 팝업 윈도우 프리로드 │ │ +├─────┼───────────────────────────────┼───────────────────────────┤ +│ 11 │ HotkeyService 초기화 │ 핫키 비활성, 계속 │ +│ │ uiohook 워커 시작 │ (UI에서 수동 조작 가능) │ +├─────┼───────────────────────────────┼───────────────────────────┤ +│ 12 │ Tray 아이콘 생성 │ 트레이 없이 계속 │ +│ │ 컨텍스트 메뉴 등록 │ │ +├─────┼───────────────────────────────┼───────────────────────────┤ +│ 13 │ IPC 핸들러 일괄 등록 │ 치명 에러 → 종료 │ +│ │ handle + on 등록 │ │ +├─────┼───────────────────────────────┼───────────────────────────┤ +│ 14 │ 사이드카 헬스체크 (비동기) │ 상태 표시줄에 경고 │ +│ │ Ollama ping, Whisper 확인 │ (사용 시점에 재시도) │ +│ │ (3초 후 백그라운드 실행) │ │ +└─────┴───────────────────────────────┴───────────────────────────┘ +``` + +### 초기화 TypeScript 구현 인터페이스 + +```typescript +// src/main/bootstrap.ts + +interface BootstrapStep { + name: string; + critical: boolean; // true면 실패 시 앱 종료 + fn: () => Promise; +} + +const BOOTSTRAP_SEQUENCE: BootstrapStep[] = [ + { name: 'single-instance-lock', critical: true, fn: acquireSingleInstanceLock }, + { name: 'logger', critical: false, fn: initLoggerService }, + { name: 'config', critical: false, fn: initConfigService }, + { name: 'i18n', critical: false, fn: initI18nService }, + { name: 'database', critical: true, fn: initDatabase }, + { name: 'history-dictionary', critical: false, fn: initDataServices }, + { name: 'sound-effects', critical: false, fn: initSoundEffectService }, + { name: 'custom-instructions', critical: false, fn: initCustomInstructionService }, + { name: 'auto-launch', critical: false, fn: initAutoLaunchService }, + { name: 'create-windows', critical: true, fn: createAllWindows }, + { name: 'hotkey', critical: false, fn: initHotkeyService }, + { name: 'tray', critical: false, fn: createTray }, + { name: 'ipc-handlers', critical: true, fn: registerAllIpcHandlers }, + { name: 'sidecar-health', critical: false, fn: checkSidecars }, +]; + +async function bootstrap(): Promise { + for (const step of BOOTSTRAP_SEQUENCE) { + try { + await step.fn(); + logger.info(`[bootstrap] ${step.name} initialized`); + } catch (error) { + logger.error(`[bootstrap] ${step.name} failed:`, error); + if (step.critical) { + dialog.showErrorBox('D3RO-VOICE 초기화 실패', `${step.name}: ${error}`); + app.quit(); + return; + } + } + } +} +``` + +--- + +## 4. 종료 순서 + +Speakly의 before-quit → will-quit 패턴을 채택한다. + +``` +┌──────────────────────────────────────────────────────────────┐ +│ 종료 시퀀스 │ +│ │ +│ [사용자 종료 요청] (트레이 메뉴 or Ctrl+Q or app.quit()) │ +│ │ │ +│ ▼ │ +│ ┌─── before-quit ───────────────────────────────────────┐ │ +│ │ 1. VoiceModeService.destroy() │ │ +│ │ - 진행 중 녹음 취소 │ │ +│ │ - RecognitionState → DESTROYED │ │ +│ │ 2. HotkeyService.destroy() │ │ +│ │ - uiohook 워커 종료 │ │ +│ │ 3. AudioCaptureService.destroy() │ │ +│ │ - 마이크 스트림 해제 │ │ +│ │ 4. isQuitting = true (윈도우 close 이벤트에서 참조) │ │ +│ └───────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─── will-quit ─────────────────────────────────────────┐ │ +│ │ 5. LocalSTTService.destroy() │ │ +│ │ - Whisper 사이드카 프로세스 kill │ │ +│ │ 6. LocalTTSService.destroy() │ │ +│ │ - TTS 사이드카 프로세스 kill │ │ +│ │ 7. HistoryService.close() │ │ +│ │ - SQLite DB 정상 종료 │ │ +│ │ 8. ConfigService.flush() │ │ +│ │ - 미저장 설정 디스크 기록 │ │ +│ │ 9. LoggerService.flush() │ │ +│ │ - 로그 버퍼 플러시 │ │ +│ └───────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ [프로세스 종료] │ +└──────────────────────────────────────────────────────────────┘ +``` + +### 종료 TypeScript 인터페이스 + +```typescript +// src/main/lifecycle.ts + +interface Destroyable { + destroy(): Promise; +} + +// 종료 순서 (before-quit) +const BEFORE_QUIT_SEQUENCE: Destroyable[] = [ + voiceModeService, + hotkeyService, + audioCaptureService, +]; + +// 종료 순서 (will-quit) +const WILL_QUIT_SEQUENCE: Array<{ name: string; fn: () => Promise }> = [ + { name: 'stt-sidecar', fn: () => localSTTService.destroy() }, + { name: 'tts-sidecar', fn: () => localTTSService.destroy() }, + { name: 'database', fn: () => historyService.close() }, + { name: 'config-flush', fn: () => configService.flush() }, + { name: 'logger-flush', fn: () => loggerService.flush() }, +]; + +let isQuitting = false; + +app.on('before-quit', async (event) => { + if (isQuitting) return; + event.preventDefault(); + isQuitting = true; + + for (const service of BEFORE_QUIT_SEQUENCE) { + try { + await Promise.race([service.destroy(), timeout(3000)]); + } catch (err) { + logger.warn(`[shutdown] ${service.constructor.name} destroy timeout`); + } + } + app.quit(); +}); + +app.on('will-quit', async (event) => { + event.preventDefault(); + for (const step of WILL_QUIT_SEQUENCE) { + try { + await Promise.race([step.fn(), timeout(2000)]); + } catch (err) { + logger.warn(`[shutdown] ${step.name} cleanup failed`); + } + } + app.exit(0); +}); +``` + +--- + +## 5. 프로세스 간 통신 (IPC) 설계 + +### 5.1 IPC 채널 네임스페이스 규칙 + +``` +${feature}:${action} +``` + +| 네임스페이스 | 예시 | 설명 | +|-------------|------|------| +| `voice:` | `voice:startRecording` | 음성 파이프라인 제어 | +| `audio:` | `audio:getDevices` | 오디오 디바이스/캡처 | +| `stt:` | `stt:getStatus` | STT 엔진 상태 | +| `llm:` | `llm:process` | LLM 처리 요청 | +| `tts:` | `tts:speak` | TTS 재생 | +| `config:` | `config:get` | 설정 CRUD | +| `hotkey:` | `hotkey:setDictation` | 핫키 설정 | +| `history:` | `history:getRecent` | 이력 조회 | +| `dictionary:` | `dictionary:addWord` | 사전 관리 | +| `instruction:` | `instruction:getAll` | 커스텀 명령어 | +| `window:` | `window:showTip` | 윈도우 제어 | +| `app:` | `app:getVersion` | 앱 정보/상태 | + +### 5.2 handle vs on 사용 기준 + +```typescript +// ── handle (양방향, request/response) ── +// 사용 조건: 렌더러가 응답을 기다려야 할 때 +// 패턴: renderer → ipcRenderer.invoke() → main → return value + +ipcMain.handle('config:get', async (_event, key: string) => { + return configService.get(key); +}); + +ipcMain.handle('audio:getDevices', async () => { + return audioCaptureService.getDevices(); +}); + +ipcMain.handle('history:getRecent', async (_event, limit: number) => { + return historyService.getRecent(limit); +}); + +// ── on (단방향, fire-and-forget) ── +// 사용 조건: 렌더러가 응답을 기다릴 필요 없을 때 +// 패턴: renderer → ipcRenderer.send() → main (no return) + +ipcMain.on('voice:startRecording', (_event) => { + voiceModeService.startRecording(); +}); + +ipcMain.on('voice:stopRecording', (_event) => { + voiceModeService.stopRecording(); +}); + +ipcMain.on('voice:cancelRecording', (_event) => { + voiceModeService.cancel(); +}); + +// ── webContents.send (main → renderer, 단방향 푸시) ── +// 사용 조건: main에서 렌더러로 상태/데이터 푸시할 때 +// 패턴: main → webContents.send(channel, data) + +mainWindow.webContents.send('voice:state-changed', newState); +mainWindow.webContents.send('voice:transcription-delta', text); +mainWindow.webContents.send('audio:level', rmsLevel); +``` + +### 5.3 오디오 데이터 전송 방식 + +오디오 데이터는 main 프로세스 내부에서만 흐르며, IPC를 통해 렌더러로 전송하지 않는다. 렌더러에는 오디오 레벨(RMS)만 전송한다. + +``` +Main Process 내부: + AudioCaptureService ──Buffer──► VoiceModeService ──Buffer──► LocalSTTService + │ + (HTTP multipart POST) + │ + faster-whisper (FastAPI) + +렌더러로 전송되는 것: + - audio:level (number, 0.0~1.0) — 100ms 간격, fire-and-forget + - voice:state-changed (RecognitionState) — 상태 변경 시 + - voice:transcription-delta (string) — 전사 중간 결과 +``` + +### 5.4 IPC 타입 안전성 + +```typescript +// src/shared/ipc-channels.ts + +// 채널 정의: 타입 안전한 IPC 인터페이스 +export interface IpcChannelMap { + // handle (양방향) + 'config:get': { args: [key: string]; return: unknown }; + 'config:set': { args: [key: string, value: unknown]; return: void }; + 'config:getAll': { args: []; return: Record }; + 'audio:getDevices': { args: []; return: AudioDevice[] }; + 'audio:getSelectedDevice': { args: []; return: string | null }; + 'stt:getStatus': { args: []; return: STTStatus }; + 'stt:getModels': { args: []; return: STTModel[] }; + 'llm:getModels': { args: []; return: LLMModel[] }; + 'llm:getStatus': { args: []; return: LLMStatus }; + 'history:getRecent': { args: [limit: number]; return: HistoryEntry[] }; + 'history:getStats': { args: []; return: HistoryStats }; + 'dictionary:getAll': { args: []; return: DictionaryEntry[] }; + 'dictionary:addWord': { args: [word: string, replacement: string]; return: void }; + 'dictionary:removeWord': { args: [id: number]; return: void }; + 'instruction:getAll': { args: []; return: CustomInstruction[] }; + 'instruction:save': { args: [instruction: CustomInstruction]; return: void }; + 'instruction:delete': { args: [id: string]; return: void }; + 'hotkey:getDictationShortcut': { args: []; return: HotkeyBinding }; + 'hotkey:setDictationShortcut': { args: [binding: HotkeyBinding]; return: void }; + 'app:getVersion': { args: []; return: string }; + 'app:getPlatform': { args: []; return: NodeJS.Platform }; + + // on (단방향 renderer→main) + 'voice:startRecording': { args: []; return: void }; + 'voice:stopRecording': { args: []; return: void }; + 'voice:cancelRecording': { args: []; return: void }; + 'audio:setDevice': { args: [deviceId: string]; return: void }; + + // send (단방향 main→renderer) + 'voice:state-changed': { args: [state: RecognitionState]; return: void }; + 'voice:audio-state-changed': { args: [state: AudioState]; return: void }; + 'voice:transcription-delta': { args: [text: string]; return: void }; + 'voice:result': { args: [result: VoiceResult]; return: void }; + 'voice:error': { args: [error: SerializedNXError]; return: void }; + 'voice:cancelled': { args: []; return: void }; + 'audio:level': { args: [level: number]; return: void }; + 'audio:device-changed': { args: [devices: AudioDevice[]]; return: void }; + 'stt:model-loading': { args: [progress: number]; return: void }; +} +``` + +### 5.5 Preload Bridge + +```typescript +// src/preload/index.ts + +import { contextBridge, ipcRenderer } from 'electron'; + +export interface D3roAPI { + // invoke (handle 채널) + invoke( + channel: K, + ...args: HandleChannels[K]['args'] + ): Promise; + + // send (on 채널, fire-and-forget) + send( + channel: K, + ...args: SendChannels[K]['args'] + ): void; + + // on (main→renderer 수신) + on( + channel: K, + callback: (...args: ReceiveChannels[K]['args']) => void + ): () => void; // 반환: unsubscribe 함수 +} + +contextBridge.exposeInMainWorld('d3ro', { + invoke: (channel: string, ...args: unknown[]) => + ipcRenderer.invoke(channel, ...args), + send: (channel: string, ...args: unknown[]) => + ipcRenderer.send(channel, ...args), + on: (channel: string, callback: (...args: unknown[]) => void) => { + const handler = (_event: Electron.IpcRendererEvent, ...args: unknown[]) => + callback(...args); + ipcRenderer.on(channel, handler); + return () => ipcRenderer.removeListener(channel, handler); + }, +} satisfies D3roAPI); +``` + +--- + +## 6. 에러 처리 아키텍처 + +### 6.1 NXError 패턴 (Speakly ErrorCode.js 기반) + +```typescript +// src/shared/errors.ts + +export class NXError extends Error { + constructor( + public readonly code: ErrorCode, + message: string, + public readonly cause?: Error, + ) { + super(message); + this.name = 'NXError'; + } + + /** IPC 전송용 직렬화 */ + serialize(): SerializedNXError { + return { + code: this.code, + message: this.message, + stack: this.stack, + }; + } + + /** IPC 수신 후 역직렬화 */ + static deserialize(data: SerializedNXError): NXError { + const error = new NXError(data.code, data.message); + error.stack = data.stack; + return error; + } +} + +export interface SerializedNXError { + code: ErrorCode; + message: string; + stack?: string; +} +``` + +### 6.2 에러 코드 범위 할당 + +```typescript +// src/shared/errors.ts + +export enum ErrorCode { + // ── 일반 (1000~1099) ── + UNKNOWN = 1000, + INITIALIZATION_FAILED = 1001, + SERVICE_UNAVAILABLE = 1002, + INVALID_ARGUMENT = 1003, + TIMEOUT = 1004, + PERMISSION_DENIED = 1005, + + // ── 오디오 (1100~1199) ── + AUDIO_DEVICE_NOT_FOUND = 1100, + AUDIO_CAPTURE_FAILED = 1101, + AUDIO_PERMISSION_DENIED = 1102, + AUDIO_DEVICE_BUSY = 1103, + AUDIO_TOO_SHORT = 1104, // < 700ms + + // ── STT (1200~1299) ── + STT_MODEL_NOT_FOUND = 1200, + STT_MODEL_LOAD_FAILED = 1201, + STT_SIDECAR_CRASH = 1202, + STT_SIDECAR_TIMEOUT = 1203, + STT_TRANSCRIPTION_FAILED = 1204, + STT_EMPTY_RESULT = 1205, + + // ── LLM (1300~1399) ── + LLM_SERVER_UNREACHABLE = 1300, + LLM_MODEL_NOT_FOUND = 1301, + LLM_REQUEST_FAILED = 1302, + LLM_RESPONSE_TIMEOUT = 1303, + LLM_INVALID_RESPONSE = 1304, + + // ── TTS (1400~1499) ── + TTS_ENGINE_NOT_FOUND = 1400, + TTS_SYNTHESIS_FAILED = 1401, + TTS_SIDECAR_CRASH = 1402, + + // ── 텍스트 삽입 (1500~1599) ── + TEXT_INSERT_FAILED = 1500, + TEXT_INSERT_CLIPBOARD_ERROR = 1501, + TEXT_INSERT_SIMULATE_FAILED = 1502, + + // ── 핫키 (1600~1699) ── + HOTKEY_REGISTER_FAILED = 1600, + HOTKEY_CONFLICT = 1601, + HOTKEY_UIOHOOK_INIT_FAILED = 1602, + + // ── DB (1700~1799) ── + DB_OPEN_FAILED = 1700, + DB_MIGRATION_FAILED = 1701, + DB_QUERY_FAILED = 1702, + + // ── 설정 (1800~1899) ── + CONFIG_READ_FAILED = 1800, + CONFIG_WRITE_FAILED = 1801, + CONFIG_INVALID_VALUE = 1802, + + // ── 윈도우 (1900~1999) ── + WINDOW_CREATE_FAILED = 1900, + WINDOW_NOT_FOUND = 1901, +} +``` + +### 6.3 에러 전파 경로 + +``` +서비스 내부: + 1. NXError 생성 (코드 + 메시지 + 원인) + 2. EventEmitter로 'error' 이벤트 발행 + 3. 로거에 기록 + +서비스 → IPC: + 4. ipcMain.handle 내 try/catch + 5. NXError.serialize() → IPC 응답 에러 + +IPC → 렌더러: + 6-A. handle: invoke() Promise reject → renderer catch + 6-B. send: webContents.send('voice:error', serialized) + 7. 렌더러에서 NXError.deserialize() → UI 표시 + +예외 포착 안전망: + - process.on('uncaughtException') → 로거 + 재시작 시도 + - process.on('unhandledRejection') → 로거 + 에러 표시 +``` + +--- + +## 7. 설정 관리 아키텍처 + +### 7.1 electron-store 기반 + +```typescript +// src/main/services/ConfigService.ts + +import Store from 'electron-store'; + +export interface AppConfig { + audio: AudioConfig; + hotkey: HotkeyConfig; + ui: UIConfig; + stt: STTConfig; + tts: TTSConfig; + llm: LLMConfig; +} + +export interface AudioConfig { + selectedDeviceId: string | null; + sampleRate: 16000; // Whisper 기본값, 변경 불가 + noiseSuppression: boolean; // 기본: false + echoCancellation: boolean; // 기본: false + silenceThreshold: number; // 0.0~1.0, 기본: 0.01 + minDurationMs: number; // 최소 녹음 시간, 기본: 700 +} + +export interface HotkeyConfig { + dictation: HotkeyBinding; // 기본: Right Alt + command: HotkeyBinding; // 기본: Right Alt 더블프레스 + doublePressDurationMs: number; // 기본: 300 +} + +export interface HotkeyBinding { + keycode: number; + modifiers: number[]; + label: string; // UI 표시용 (예: "Right Alt") +} + +export interface UIConfig { + locale: 'ko' | 'en'; + theme: 'light' | 'dark' | 'system'; + closeToTray: boolean; // 기본: true + showTrayIcon: boolean; // 기본: true + recordingTipPosition: 'cursor' | 'center' | 'bottom-right'; +} + +export interface STTConfig { + engine: 'faster-whisper' | 'whisper-cpp'; + modelSize: 'tiny' | 'base' | 'small' | 'medium' | 'large-v3'; + language: string; // 기본: 'ko' + vadEnabled: boolean; // 기본: true + beamSize: number; // 기본: 5 +} + +export interface TTSConfig { + engine: 'kokoro' | 'edge-tts'; + voiceId: string; + speed: number; // 0.5~2.0, 기본: 1.0 + enabled: boolean; // 기본: false +} + +export interface LLMConfig { + serverUrl: string; // 기본: 'http://localhost:11434' + model: string; // 기본: 'qwen3:4b' (권장) + temperature: number; // 0.0~2.0, 기본: 0.3 + maxTokens: number; // 기본: 2048 + timeout: number; // ms, 기본: 30000 +} + +// electron-store 기본값 +const CONFIG_DEFAULTS: AppConfig = { + audio: { + selectedDeviceId: null, + sampleRate: 16000, + noiseSuppression: false, + echoCancellation: false, + silenceThreshold: 0.01, + minDurationMs: 700, + }, + hotkey: { + dictation: { keycode: 0xA5, modifiers: [], label: 'Right Alt' }, + command: { keycode: 0xA5, modifiers: [], label: 'Right Alt (double)' }, + doublePressDurationMs: 300, + }, + ui: { + locale: 'ko', + theme: 'system', + closeToTray: true, + showTrayIcon: true, + recordingTipPosition: 'cursor', + }, + stt: { + engine: 'faster-whisper', + modelSize: 'base', + language: 'ko', + vadEnabled: true, + beamSize: 5, + }, + tts: { + engine: 'kokoro', + voiceId: '', + speed: 1.0, + enabled: false, + }, + llm: { + serverUrl: 'http://localhost:11434', + model: 'qwen3:4b', + temperature: 0.3, + maxTokens: 2048, + timeout: 30000, + }, +}; +``` + +--- + +## 8. 파일/디렉토리 완전한 트리 + +``` +D3ROVoice/ +├── package.json +├── tsconfig.json +├── tsconfig.node.json +├── vite.config.ts +├── electron.vite.config.ts +├── .eslintrc.cjs +├── .prettierrc +├── .gitignore +├── README.md +├── CLAUDE.md +│ +├── docs/ +│ ├── design/ +│ │ ├── 00-master-architecture.md ← 본 문서 +│ │ ├── 01-service-specifications.md +│ │ ├── 02-ipc-channel-spec.md +│ │ └── 03-db-ui-spec.md +│ ├── phases/ +│ │ ├── phase-1.md +│ │ ├── phase-2.md +│ │ ├── phase-3.md +│ │ ├── phase-4.md +│ │ ├── phase-5.md +│ │ ├── phase-6.md +│ │ └── phase-7.md +│ └── re-findings/ +│ ├── speakly-architecture.md +│ ├── voice-pipeline-patterns.md +│ └── native-dll-patterns.md +│ +├── resources/ +│ ├── icons/ +│ │ ├── icon.ico # 앱 아이콘 +│ │ ├── icon.png # 앱 아이콘 (PNG) +│ │ ├── tray-icon.ico # 트레이 아이콘 +│ │ └── tray-icon-active.ico # 녹음 중 트레이 아이콘 +│ └── sounds/ +│ ├── recording-start.wav +│ ├── recording-stop.wav +│ └── error.wav +│ +├── scripts/ +│ ├── download-whisper-model.ts # Whisper 모델 다운로드 스크립트 +│ └── download-kokoro-voice.ts # Kokoro 음성 다운로드 스크립트 +│ +├── src/ +│ ├── main/ +│ │ ├── index.ts # 앱 진입점 (app.whenReady) +│ │ ├── bootstrap.ts # 14단계 초기화 시퀀스 +│ │ ├── lifecycle.ts # before-quit / will-quit 정리 +│ │ │ +│ │ ├── services/ +│ │ │ ├── index.ts # 서비스 레지스트리 (싱글턴 export) +│ │ │ ├── LoggerService.ts # electron-log 래퍼 +│ │ │ ├── ConfigService.ts # electron-store 설정 관리 +│ │ │ ├── I18nService.ts # 다국어 지원 +│ │ │ ├── AudioCaptureService.ts # 마이크 PCM 캡처 +│ │ │ ├── LocalSTTService.ts # Whisper 사이드카 관리 +│ │ │ ├── LocalLLMService.ts # Ollama REST API +│ │ │ ├── LocalTTSService.ts # Kokoro TTS (+edge-tts 폴백) +│ │ │ ├── VoiceModeService.ts # 음성 파이프라인 오케스트레이터 +│ │ │ ├── HotkeyService.ts # uiohook-napi 글로벌 핫키 +│ │ │ ├── TextInsertService.ts # 클립보드+Ctrl+V 삽입 +│ │ │ ├── HistoryService.ts # SQLite 이력 관리 +│ │ │ ├── DictionaryService.ts # 사용자 사전 +│ │ │ ├── SoundEffectService.ts # 효과음 재생 +│ │ │ ├── CustomInstructionService.ts # 사용자 정의 LLM 명령어 +│ │ │ └── AutoLaunchService.ts # 시스템 시작 자동 실행 +│ │ │ +│ │ ├── windows/ +│ │ │ ├── WindowManager.ts # 윈도우 생성/관리 (프리로딩) +│ │ │ ├── MainWindow.ts # 메인 앱 윈도우 (React) +│ │ │ ├── RecordingTipWindow.ts # 녹음 상태 팝업 (Vanilla JS) +│ │ │ └── ResultPopupWindow.ts # 전사 결과 팝업 (Vanilla JS) +│ │ │ +│ │ ├── ipc/ +│ │ │ ├── index.ts # IPC 핸들러 일괄 등록 +│ │ │ ├── voice-handlers.ts # voice:* 핸들러 +│ │ │ ├── audio-handlers.ts # audio:* 핸들러 +│ │ │ ├── stt-handlers.ts # stt:* 핸들러 +│ │ │ ├── llm-handlers.ts # llm:* 핸들러 +│ │ │ ├── tts-handlers.ts # tts:* 핸들러 +│ │ │ ├── config-handlers.ts # config:* 핸들러 +│ │ │ ├── hotkey-handlers.ts # hotkey:* 핸들러 +│ │ │ ├── history-handlers.ts # history:* 핸들러 +│ │ │ ├── dictionary-handlers.ts # dictionary:* 핸들러 +│ │ │ ├── instruction-handlers.ts # instruction:* 핸들러 +│ │ │ ├── window-handlers.ts # window:* 핸들러 +│ │ │ └── app-handlers.ts # app:* 핸들러 +│ │ │ +│ │ └── db/ +│ │ ├── index.ts # better-sqlite3 초기화 + drizzle +│ │ ├── schema.ts # drizzle ORM 스키마 정의 +│ │ └── migrations/ # drizzle 마이그레이션 파일 +│ │ └── 0000_initial.sql +│ │ +│ ├── renderer/ +│ │ ├── index.html # React 앱 HTML 엔트리 +│ │ ├── main.tsx # React 앱 진입점 +│ │ ├── App.tsx # 루트 컴포넌트 (MUI Theme + Router) +│ │ ├── theme.ts # MUI 7 테마 정의 +│ │ ├── hooks/ +│ │ │ ├── useIpc.ts # window.d3ro IPC 래퍼 훅 +│ │ │ ├── useVoiceState.ts # 음성 상태 구독 훅 +│ │ │ └── useConfig.ts # 설정 읽기/쓰기 훅 +│ │ ├── components/ +│ │ │ ├── Layout.tsx # MUI Drawer(240px) + Content +│ │ │ ├── Sidebar.tsx # 네비게이션 사이드바 +│ │ │ ├── StatusBar.tsx # 하단 상태 표시 (STT/LLM/Ollama) +│ │ │ └── VoiceButton.tsx # 녹음 시작 버튼 +│ │ ├── pages/ +│ │ │ ├── DashboardPage.tsx # 통계 + 최근 세션 +│ │ │ ├── HistoryPage.tsx # 전사 이력 목록 +│ │ │ ├── DictionaryPage.tsx # 사전 관리 +│ │ │ └── InstructionsPage.tsx # 커스텀 명령어 관리 +│ │ ├── modals/ +│ │ │ ├── SettingsModal.tsx # 설정 다이얼로그 +│ │ │ └── HotkeyRecordModal.tsx # 핫키 녹화 다이얼로그 +│ │ └── popups/ +│ │ ├── recording-tip/ +│ │ │ ├── index.html # Vanilla JS HTML +│ │ │ ├── recording-tip.ts # 웨이브 바 + 상태 표시 +│ │ │ └── recording-tip.css # 스타일 +│ │ └── result-popup/ +│ │ ├── index.html # Vanilla JS HTML +│ │ ├── result-popup.ts # 결과 표시 + 복사 +│ │ └── result-popup.css # 스타일 +│ │ +│ ├── preload/ +│ │ ├── index.ts # contextBridge (window.d3ro) +│ │ └── popup-preload.ts # 팝업 윈도우용 최소 preload +│ │ +│ └── shared/ +│ ├── ipc-channels.ts # IPC 채널 타입 정의 (IpcChannelMap) +│ ├── types.ts # 공유 타입 정의 +│ ├── errors.ts # NXError + ErrorCode enum +│ └── constants.ts # 공유 상수 (타이밍 등) +│ +├── tests/ +│ ├── setup.ts # vitest 글로벌 셋업 +│ ├── main/ +│ │ ├── services/ +│ │ │ ├── ConfigService.test.ts +│ │ │ ├── AudioCaptureService.test.ts +│ │ │ ├── LocalSTTService.test.ts +│ │ │ ├── VoiceModeService.test.ts +│ │ │ ├── HotkeyService.test.ts +│ │ │ ├── TextInsertService.test.ts +│ │ │ └── HistoryService.test.ts +│ │ └── ipc/ +│ │ └── handlers.test.ts +│ └── renderer/ +│ ├── components/ +│ │ └── Layout.test.tsx +│ └── hooks/ +│ └── useVoiceState.test.ts +│ +└── sidecar/ + └── whisper/ + ├── requirements.txt # faster-whisper 의존성 + └── server.py # Whisper stdin/stdout 서버 +``` + +--- + +## 부록 A: 공유 타입 정의 + +```typescript +// src/shared/types.ts + +/** 음성 인식 상태 머신 (Speakly 패턴) */ +export enum RecognitionState { + IDLE = 'IDLE', + PREPARING = 'PREPARING', // 오디오 장치 초기화 중 + READY = 'READY', // STT 모델 로딩 완료, 녹음 대기 + RECOGNIZING = 'RECOGNIZING', // 녹음 + 전사 진행 중 + PROCESSING = 'PROCESSING', // LLM 처리 중 (옵션) + COMPLETED = 'COMPLETED', // 전사/처리 완료 + CANCELLED = 'CANCELLED', // 사용자 취소 + ERROR = 'ERROR', // 에러 발생 + DESTROYED = 'DESTROYED', // 서비스 종료됨 +} + +/** 오디오 캡처 상태 (RecognitionState와 별도 추적) */ +export enum AudioState { + IDLE = 'IDLE', + INITIALIZING = 'INITIALIZING', + STREAMING = 'STREAMING', + STOPPED = 'STOPPED', +} + +/** 음성 모드 */ +export enum VoiceMode { + DICTATION = 'dictation', // hold-to-talk: 키 누르는 동안 녹음 + HANDS_FREE = 'hands-free', // toggle: 키 한번 누르면 시작, 다시 누르면 종료 +} + +/** 오디오 디바이스 */ +export interface AudioDevice { + deviceId: string; + label: string; + isDefault: boolean; +} + +/** 전사 결과 */ +export interface TranscriptionResult { + text: string; + language: string; + duration: number; // 오디오 길이 (ms) + segments?: TranscriptionSegment[]; +} + +export interface TranscriptionSegment { + start: number; + end: number; + text: string; + confidence: number; +} + +/** 음성 파이프라인 최종 결과 */ +export interface VoiceResult { + originalText: string; // STT 원본 전사 + processedText: string; // LLM 처리 후 (또는 원본과 동일) + mode: VoiceMode; + duration: number; + timestamp: number; + inserted: boolean; // 텍스트 삽입 성공 여부 +} + +/** LLM 처리 결과 */ +export interface LLMResult { + text: string; + model: string; + tokensUsed: number; + processingTime: number; +} + +/** STT 엔진 상태 */ +export interface STTStatus { + ready: boolean; + engine: 'faster-whisper' | 'whisper-cpp'; + modelLoaded: string | null; + sidecarPid: number | null; +} + +/** LLM 서버 상태 */ +export interface LLMStatus { + reachable: boolean; + serverUrl: string; + models: string[]; + selectedModel: string | null; +} + +/** 이력 항목 */ +export interface HistoryEntry { + id: number; + originalText: string; + processedText: string; + mode: VoiceMode; + duration: number; + createdAt: string; // ISO 8601 +} + +/** 이력 통계 */ +export interface HistoryStats { + totalSessions: number; + totalDuration: number; // ms + totalWords: number; + todaySessions: number; +} + +/** 사전 항목 */ +export interface DictionaryEntry { + id: number; + word: string; + replacement: string; + createdAt: string; +} + +/** 커스텀 명령어 */ +export interface CustomInstruction { + id: string; + name: string; + prompt: string; + shortcut?: HotkeyBinding; + enabled: boolean; +} + +/** STT 모델 정보 */ +export interface STTModel { + id: string; + name: string; + size: string; // 예: "141 MB" + downloaded: boolean; +} + +/** LLM 모델 정보 */ +export interface LLMModel { + name: string; + size: string; + modifiedAt: string; +} +``` + +--- + +## 부록 B: 공유 상수 + +```typescript +// src/shared/constants.ts + +/** 타이밍 상수 (Speakly 리버스엔지니어링 기반) */ +export const TIMING = { + /** 더블프레스 감지 간격 (ms) */ + DOUBLE_PRESS_DURATION: 300, + + /** 최소 녹음 시간 (ms) — 이하 자동 취소 */ + MIN_AUDIO_DURATION: 700, + + /** 녹음 후 STT 대기 시간 (ms) */ + POST_RECORDING_WAIT: 4000, + + /** 녹음 후 STT 대기 (버퍼 있을 때) (ms) */ + POST_RECORDING_WAIT_BUFFERED: 6000, + + /** STT 아이들 타임아웃 (ms) */ + STT_IDLE_TIMEOUT: 30000, + + /** 절대 최대 대기 시간 (ms) */ + ABSOLUTE_MAX_WAIT: 120000, + + /** 오디오 레벨 전송 간격 (ms) */ + AUDIO_LEVEL_INTERVAL: 100, + + /** 서비스 종료 타임아웃 (ms) */ + SERVICE_DESTROY_TIMEOUT: 3000, + + /** 사이드카 헬스체크 지연 (ms) */ + SIDECAR_HEALTH_DELAY: 3000, + + /** LLM 요청 타임아웃 (ms) */ + LLM_REQUEST_TIMEOUT: 30000, +} as const; + +/** 웨이브 바 상수 (RecordingTip) */ +export const WAVE_BAR = { + COUNT: 9, + ANIMATION_INTERVAL: 100, + + /** 코사인 분포 가중치 (Speakly 패턴) */ + COS_WEIGHTS: Array.from({ length: 9 }, (_, i) => + Math.cos((i - 4) * (Math.PI / 9)) + ), +} as const; + +/** 오디오 포맷 */ +export const AUDIO_FORMAT = { + SAMPLE_RATE: 16000, // Whisper 기본값 + CHANNELS: 1, // mono + BIT_DEPTH: 16, // PCM16 + BYTES_PER_SAMPLE: 2, +} as const; + +/** 윈도우 크기 */ +export const WINDOW_SIZE = { + MAIN: { width: 1104, height: 816 }, + RECORDING_TIP: { width: 280, height: 80 }, + RESULT_POPUP: { width: 400, height: 200 }, // 초기, 2-phase resize +} as const; +``` + +--- + +## 부록 C: Speakly 채택 / 변경 / 제거 요약 + +| 패턴 | Speakly | D3RO-VOICE | 상태 | +|------|---------|-----------|------| +| RecognitionState 상태 머신 | 8개 상태 | 9개 상태 (+PROCESSING) | **채택+확장** | +| AudioState 분리 추적 | O | O | **채택** | +| 이중 조건 플러시 | WebSocket+오디오 | 모델로딩+오디오 | **채택 (로컬 적용)** | +| 재연결 3계층 | WebSocket 전용 | 불필요 (로컬 사이드카) | **제거** | +| 하트비트 (5s/15s) | WebSocket | 불필요 | **제거** | +| Opus 인코딩 | 24kHz Opus 60ms | PCM 16kHz 직접 전달 | **제거** | +| NXError 패턴 | ErrorCode.js | ErrorCode enum (TypeScript) | **채택** | +| 22단계 초기화 | 클라우드 포함 | 14단계 (로컬 전용) | **채택+축소** | +| 클립보드 삽입 | NativeHelper.dll | @nut-tree/nut-js | **채택 (구현 교체)** | +| 텍스트 삽입 전략 | save→set→Ctrl+V→restore | 동일 | **채택** | +| 윈도우 프리로딩 | O | O | **채택** | +| 2-phase 리사이즈 | O | O | **채택** | +| 팝업 = Vanilla JS | O | O | **채택** | +| 메인 앱 = React+MUI | O | O (MUI 7로 업그레이드) | **채택** | +| 9개 웨이브 바 | cos 분포 가중치 | 동일 | **채택** | +| koffi FFI (DLL) | NativeHelper.dll | npm 패키지로 전체 교체 | **제거** | +| WASAPI 마이크 캡처 | C++ 코드 | Web Audio API | **제거** | +| WH_KEYBOARD_LL | DLL 후크 | uiohook-napi | **제거** | +| Win32 클립보드 | DLL API | electron clipboard | **제거** | +| Auth / Cloud 서비스 | 10+ 서비스 | 전부 제거 | **제거** | +| electron-store 설정 | 암호화 사용 | 암호화 불필요 (로컬) | **채택+간소화** | +| 단일 인스턴스 잠금 | O | O | **채택** | +| 시스템 트레이 | O | O | **채택** | +| closeToTray | O | O | **채택** | +| errorEmitted 플래그 | O | O | **채택** | +| settled boolean | O | O | **채택** | +| _isInTerminalState() 가드 | O | O | **채택** | + +--- + +*끝.* diff --git a/docs/design/01-service-specifications.md b/docs/design/01-service-specifications.md new file mode 100644 index 0000000..fbc71c3 --- /dev/null +++ b/docs/design/01-service-specifications.md @@ -0,0 +1,1834 @@ +# D3RO-VOICE 서비스 상세 명세서 + +## 목차 +1. [공통 타입](#공통-타입) +2. [AudioCaptureService](#1-audiocaptureservice) +3. [LocalSTTService](#2-localsttservice) +4. [LocalTTSService](#3-localttsservice) +5. [LocalLLMService](#4-localllmservice) +6. [VoiceModeService](#5-voicemodeservice) +7. [TextInsertService](#6-textinsertservice) +8. [HotkeyService](#7-hotkeyservice) +9. [ConfigService](#8-configservice) +10. [HistoryService](#9-historyservice) +11. [WindowManagerService](#10-windowmanagerservice) + +--- + +## 공통 타입 + +```typescript +/** 모든 서비스 에러에 사용되는 에러 코드 */ +type ErrorCode = + | 'AUDIO_DEVICE_NOT_FOUND' + | 'AUDIO_DEVICE_ACCESS_DENIED' + | 'AUDIO_CAPTURE_FAILED' + | 'STT_MODEL_NOT_FOUND' + | 'STT_MODEL_LOAD_FAILED' + | 'STT_TRANSCRIPTION_FAILED' + | 'STT_SIDECAR_CRASH' + | 'TTS_ENGINE_NOT_FOUND' + | 'TTS_SYNTHESIS_FAILED' + | 'LLM_CONNECTION_FAILED' + | 'LLM_MODEL_NOT_FOUND' + | 'LLM_GENERATION_FAILED' + | 'HOTKEY_REGISTER_FAILED' + | 'TEXT_INSERT_FAILED' + | 'CLIPBOARD_ACCESS_FAILED' + | 'CONFIG_READ_FAILED' + | 'CONFIG_WRITE_FAILED' + | 'DB_QUERY_FAILED' + | 'WINDOW_CREATE_FAILED'; + +/** 서비스 에러 */ +interface ServiceError { + readonly code: ErrorCode; + readonly message: string; + readonly cause?: unknown; +} + +/** Disposable 패턴 — 모든 서비스가 구현 */ +interface Disposable { + dispose(): void; +} +``` + +--- + +## 1. AudioCaptureService + +마이크 입력을 캡처하여 PCM16 오디오 데이터를 스트리밍한다. +싱글톤 + EventEmitter 패턴. Speakly MicNativeService의 구독/레퍼런스 카운팅 참조. + +### 타입 정의 + +```typescript +/** 오디오 포맷 상수 */ +const AUDIO_FORMAT = { + sampleRate: 16_000, // 16kHz (Whisper 기본) + channels: 1, // mono + bitDepth: 16, // PCM16 + frameSizeMs: 60, // 60ms 프레임 + bytesPerFrame: 1920, // 16000 * 2 * 0.06 +} as const; + +type AudioFormat = typeof AUDIO_FORMAT; + +/** 마이크 디바이스 정보 */ +interface AudioDevice { + readonly id: string; + readonly name: string; + readonly isDefault: boolean; +} + +/** 캡처 상태 */ +const enum CaptureState { + Idle = 'idle', + Starting = 'starting', + Capturing = 'capturing', + Stopping = 'stopping', + Error = 'error', +} + +/** 이벤트 페이로드 */ +interface AudioCaptureEvents { + /** PCM16 오디오 프레임 (60ms 단위) */ + 'audio-data': { buffer: Buffer; timestamp: number }; + /** RMS 기반 레벨 (0.0 ~ 1.0) */ + 'audio-level': { level: number; timestamp: number }; + /** 디바이스 변경 (핫플러그 또는 수동) */ + 'device-changed': { previous: AudioDevice | null; current: AudioDevice }; + 'started': { deviceId: string }; + 'stopped': { reason: 'manual' | 'device-lost' | 'error' }; + 'error': { error: ServiceError }; +} +``` + +### 인터페이스 + +```typescript +interface IAudioCaptureService extends Disposable { + /** 현재 캡처 상태 */ + readonly state: CaptureState; + /** 현재 사용 중인 디바이스 */ + readonly currentDevice: AudioDevice | null; + + /** + * 마이크 캡처 시작. + * deviceId 미지정 시 시스템 기본 디바이스 사용. + * 이미 캡처 중이면 무시 (레퍼런스 카운팅). + */ + start(deviceId?: string): Promise; + + /** 캡처 중지. 레퍼런스 카운트가 0이 되면 실제 중지. */ + stop(): Promise; + + /** 사용 가능한 입력 디바이스 목록 */ + getDevices(): Promise; + + /** 현재 디바이스 조회 */ + getCurrentDevice(): AudioDevice | null; + + /** 이벤트 등록 */ + on( + event: K, + listener: (payload: AudioCaptureEvents[K]) => void, + ): this; + off( + event: K, + listener: (payload: AudioCaptureEvents[K]) => void, + ): this; +} +``` + +### 상태 전이 + +``` + ┌──────────────────────────────────────┐ + │ │ + ▼ │ +IDLE ──start()──► STARTING ──success──► CAPTURING + ▲ │ │ + │ │ fail │ stop() / device-lost + │ ▼ ▼ + │ ERROR STOPPING + │ │ │ + │ └────dispose()───────┘ + │ │ + └───────────────────────────────────────┘ +``` + +### 디바이스 관리 + +- 시스템 디바이스 목록 변경 감지 (폴링 2초 간격) +- 현재 디바이스 분리 시: 기본 디바이스로 자동 폴백, `device-changed` 이벤트 발생 +- 레퍼런스 카운팅: 여러 소비자가 `start()` 호출 가능, 모두 `stop()` 해야 실제 중지 + +--- + +## 2. LocalSTTService + +faster-whisper sidecar를 관리하고 오디오 버퍼를 텍스트로 변환한다. +싱글톤 + EventEmitter 패턴. Speakly VoiceRecognitionService의 상태 머신 및 이중 조건 플러시 참조. + +### 타입 정의 + +```typescript +/** STT 모델 정보 */ +interface STTModel { + readonly id: string; // 예: 'base', 'small', 'medium', 'large-v3' + readonly name: string; + readonly size: number; // 바이트 단위 + readonly language: string; // 'auto' | 'ko' | 'en' | ... + readonly downloaded: boolean; +} + +/** 전사 결과 세그먼트 */ +interface TranscriptionSegment { + readonly text: string; + readonly start: number; // 초 단위 + readonly end: number; + readonly confidence: number; // 0.0 ~ 1.0 +} + +/** 전사 결과 */ +interface TranscriptionResult { + readonly text: string; + readonly segments: TranscriptionSegment[]; + readonly language: string; + readonly duration: number; // 오디오 길이 (초) + readonly processingTime: number; // 처리 시간 (ms) +} + +/** STT 엔진 상태 */ +const enum STTState { + Uninitialized = 'uninitialized', + Loading = 'loading', + Ready = 'ready', + Transcribing = 'transcribing', + Error = 'error', +} + +/** 이벤트 페이로드 */ +interface LocalSTTEvents { + /** 실시간 부분 전사 결과 (스트리밍 모드) */ + 'transcription-delta': { text: string; isFinal: boolean }; + /** 최종 전사 완료 */ + 'transcription-complete': { result: TranscriptionResult }; + /** 모델 로딩 완료 */ + 'model-loaded': { model: STTModel; loadTimeMs: number }; + 'error': { error: ServiceError }; +} +``` + +### 인터페이스 + +```typescript +interface TranscribeOptions { + /** 언어 힌트 ('auto'이면 자동 감지) */ + language?: string; + /** 초기 프롬프트 (컨텍스트 힌트) */ + initialPrompt?: string; + /** VAD 필터 활성화 */ + vadFilter?: boolean; +} + +interface ILocalSTTService extends Disposable { + readonly state: STTState; + readonly currentModel: STTModel | null; + + /** + * Whisper sidecar 프로세스 시작 + 모델 로딩. + * 이미 로딩된 모델과 같으면 무시. + */ + initialize(modelId: string): Promise; + + /** + * 오디오 버퍼를 전사. + * PCM16 16kHz mono 포맷이어야 한다. + * 이중 조건 플러시: 모델 로딩과 오디오 버퍼링이 모두 완료되면 실행. + */ + transcribe(audioBuffer: Buffer, options?: TranscribeOptions): Promise; + + /** 다운로드된 모델 목록 조회 */ + getModels(): Promise; + + /** 현재 상태 조회 */ + getStatus(): { state: STTState; modelId: string | null; uptime: number }; + + on( + event: K, + listener: (payload: LocalSTTEvents[K]) => void, + ): this; + off( + event: K, + listener: (payload: LocalSTTEvents[K]) => void, + ): this; +} +``` + +### 상태 전이 + +``` +UNINITIALIZED ──initialize()──► LOADING ──success──► READY + ▲ │ │ + │ │ fail │ transcribe() + │ ▼ ▼ + │ ERROR TRANSCRIBING + │ │ │ + │ │ complete + │ │ │ + └───────dispose()───────────┴────────────────────┘ + │ + ──► READY +``` + +### Sidecar 통신 프로토콜 (FastAPI HTTP 서버) + +``` +Main Process STT+TTS Python Sidecar (FastAPI) + │ │ + │── spawn (port 인자) ──────────────►│ + │── GET /health (폴링) ─────────────►│ + │◄── {"status":"ready"} ──────────────│ + │ │ + │── POST /load {"model_id":"base"} ─►│ + │◄── {"status":"loaded"} ────────────│ + │ │ + │── POST /transcribe │ + │ (multipart: audio.pcm) ─────────►│ + │◄── {"text":"안녕하세요",...} ────────│ + │ │ + │── POST /tts/speak │ + │ {"text":"...", "voice":"..."} ──►│ + │◄── audio/wav (바이너리) ────────────│ + │ │ + │── POST /shutdown ─────────────────►│ + │ exit│ +``` + +STT(faster-whisper)와 TTS(Kokoro)를 단일 Python sidecar로 통합 운영한다. +Electron에서 `fetch()` API로 HTTP 통신하며, health check는 `GET /health`로 수행한다. + +### 이중 조건 플러시 패턴 + +```typescript +// Speakly에서 차용한 핵심 패턴 +// 모델 로딩과 오디오 버퍼링을 동시에 진행, 둘 다 준비되면 플러시 + +private modelReady = false; +private audioBuffer: Buffer[] = []; +private pendingResolve: ((result: TranscriptionResult) => void) | null = null; + +private tryFlushAll(): void { + if (this.modelReady && this.audioBuffer.length > 0 && this.pendingResolve) { + const merged = Buffer.concat(this.audioBuffer); + this.audioBuffer = []; + this.sendToSidecar(merged).then(this.pendingResolve); + this.pendingResolve = null; + } +} +``` + +--- + +## 3. LocalTTSService + +Kokoro TTS를 기본 엔진으로 사용하고, edge-tts를 온라인 폴백으로 제공하여 텍스트를 음성으로 변환한다. +STT(faster-whisper)와 동일한 Python sidecar 프로세스에서 호스팅된다. + +### 타입 정의 + +```typescript +/** TTS 음성 정보 */ +interface TTSVoice { + readonly id: string; // 예: 'kf_default' (Kokoro), 'ko-KR-SunHiNeural' (edge-tts) + readonly name: string; + readonly language: string; + readonly gender: 'male' | 'female' | 'neutral'; + readonly sampleRate: number; + readonly downloaded: boolean; + readonly engine: 'kokoro' | 'edge-tts'; +} + +/** TTS 옵션 */ +interface TTSOptions { + /** 음성 속도 (0.5 ~ 2.0, 기본 1.0) */ + speed?: number; + /** 출력 포맷 */ + format?: 'pcm' | 'wav'; +} + +/** TTS 상태 */ +const enum TTSState { + Idle = 'idle', + Loading = 'loading', + Ready = 'ready', + Speaking = 'speaking', + Error = 'error', +} + +/** 이벤트 페이로드 */ +interface LocalTTSEvents { + /** 오디오 출력 청크 (스트리밍) */ + 'audio-output': { buffer: Buffer; sampleRate: number; isFinal: boolean }; + 'started': { text: string; voiceId: string }; + 'finished': { durationMs: number }; + 'error': { error: ServiceError }; +} +``` + +### 인터페이스 + +```typescript +interface ILocalTTSService extends Disposable { + readonly state: TTSState; + + /** + * 텍스트를 음성으로 합성하여 재생. + * Kokoro (오프라인) → edge-tts (온라인 폴백) 순으로 시도. + */ + speak(text: string, voiceId?: string, options?: TTSOptions): Promise; + + /** 사용 가능한 음성 목록 */ + getVoices(): Promise; + + /** 현재 재생 중지 */ + stop(): void; + + on( + event: K, + listener: (payload: LocalTTSEvents[K]) => void, + ): this; + off( + event: K, + listener: (payload: LocalTTSEvents[K]) => void, + ): this; +} +``` + +### 상태 전이 + +``` +IDLE ──speak()──► LOADING ──voice ready──► SPEAKING ──done──► IDLE + ▲ │ │ + │ │ fail │ stop() + │ ▼ │ + │ ERROR ◄────────error────────┘ + │ │ + └──────────────────┘ +``` + +--- + +## 4. LocalLLMService + +Ollama REST API를 통해 로컬 LLM과 상호작용한다. + +### 타입 정의 + +```typescript +/** Ollama 모델 정보 */ +interface LLMModel { + readonly name: string; // 예: 'llama3.2:3b', 'mistral:7b' + readonly size: number; // 바이트 + readonly quantization: string;// 예: 'Q4_K_M' + readonly modifiedAt: string; // ISO 8601 +} + +/** 생성 옵션 */ +interface GenerateOptions { + model?: string; + temperature?: number; // 0.0 ~ 2.0, 기본 0.7 + maxTokens?: number; // 기본 2048 + topP?: number; // 0.0 ~ 1.0 + topK?: number; + systemPrompt?: string; + /** 스트리밍 여부 */ + stream?: boolean; +} + +/** 생성 결과 */ +interface GenerateResult { + readonly text: string; + readonly model: string; + readonly promptTokens: number; + readonly completionTokens: number; + readonly totalDuration: number; // ms +} + +/** LLM 상태 */ +const enum LLMState { + Unavailable = 'unavailable', // Ollama 미실행 + Available = 'available', // Ollama 실행 중 + Generating = 'generating', + Error = 'error', +} + +/** Ollama API 기본 설정 */ +const OLLAMA_CONFIG = { + baseUrl: 'http://localhost:11434', + healthEndpoint: '/api/tags', + generateEndpoint: '/api/generate', + chatEndpoint: '/api/chat', + pollIntervalMs: 5_000, // 가용성 폴링 간격 +} as const; + +/** 이벤트 페이로드 */ +interface LocalLLMEvents { + /** 스트리밍 토큰 */ + 'token': { token: string; done: boolean }; + /** 생성 완료 */ + 'complete': { result: GenerateResult }; + /** Ollama 연결 상태 변경 */ + 'availability-changed': { available: boolean }; + 'error': { error: ServiceError }; +} +``` + +### 인터페이스 + +```typescript +interface ILocalLLMService extends Disposable { + readonly state: LLMState; + + /** + * 텍스트 생성 (비스트리밍). + * Ollama /api/generate 엔드포인트 사용. + */ + generate(prompt: string, options?: GenerateOptions): Promise; + + /** + * 스트리밍 텍스트 생성. + * 'token' 이벤트를 통해 점진적으로 토큰 전달. + * AbortController로 취소 가능. + */ + stream(prompt: string, options?: Omit): AbortController; + + /** 사용 가능한 모델 목록 (Ollama에서 조회) */ + getModels(): Promise; + + /** Ollama 가용 여부 */ + isAvailable(): boolean; + + on( + event: K, + listener: (payload: LocalLLMEvents[K]) => void, + ): this; + off( + event: K, + listener: (payload: LocalLLMEvents[K]) => void, + ): this; +} +``` + +### 스트리밍 응답 처리 + +```typescript +// Ollama /api/generate 스트리밍 응답 (NDJSON) +// 각 줄이 하나의 JSON 객체: +// {"model":"llama3.2","response":" 안녕","done":false} +// {"model":"llama3.2","response":"하세요","done":false} +// {"model":"llama3.2","response":"","done":true,"total_duration":1234567890} + +// fetch + ReadableStream으로 처리: +async function* streamGenerate(prompt: string): AsyncGenerator { + const response = await fetch(`${OLLAMA_CONFIG.baseUrl}/api/generate`, { + method: 'POST', + body: JSON.stringify({ model, prompt, stream: true }), + }); + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop()!; + for (const line of lines) { + if (!line.trim()) continue; + const chunk = JSON.parse(line); + yield chunk.response; + if (chunk.done) return; + } + } +} +``` + +--- + +## 5. VoiceModeService (오케스트레이터) + +AudioCapture → STT → LLM → TextInsert 파이프라인을 오케스트레이션한다. +Speakly VoiceModeService의 전체 오케스트레이션 패턴 참조. + +### 타입 정의 + +```typescript +/** 음성 인식 상태 (Speakly RecognitionState 기반, 9개 상태) */ +const enum RecognitionState { + Idle = 'idle', + Preparing = 'preparing', // STT 모델 로딩 + Connecting = 'connecting', // sidecar/WebSocket 연결 중 + Ready = 'ready', // 모델 준비 완료, 대기 + Recognizing = 'recognizing', // 실시간 전사 중 (LLM 후처리 포함) + Completed = 'completed', + Cancelled = 'cancelled', + Error = 'error', + Destroyed = 'destroyed', // 리소스 정리 완료 (dispose 전용 최종 상태) +} + +/** 오디오 상태 (Speakly AudioState 기반) */ +const enum AudioState { + Idle = 'idle', + Initializing = 'initializing', + Streaming = 'streaming', + Stopped = 'stopped', +} + +/** 음성 모드 */ +const enum VoiceMode { + /** 누르고 있는 동안 녹음 */ + Dictation = 'dictation', + /** 토글 (한 번 누르면 시작, 다시 누르면 종료) */ + HandsFree = 'hands-free', + /** 커스텀 명령어 모드 */ + CustomInstruction = 'custom-instruction', + /** 핸즈프리 + 웨이크워드 없이 (별도 핫키) */ + HandsFreeNoWake = 'hands-free-no-wake', +} + +/** 세션 정보 */ +interface VoiceSession { + readonly id: string; + readonly mode: VoiceMode; + readonly startedAt: number; + readonly recognitionState: RecognitionState; + readonly audioState: AudioState; + readonly audioBufferDurationMs: number; + readonly transcription: string; + readonly processedText: string | null; +} + +/** LLM 후처리 명령 */ +type PostProcessCommand = + | { type: 'none' } // 원본 그대로 삽입 + | { type: 'polish'; style?: 'formal' | 'casual' } // 문체 다듬기 + | { type: 'translate'; targetLanguage: string } // 번역 + | { type: 'summarize' } // 요약 + | { type: 'custom'; prompt: string }; // 사용자 정의 + +/** 이벤트 페이로드 */ +interface VoiceModeEvents { + 'session-started': { session: VoiceSession }; + 'recognition-state-changed': { previous: RecognitionState; current: RecognitionState }; + 'audio-state-changed': { previous: AudioState; current: AudioState }; + 'transcription-update': { text: string; isFinal: boolean }; + 'processing-update': { text: string; done: boolean }; + 'session-completed': { session: VoiceSession; finalText: string }; + 'session-cancelled': { session: VoiceSession; reason: 'user' | 'timeout' | 'too-short' }; + 'audio-level': { level: number }; + 'error': { error: ServiceError; session: VoiceSession | null }; +} +``` + +### 인터페이스 + +```typescript +interface VoiceModeOptions { + mode: VoiceMode; + postProcess?: PostProcessCommand; + language?: string; + insertAfterComplete?: boolean; // 기본 true +} + +interface IVoiceModeService extends Disposable { + readonly currentSession: VoiceSession | null; + readonly isActive: boolean; + + /** 음성 세션 시작 (핫키 누를 때) */ + startSession(options: VoiceModeOptions): Promise; + + /** 음성 세션 종료 (핫키 놓을 때 / 다시 누를 때) */ + stopSession(): Promise; + + /** 세션 취소 */ + cancelSession(): void; + + on( + event: K, + listener: (payload: VoiceModeEvents[K]) => void, + ): this; + off( + event: K, + listener: (payload: VoiceModeEvents[K]) => void, + ): this; +} +``` + +### 상태 전이 다이어그램 — RecognitionState + +``` + startSession() + │ + ▼ + ┌──────── IDLE ──► PREPARING ──► CONNECTING ──connected──► READY + │ ▲ │ │ │ + │ │ │ fail │ fail │ audio started + │ │ ▼ ▼ ▼ + │ │ ERROR ◄────────────────────── RECOGNIZING ◄──┐ + │ │ │ │ │ │ + │ │ │ done/insert cancel + │ │ │ │ │ │ + │ │ │ ▼ ▼ │ + │ │ │ COMPLETED CANCELLED│ + │ │ │ │ │ + └──────────┴──────────┴──────────────────────────────┘ │ + (모든 최종 상태에서 IDLE로 복귀) │ + │ + * cancel은 PREPARING, CONNECTING, READY, RECOGNIZING에서 가능 + * destroy() 호출 시 → DESTROYED (최종 상태, 복귀 없음) +``` + +### 상태 전이 다이어그램 — AudioState + +``` + IDLE ──start──► INITIALIZING ──device ready──► STREAMING + │ │ + │ fail stop / done + ▼ ▼ + (error → STOPPED + RecognitionState.Error) │ + └──► IDLE +``` + +### 오케스트레이션 흐름 (_tryFlushAll 패턴) + +```typescript +// 이중 조건 플러시: STT 모델 로딩과 오디오 캡처를 동시에 진행 +// +// startSession() 호출 시: +// 1. sttService.initialize(modelId) ← 비동기 +// 2. audioService.start(deviceId) ← 비동기 +// 3. 오디오 도착 → audioBuffer에 적재 +// 4. 각 비동기 작업 완료 시 → _tryFlushAll() 호출 +// +// _tryFlushAll(): +// if (sttReady && audioBuffer.length > 0) { +// recognitionState = RecognitionState.Recognizing; +// sttService.transcribe(Buffer.concat(audioBuffer)); +// audioBuffer = []; +// } + +private audioBuffer: Buffer[] = []; +private sttReady = false; +private audioStarted = false; + +private tryFlushAll(): void { + if (!this.sttReady || !this.audioStarted) return; + if (this.audioBuffer.length === 0) return; + + const merged = Buffer.concat(this.audioBuffer); + this.audioBuffer = []; + this.transitionRecognition(RecognitionState.Recognizing); + this.sttService.transcribe(merged); +} +``` + +### 타이밍 상수 + +```typescript +const VOICE_TIMING = { + /** 최소 오디오 길이 — 이하 자동 취소 (Speakly: 700ms) */ + minAudioDurationMs: 700, + /** 더블프레스 감지 간격 */ + doublePressMs: 300, + /** 완료 후 아이들 타임아웃 */ + completionIdleTimeoutMs: 30_000, + /** 절대 최대 대기 */ + absoluteMaxWaitMs: 120_000, + /** 녹음 종료 후 후처리 대기 (버퍼 없으면 4초, 있으면 6초) */ + postRecordingWaitMs: 4_000, + postRecordingWaitWithBufferMs: 6_000, + /** 키 릴리스 후 정지 지연 */ + stopDelayMs: 200, + /** 음소거 지연 (사운드 이펙트 재생 후) */ + muteDelayMs: 500, + /** 언뮤트 후 효과음 재생 지연 */ + unmuteSoundDelayMs: 100, +} as const; +``` + +### accidentalPress 감지 + +키 누름 시간이 짧아 의도치 않은 입력으로 판정되면 세션을 즉시 취소한다. + +```typescript +// 키 릴리스 시점에 호출 +private checkAndMarkAccidentalPress(): boolean { + const duration = Date.now() - this.currentSession.startedAt; + if (duration < VOICE_TIMING.minAudioDurationMs) { // 700ms + this.currentSession.accidentalPress = true; + this.cancelSession(); + return true; + } + return false; +} +``` + +- 판정 기준: (1) key press duration < 700ms, (2) session lifetime < 700ms +- 세션에 `accidentalPress = true` 마킹 후 즉시 취소 +- 취소 사유: `'too-short'` + +### Audio Mute 연동 + +녹음 중 시스템 오디오를 음소거하여 마이크 피드백을 방지한다. + +```typescript +// 녹음 시작 시 시퀀스: +// 1. 시작 사운드 이펙트 재생 +// 2. MUTE_DELAY_MS(500ms) 후 시스템 오디오 음소거 +// 3. wasMutedBeforeRecording = 이전 음소거 상태 저장 +// +// 녹음 종료 시 시퀀스: +// 1. wasMutedBeforeRecording이 false일 때만 언뮤트 +// 2. UNMUTE_SOUND_DELAY_MS(100ms) 후 종료 효과음 재생 +// +// 설정: config:getMuteAudioWhenDictating으로 on/off 가능 +// 루프백 마이크 사용 시 음소거 스킵 +``` + +### Action Queue (이벤트 직렬화) + +핫키 press/release 이벤트 간 race condition을 방지하기 위해 이벤트를 큐에 넣고 순차 처리한다. + +```typescript +interface NXAction { + type: 'press' | 'release' | 'escape'; + timestamp: number; + hotkeyId: string; + hotkeyTimestamp: number; +} + +private actionQueue: NXAction[] = []; +private isProcessingActionQueue = false; + +// 핫키 이벤트 수신 시 큐에 추가 후 processQueue() 호출 +// processQueue(): while (queue.length > 0) { ... await processAction(queue.shift()) } +// isProcessingActionQueue 플래그로 동시 실행 방지 +// ESC 시 clearActionQueue()로 전체 큐 클리어 +``` + +--- + +## 6. TextInsertService + +전사된 텍스트를 현재 활성 앱에 삽입한다. +Speakly ClipboardPaste + TextOperationStrategy 참조. + +### 타입 정의 + +```typescript +/** 텍스트 삽입 전략 */ +const enum InsertMethod { + /** 클립보드 저장 → 텍스트 설정 → Ctrl+V → 클립보드 복원 */ + Clipboard = 'clipboard', + /** 키보드 타이핑 시뮬레이션 (느리지만 클립보드 비파괴) */ + Keyboard = 'keyboard', +} + +/** 클립보드 스냅샷 */ +interface ClipboardSnapshot { + readonly text: string | null; + readonly html: string | null; + readonly image: Buffer | null; + readonly rtf: string | null; + readonly hasContent: boolean; +} + +/** 삽입 결과 */ +interface InsertResult { + readonly success: boolean; + readonly method: InsertMethod; + readonly textLength: number; + readonly durationMs: number; +} + +/** 이벤트 페이로드 */ +interface TextInsertEvents { + 'insert-started': { text: string; method: InsertMethod }; + 'insert-completed': { result: InsertResult }; + 'insert-failed': { error: ServiceError; method: InsertMethod }; + 'clipboard-saved': Record; + 'clipboard-restored': Record; +} +``` + +### 인터페이스 + +```typescript +interface InsertTextOptions { + method?: InsertMethod; // 기본: Clipboard + /** Ctrl+V 후 클립보드 복원까지 대기 시간 */ + restoreDelayMs?: number; // 기본: 100 +} + +interface ITextInsertService extends Disposable { + /** + * 텍스트를 현재 활성 앱에 삽입. + * 기본 전략: clipboard save → set → Ctrl+V → restore + */ + insertText(text: string, options?: InsertTextOptions): Promise; + + /** 현재 클립보드 상태 캡처 */ + getClipboardState(): ClipboardSnapshot; + + /** 클립보드 내용 저장 (수동) */ + saveClipboard(): ClipboardSnapshot; + + /** 저장된 클립보드 내용 복원 (수동) */ + restoreClipboard(snapshot: ClipboardSnapshot): void; + + on( + event: K, + listener: (payload: TextInsertEvents[K]) => void, + ): this; + off( + event: K, + listener: (payload: TextInsertEvents[K]) => void, + ): this; +} +``` + +### 삽입 흐름 의사코드 + +```typescript +async function insertText(text: string, options: InsertTextOptions): Promise { + const method = options.method ?? InsertMethod.Clipboard; + const start = performance.now(); + + if (method === InsertMethod.Clipboard) { + // 1. 기존 클립보드 저장 + const snapshot = this.saveClipboard(); + this.emit('clipboard-saved', {}); + + try { + // 2. 클립보드에 텍스트 설정 + clipboard.writeText(text); + + // 3. Ctrl+V 시뮬레이션 (@nut-tree/nut-js) + await keyboard.pressKey(Key.LeftControl, Key.V); + await keyboard.releaseKey(Key.LeftControl, Key.V); + + // 4. 붙여넣기 완료 대기 + await sleep(options.restoreDelayMs ?? 100); + + // 5. 클립보드 복원 + this.restoreClipboard(snapshot); + this.emit('clipboard-restored', {}); + } catch (err) { + // 실패 시에도 클립보드 복원 시도 + this.restoreClipboard(snapshot); + throw err; + } + } else { + // Keyboard 전략: 한 글자씩 타이핑 + await keyboard.type(text); + } + + return { + success: true, + method, + textLength: text.length, + durationMs: performance.now() - start, + }; +} +``` + +--- + +## 7. HotkeyService + +글로벌 키보드 후킹으로 핫키를 감지한다. +uiohook-napi 기반. Speakly HotkeyConfig 및 더블프레스 감지 참조. + +### 타입 정의 + +```typescript +/** 키 코드 (uiohook-napi UiohookKey 기반) */ +type KeyCode = number; + +/** 수정자 키 */ +const enum Modifier { + Ctrl = 'ctrl', + Alt = 'alt', + Shift = 'shift', + Meta = 'meta', +} + +/** 핫키 설정 */ +interface HotkeyConfig { + readonly id: string; // 예: 'voice-dictation', 'voice-handsfree' + readonly keyCode: KeyCode; + readonly modifiers: Modifier[]; + /** true이면 누르고 있는 동안 활성, false이면 토글 */ + readonly holdMode: boolean; + /** 더블프레스 활성화 여부 */ + readonly doublePressEnabled: boolean; + /** 활성화 여부 */ + readonly enabled: boolean; +} + +/** 핫키 이벤트 타입 */ +const enum HotkeyAction { + Pressed = 'pressed', + Released = 'released', + DoublePress = 'double-press', +} + +/** 이벤트 페이로드 */ +interface HotkeyEvents { + 'hotkey-pressed': { config: HotkeyConfig; timestamp: number }; + 'hotkey-released': { config: HotkeyConfig; durationMs: number; timestamp: number }; + 'double-press': { config: HotkeyConfig; intervalMs: number; timestamp: number }; + 'error': { error: ServiceError }; +} +``` + +### 인터페이스 + +```typescript +interface IHotkeyService extends Disposable { + readonly isRunning: boolean; + readonly registeredHotkeys: ReadonlyMap; + + /** uiohook 시작 (글로벌 키보드 후킹) */ + start(): void; + + /** uiohook 중지 */ + stop(): void; + + /** 핫키 등록/갱신 */ + registerHotkey(config: HotkeyConfig): void; + + /** 핫키 해제 */ + unregisterHotkey(id: string): void; + + /** 사용 가능한 키 목록 (UI 바인딩용) */ + getAvailableKeys(): Array<{ code: KeyCode; label: string }>; + + on( + event: K, + listener: (payload: HotkeyEvents[K]) => void, + ): this; + off( + event: K, + listener: (payload: HotkeyEvents[K]) => void, + ): this; +} +``` + +### 더블프레스 감지 로직 + +```typescript +// Speakly 패턴: 300ms 이내 연속 두 번 press → 더블프레스 +// LLKHF_INJECTED 바이패스: 자체 시뮬레이션 키는 무시 + +private lastPressTime: Map = new Map(); + +private onKeyDown(hotkeyId: string, config: HotkeyConfig): void { + const now = Date.now(); + const lastPress = this.lastPressTime.get(hotkeyId) ?? 0; + + if (config.doublePressEnabled && (now - lastPress) < VOICE_TIMING.doublePressMs) { + this.emit('double-press', { + config, + intervalMs: now - lastPress, + timestamp: now, + }); + this.lastPressTime.delete(hotkeyId); + return; + } + + this.lastPressTime.set(hotkeyId, now); + this.emit('hotkey-pressed', { config, timestamp: now }); +} +``` + +### uiohook-napi 연동 + +```typescript +import { uIOhook, UiohookKey } from 'uiohook-napi'; + +// start(): +uIOhook.on('keydown', (e) => this.handleKeyEvent(e, 'down')); +uIOhook.on('keyup', (e) => this.handleKeyEvent(e, 'up')); +uIOhook.start(); + +// stop(): +uIOhook.stop(); + +// handleKeyEvent: 등록된 핫키 목록과 매칭 +// modifiers 체크: e.ctrlKey, e.altKey, e.shiftKey, e.metaKey +``` + +--- + +## 8. ConfigService + +electron-store 기반 설정 관리. 섹션별 키/값/기본값 전체 정의. + +### 타입 정의 + +```typescript +/** 오디오 설정 */ +interface AudioConfig { + /** 입력 디바이스 ID (null이면 시스템 기본) */ + inputDeviceId: string | null; + /** 입력 게인 (0.0 ~ 2.0) */ + inputGain: number; + /** 무음 감지 임계값 */ + silenceThreshold: number; +} + +/** 핫키 설정 */ +interface HotkeyConfigSection { + /** dictation 모드 핫키 */ + dictation: HotkeyConfig; + /** hands-free 모드 핫키 */ + handsFree: HotkeyConfig; +} + +/** UI 설정 */ +interface UIConfig { + /** 테마 */ + theme: 'light' | 'dark' | 'system'; + /** UI 언어 */ + language: string; + /** 트레이 아이콘 표시 */ + showTrayIcon: boolean; + /** 시작 시 최소화 */ + startMinimized: boolean; + /** 녹음 팁 표시 */ + showRecordingTip: boolean; + /** 결과 팝업 자동 닫기 (ms, 0이면 수동) */ + resultPopupAutoCloseMs: number; +} + +/** STT 설정 */ +interface STTConfig { + /** Whisper 모델 ID */ + modelId: string; + /** 기본 언어 ('auto' | 언어 코드) */ + language: string; + /** VAD 필터 활성화 */ + vadFilter: boolean; + /** 초기 프롬프트 */ + initialPrompt: string; +} + +/** TTS 설정 */ +interface TTSConfig { + /** 기본 음성 ID */ + voiceId: string; + /** 말하기 속도 (0.5 ~ 2.0) */ + speed: number; + /** TTS 활성화 여부 */ + enabled: boolean; +} + +/** LLM 설정 */ +interface LLMConfig { + /** Ollama 기본 모델 */ + defaultModel: string; + /** 기본 온도 */ + temperature: number; + /** 최대 토큰 */ + maxTokens: number; + /** 기본 후처리 명령 */ + defaultPostProcess: PostProcessCommand; + /** Ollama 서버 URL */ + serverUrl: string; +} + +/** 윈도우 설정 */ +interface WindowConfig { + /** 메인 윈도우 위치/크기 (null이면 자동) */ + mainBounds: { x: number; y: number; width: number; height: number } | null; + /** 녹음 팁 위치 */ + recordingTipPosition: 'cursor' | 'center' | 'bottom-right'; + /** 결과 팝업 위치 */ + resultPopupPosition: 'cursor' | 'center' | 'bottom-right'; +} + +/** 전체 설정 스키마 */ +interface AppConfig { + audio: AudioConfig; + hotkey: HotkeyConfigSection; + ui: UIConfig; + stt: STTConfig; + tts: TTSConfig; + llm: LLMConfig; + window: WindowConfig; +} + +/** 설정 기본값 */ +const DEFAULT_CONFIG: AppConfig = { + audio: { + inputDeviceId: null, + inputGain: 1.0, + silenceThreshold: 0.01, + }, + hotkey: { + dictation: { + id: 'voice-dictation', + keyCode: 162, // Left Ctrl + modifiers: [], + holdMode: true, + doublePressEnabled: false, + enabled: true, + }, + handsFree: { + id: 'voice-handsfree', + keyCode: 162, // Left Ctrl + modifiers: [], + holdMode: false, + doublePressEnabled: true, + enabled: true, + }, + }, + ui: { + theme: 'system', + language: 'ko', + showTrayIcon: true, + startMinimized: false, + showRecordingTip: true, + resultPopupAutoCloseMs: 5_000, + }, + stt: { + modelId: 'base', + language: 'auto', + vadFilter: true, + initialPrompt: '', + }, + tts: { + voiceId: 'ko-KR-default', + speed: 1.0, + enabled: false, + }, + llm: { + defaultModel: 'llama3.2:3b', + temperature: 0.7, + maxTokens: 2048, + defaultPostProcess: { type: 'none' }, + serverUrl: 'http://localhost:11434', + }, + window: { + mainBounds: null, + recordingTipPosition: 'cursor', + resultPopupPosition: 'center', + }, +}; + +/** 설정 변경 이벤트 */ +interface ConfigEvents { + 'config-changed': { + /** 점 표기법 경로 (예: 'audio.inputGain') */ + key: string; + oldValue: unknown; + newValue: unknown; + }; +} +``` + +### 인터페이스 + +```typescript +interface IConfigService extends Disposable { + /** + * 설정값 읽기. + * 점 표기법 지원: get('audio.inputGain') + */ + get(section: K): AppConfig[K]; + get(key: string): T; + + /** + * 설정값 쓰기. + * 점 표기법 지원: set('audio.inputGain', 1.5) + */ + set(section: K, value: AppConfig[K]): void; + set(key: string, value: unknown): void; + + /** 특정 섹션을 기본값으로 리셋 */ + reset(section: K): void; + + /** 전체 설정을 기본값으로 리셋 */ + resetAll(): void; + + /** 전체 설정 조회 (읽기 전용 복사본) */ + getAll(): Readonly; + + on( + event: K, + listener: (payload: ConfigEvents[K]) => void, + ): this; + off( + event: K, + listener: (payload: ConfigEvents[K]) => void, + ): this; +} +``` + +--- + +## 9. HistoryService + +음성 인식 히스토리를 SQLite에 저장하고 관리한다. +better-sqlite3 + drizzle-orm. Speakly HistoryService 스키마 참조. + +### 타입 정의 + +```typescript +/** 히스토리 항목 */ +interface HistoryEntry { + readonly id: number; + readonly createdAt: string; // ISO 8601 + readonly originalText: string; // STT 원본 + readonly processedText: string | null; // LLM 후처리 결과 + readonly finalText: string; // 실제 삽입된 텍스트 + readonly language: string; + readonly audioDurationMs: number; + readonly processingTimeMs: number; + readonly postProcessType: PostProcessCommand['type']; + readonly modelId: string; // 사용된 STT 모델 + readonly llmModelId: string | null;// 사용된 LLM 모델 + readonly targetApp: string | null; // 삽입된 대상 앱 + readonly deleted: boolean; // 소프트 삭제 +} + +/** 히스토리 생성 입력 */ +interface CreateHistoryInput { + originalText: string; + processedText?: string | null; + finalText: string; + language: string; + audioDurationMs: number; + processingTimeMs: number; + postProcessType: PostProcessCommand['type']; + modelId: string; + llmModelId?: string | null; + targetApp?: string | null; +} + +/** 검색 필터 */ +interface HistoryFilter { + query?: string; // 텍스트 검색 (LIKE) + language?: string; + postProcessType?: PostProcessCommand['type']; + startDate?: string; // ISO 8601 + endDate?: string; // ISO 8601 + limit?: number; // 기본 50 + offset?: number; // 기본 0 +} + +/** 통계 */ +interface HistoryStats { + readonly totalEntries: number; + readonly totalAudioDurationMs: number; + readonly totalProcessingTimeMs: number; + readonly averageProcessingTimeMs: number; + readonly entriesByLanguage: Record; + readonly entriesByPostProcess: Record; + readonly entriesLast7Days: number; + readonly entriesLast30Days: number; +} + +/** 보존 정책 */ +const RETENTION_POLICY = { + /** 보존 기간 (일) */ + retentionDays: 30, + /** 정리 실행 주기 (시간) */ + cleanupIntervalHours: 24, + /** 최대 항목 수 (0이면 무제한) */ + maxEntries: 0, +} as const; + +/** drizzle-orm 스키마 */ +// schema.ts: +// export const historyTable = sqliteTable('history', { +// id: integer('id').primaryKey({ autoIncrement: true }), +// createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`), +// originalText: text('original_text').notNull(), +// processedText: text('processed_text'), +// finalText: text('final_text').notNull(), +// language: text('language').notNull(), +// audioDurationMs: integer('audio_duration_ms').notNull(), +// processingTimeMs: integer('processing_time_ms').notNull(), +// postProcessType: text('post_process_type').notNull(), +// modelId: text('model_id').notNull(), +// llmModelId: text('llm_model_id'), +// targetApp: text('target_app'), +// deleted: integer('deleted', { mode: 'boolean' }).notNull().default(false), +// }); +``` + +### 인터페이스 + +```typescript +interface IHistoryService extends Disposable { + /** 히스토리 항목 생성 */ + create(input: CreateHistoryInput): HistoryEntry; + + /** ID로 조회 */ + getById(id: number): HistoryEntry | null; + + /** 필터 기반 목록 조회 */ + list(filter?: HistoryFilter): { entries: HistoryEntry[]; total: number }; + + /** 텍스트 검색 (LIKE '%query%') */ + search(query: string, limit?: number): HistoryEntry[]; + + /** 소프트 삭제 */ + delete(id: number): boolean; + + /** 영구 삭제 (보존 정책 정리용) */ + purge(olderThanDays: number): number; + + /** 통계 조회 */ + getStats(): HistoryStats; + + /** 보존 정책에 따라 오래된 항목 정리 (앱 시작 시 + 주기적 실행) */ + runRetentionCleanup(): number; +} +``` + +--- + +## 10. WindowManagerService + +Electron 윈도우의 생명주기, 프리로딩, 리사이즈를 관리한다. +Speakly의 8개 윈도우 관리 패턴 참조. + +### 타입 정의 + +```typescript +/** 윈도우 식별자 */ +const enum WindowId { + Main = 'main', + RecordingTip = 'recording-tip', + ResultPopup = 'result-popup', + Settings = 'settings', +} + +/** 윈도우 상태 */ +const enum WindowState { + /** 아직 생성 안 됨 */ + NotCreated = 'not-created', + /** 프리로드 중 (hidden) */ + Preloading = 'preloading', + /** 프리로드 완료, 숨김 상태 */ + Preloaded = 'preloaded', + /** 리사이징 중 (2-phase: 측정 → resize → show) */ + Resizing = 'resizing', + /** 표시 중 */ + Visible = 'visible', + /** 숨김 */ + Hidden = 'hidden', + /** 파괴됨 */ + Destroyed = 'destroyed', +} + +/** 윈도우 설정 */ +interface WindowOptions { + readonly id: WindowId; + /** HTML 파일 경로 (renderer 엔트리) */ + readonly htmlPath: string; + /** preload 스크립트 경로 */ + readonly preloadPath: string; + /** 초기 크기 */ + readonly width: number; + readonly height: number; + /** 프레임 표시 여부 */ + readonly frame: boolean; + /** 리사이즈 가능 여부 */ + readonly resizable: boolean; + /** 항상 위 (팝업용) */ + readonly alwaysOnTop: boolean; + /** 태스크바 표시 여부 */ + readonly skipTaskbar: boolean; + /** 프리로드 활성화 */ + readonly preload: boolean; + /** 투명 배경 */ + readonly transparent: boolean; +} + +/** 윈도우별 기본 옵션 */ +const WINDOW_DEFAULTS: Record = { + [WindowId.Main]: { + id: WindowId.Main, + htmlPath: 'renderer/index.html', + preloadPath: 'preload/index.js', + width: 800, + height: 600, + frame: true, + resizable: true, + alwaysOnTop: false, + skipTaskbar: false, + preload: false, + transparent: false, + }, + [WindowId.RecordingTip]: { + id: WindowId.RecordingTip, + htmlPath: 'renderer/popups/recording-tip.html', + preloadPath: 'preload/popup.js', + width: 200, + height: 80, + frame: false, + resizable: false, + alwaysOnTop: true, + skipTaskbar: true, + preload: true, + transparent: true, + }, + [WindowId.ResultPopup]: { + id: WindowId.ResultPopup, + htmlPath: 'renderer/popups/result-popup.html', + preloadPath: 'preload/popup.js', + width: 400, + height: 200, + frame: false, + resizable: false, + alwaysOnTop: true, + skipTaskbar: true, + preload: true, + transparent: true, + }, + [WindowId.Settings]: { + id: WindowId.Settings, + htmlPath: 'renderer/settings.html', + preloadPath: 'preload/index.js', + width: 700, + height: 500, + frame: true, + resizable: true, + alwaysOnTop: false, + skipTaskbar: false, + preload: false, + transparent: false, + }, +}; + +/** 윈도우 정보 (외부 노출용) */ +interface WindowInfo { + readonly id: WindowId; + readonly state: WindowState; + readonly bounds: { x: number; y: number; width: number; height: number } | null; + readonly isVisible: boolean; + readonly isFocused: boolean; +} + +/** 이벤트 페이로드 */ +interface WindowManagerEvents { + 'window-created': { id: WindowId }; + 'window-shown': { id: WindowId; bounds: WindowInfo['bounds'] }; + 'window-hidden': { id: WindowId }; + 'window-closed': { id: WindowId }; + 'window-resized': { id: WindowId; bounds: WindowInfo['bounds'] }; + 'window-focused': { id: WindowId }; + 'window-blurred': { id: WindowId }; + 'all-windows-closed': Record; +} +``` + +### 인터페이스 + +```typescript +/** 표시 위치 옵션 */ +interface ShowOptions { + /** 표시 위치 (기본: 마지막 위치 또는 화면 중앙) */ + position?: { x: number; y: number } | 'cursor' | 'center'; + /** 크기 재조정 */ + size?: { width: number; height: number }; + /** 포커스 여부 */ + focus?: boolean; +} + +interface IWindowManagerService extends Disposable { + /** + * 윈도우 생성 (또는 프리로드된 윈도우 획득). + * 이미 존재하면 기존 인스턴스 반환. + */ + getOrCreate(id: WindowId): Promise; + + /** + * 윈도우 표시. + * 2-phase 리사이즈: 콘텐츠 측정 → resize → show + * 프리로드된 윈도우는 즉시 show. + */ + show(id: WindowId, options?: ShowOptions): Promise; + + /** 윈도우 숨기기 (파괴하지 않음) */ + hide(id: WindowId): void; + + /** 윈도우 닫기 (파괴) */ + close(id: WindowId): void; + + /** 윈도우 정보 조회 */ + getInfo(id: WindowId): WindowInfo | null; + + /** 모든 윈도우 정보 */ + getAllWindows(): WindowInfo[]; + + /** + * 프리로드 시작. + * 앱 시작 시 RecordingTip, ResultPopup을 미리 생성(hidden). + */ + preloadWindows(ids: WindowId[]): Promise; + + /** + * 윈도우에 IPC 메시지 전송. + * 팝업 윈도우(Vanilla JS)에 데이터 전달용. + */ + sendToWindow(id: WindowId, channel: string, ...args: unknown[]): void; + + on( + event: K, + listener: (payload: WindowManagerEvents[K]) => void, + ): this; + off( + event: K, + listener: (payload: WindowManagerEvents[K]) => void, + ): this; +} +``` + +### 2-Phase 리사이즈 패턴 + +```typescript +// Speakly 패턴: 팝업 콘텐츠 크기에 맞춰 윈도우를 조정 +// Phase 1 — 측정: IPC로 렌더러에 콘텐츠 크기 요청 +// Phase 2 — 리사이즈 + 표시 + +async function show(id: WindowId, options?: ShowOptions): Promise { + const win = await this.getOrCreate(id); + + // Phase 1: 콘텐츠 크기 측정 (팝업 윈도우만) + if (id !== WindowId.Main && id !== WindowId.Settings) { + const contentSize = await this.measureContent(win); + win.setContentSize(contentSize.width, contentSize.height); + } + + // 위치 결정 + const position = this.resolvePosition(id, options?.position); + win.setPosition(position.x, position.y); + + // Phase 2: 표시 + win.show(); + if (options?.focus !== false) { + win.focus(); + } + + this.windowStates.set(id, WindowState.Visible); + this.emit('window-shown', { id, bounds: this.getBounds(win) }); +} +``` + +### 멀티모니터 지원 + +```typescript +// 윈도우가 화면 밖으로 나가지 않도록 보정 +function clampToScreen(bounds: Rectangle): Rectangle { + const displays = screen.getAllDisplays(); + const display = screen.getDisplayNearestPoint({ x: bounds.x, y: bounds.y }); + const { workArea } = display; + + return { + x: Math.max(workArea.x, Math.min(bounds.x, workArea.x + workArea.width - bounds.width)), + y: Math.max(workArea.y, Math.min(bounds.y, workArea.y + workArea.height - bounds.height)), + width: Math.min(bounds.width, workArea.width), + height: Math.min(bounds.height, workArea.height), + }; +} +``` + +--- + +## 11. I18nService + +다국어 지원을 담당한다. 시스템 언어를 감지하고 사용자 설정에 따라 UI 문자열을 제공한다. + +```typescript +interface II18nService extends Disposable { + readonly currentLocale: 'ko' | 'en'; + + /** 번역 문자열 조회 */ + t(key: string, params?: Record): string; + + /** 로케일 변경 */ + setLocale(locale: 'ko' | 'en'): void; + + /** 지원 로케일 목록 */ + getSupportedLocales(): Array<{ code: string; name: string }>; +} +``` + +- 의존: ConfigService (로케일 설정 저장/읽기) +- 번역 파일: `src/renderer/locales/{ko,en}.json` (JSON 키-값) +- fallback: 키가 없으면 'ko' 기본값 반환 + +--- + +## 12. LoggerService + +electron-log 래퍼. 카테고리별 로깅을 지원한다. + +```typescript +interface ILoggerService { + /** 카테고리별 로거 생성 */ + create(category: string): CategoryLogger; + + /** 로그 플러시 (종료 시) */ + flush(): Promise; +} + +interface CategoryLogger { + info(message: string, ...args: unknown[]): void; + warn(message: string, ...args: unknown[]): void; + error(message: string, ...args: unknown[]): void; + debug(message: string, ...args: unknown[]): void; +} +``` + +- 로그 파일: `{userData}/logs/d3ro-voice.log` +- 로테이션: 파일 크기 5MB 초과 시 자동 교체, 최대 3개 보관 +- 민감 정보 마스킹: 오디오 데이터, 전사 텍스트 본문은 로깅하지 않음 + +--- + +## 13. DictionaryService + +사용자 커스텀 단어 사전. STT 후처리에서 자동 교정에 사용한다. + +```typescript +interface IDictionaryService extends Disposable { + /** 사전 항목 목록 조회 */ + getAll(params?: { page: number; pageSize: number }): Promise; + + /** 항목 추가 */ + add(word: string, pronunciation?: string, category?: string): Promise; + + /** 항목 삭제 */ + delete(id: string): Promise; + + /** 항목 수정 */ + update(id: string, data: Partial): Promise; + + /** 텍스트에 사전 교정 적용 */ + applyCorrections(text: string): string; + + /** 가져오기/내보내기 */ + importFromFile(filePath: string, format: 'json' | 'csv'): Promise<{ imported: number; skipped: number }>; + exportToFile(format: 'json' | 'csv'): Promise; +} +``` + +- 의존: LoggerService, DB (better-sqlite3) +- DB 테이블: `dictionary` (03-db-and-ui.md 참조) + +--- + +## 14. SoundEffectService + +녹음 시작/종료/에러 효과음을 재생한다. + +```typescript +interface ISoundEffectService extends Disposable { + /** 효과음 재생 (fire-and-forget) */ + play(sound: 'recording-start' | 'recording-stop' | 'error' | 'cancel'): void; + + /** 효과음 활성화/비활성화 */ + setEnabled(enabled: boolean): void; + isEnabled(): boolean; +} +``` + +- 의존: ConfigService (`soundEnabled` 설정) +- 오디오 파일: `resources/sounds/*.wav` +- 앱 시작 시 프리로드 (메모리 캐싱) + +--- + +## 15. CustomInstructionService + +사용자 정의 LLM 명령어를 관리한다. CRUD API를 제공한다. + +```typescript +interface CustomInstruction { + id: string; + name: string; + prompt: string; + icon?: string; + createdAt: number; + updatedAt: number; +} + +interface ICustomInstructionService extends Disposable { + getAll(): Promise; + getById(id: string): Promise; + save(instruction: Omit): Promise; + update(id: string, data: Partial): Promise; + delete(id: string): Promise; +} +``` + +- 의존: ConfigService, LoggerService +- 저장: electron-store 또는 별도 JSON 파일 (`{userData}/custom-instructions.json`) + +--- + +## 16. AutoLaunchService + +시스템 시작 시 자동 실행을 관리한다. + +```typescript +interface IAutoLaunchService extends Disposable { + /** 자동 실행 상태 조회 */ + isEnabled(): Promise; + + /** 자동 실행 활성화/비활성화 */ + setEnabled(enabled: boolean): Promise; +} +``` + +- 의존: ConfigService +- Windows: 레지스트리 `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` 또는 `app.setLoginItemSettings()` +- Electron API: `app.setLoginItemSettings({ openAtLogin: true })` + +--- + +## 서비스 간 의존 관계 + +``` +ConfigService (독립) + │ + ├──► AudioCaptureService + ├──► HotkeyService + ├──► LocalSTTService + ├──► LocalTTSService + ├──► LocalLLMService + ├──► TextInsertService + ├──► WindowManagerService + └──► HistoryService + +VoiceModeService (오케스트레이터) + ├──► AudioCaptureService + ├──► LocalSTTService + ├──► LocalLLMService + ├──► TextInsertService + ├──► HistoryService + └──► WindowManagerService (RecordingTip, ResultPopup) +``` + +모든 서비스는 ConfigService에 의존하여 설정을 읽는다. +VoiceModeService는 파이프라인 서비스들을 오케스트레이션하는 유일한 조합점이다. diff --git a/docs/design/02-ipc-and-types.md b/docs/design/02-ipc-and-types.md new file mode 100644 index 0000000..c60ef9f --- /dev/null +++ b/docs/design/02-ipc-and-types.md @@ -0,0 +1,1786 @@ +# 02. IPC 채널 & 타입 명세서 + +> D3RO-VOICE의 모든 IPC 채널, TypeScript 타입, 에러 코드, preload API를 정의한다. +> Speakly의 ~200개 IPC 채널을 참고하되, 클라우드/인증/텔레메트리를 제거하고 로컬 서비스(STT, TTS, LLM)를 추가한다. + +--- + +## 1. IPC 채널 전체 목록 + +### 방향 범례 + +| 기호 | Electron API | 설명 | +|------|-------------|------| +| `handle` | `ipcMain.handle` / `ipcRenderer.invoke` | 양방향 (요청→응답) | +| `on` | `ipcMain.on` | 단방향 Renderer → Main (fire-and-forget) | +| `send` | `webContents.send` | 단방향 Main → Renderer (푸시 이벤트) | + +--- + +### 1.1 `voice:*` -- 음성 녹음/인식 오케스트레이션 + +| 채널명 | 방향 | 파라미터 타입 | 반환 타입 | 담당 서비스 | 설명 | +|--------|------|--------------|----------|------------|------| +| `voice:startRecording` | handle | `StartRecordingParams` | `StartRecordingResult` | VoiceModeService | 녹음 세션 시작 (마이크 캡처 + STT 준비) | +| `voice:stopRecording` | handle | `StopRecordingParams` | `StopRecordingResult` | VoiceModeService | 녹음 중단 및 최종 전사 요청 | +| `voice:cancelRecording` | handle | `CancelRecordingParams` | `void` | VoiceModeService | 녹음 취소 (결과 버림) | +| `voice:getState` | handle | `void` | `VoiceState` | VoiceModeService | 현재 RecognitionState + AudioState 조회 | +| `voice:setMode` | handle | `SetVoiceModeParams` | `void` | VoiceModeService | 녹음 모드 변경 (dictation/hands-free) | +| `voice:getMode` | handle | `void` | `VoiceMode` | VoiceModeService | 현재 녹음 모드 조회 | +| `voice:stateChanged` | send | -- | `VoiceStateChangedEvent` | VoiceModeService | 상태 전이 알림 | +| `voice:transcriptionDelta` | send | -- | `TranscriptionDeltaEvent` | VoiceModeService | 중간 전사 결과 스트리밍 | +| `voice:transcriptionComplete` | send | -- | `TranscriptionCompleteEvent` | VoiceModeService | 최종 전사 결과 | +| `voice:error` | send | -- | `VoiceErrorEvent` | VoiceModeService | 음성 처리 에러 알림 | +| `voice:audioLevel` | send | -- | `AudioLevelEvent` | VoiceModeService | 오디오 레벨 (웨이브 바 애니메이션용, ~100ms 간격) | + +### 1.2 `audio:*` -- 오디오 디바이스 & 캡처 + +| 채널명 | 방향 | 파라미터 타입 | 반환 타입 | 담당 서비스 | 설명 | +|--------|------|--------------|----------|------------|------| +| `audio:getDevices` | handle | `void` | `AudioDevice[]` | AudioCaptureService | 사용 가능한 마이크 디바이스 목록 | +| `audio:getSelectedDevice` | handle | `void` | `string \| null` | AudioCaptureService | 현재 선택된 디바이스 ID | +| `audio:setSelectedDevice` | handle | `SetDeviceParams` | `void` | AudioCaptureService | 마이크 디바이스 변경 | +| `audio:testDevice` | handle | `TestDeviceParams` | `TestDeviceResult` | AudioCaptureService | 마이크 테스트 (짧은 캡처 후 레벨 반환) | +| `audio:deviceChanged` | send | -- | `AudioDeviceChangedEvent` | AudioCaptureService | 디바이스 연결/해제 알림 | + +### 1.3 `stt:*` -- 로컬 STT (Whisper) + +| 채널명 | 방향 | 파라미터 타입 | 반환 타입 | 담당 서비스 | 설명 | +|--------|------|--------------|----------|------------|------| +| `stt:getStatus` | handle | `void` | `STTStatus` | LocalSTTService | STT 엔진 상태 (모델 로딩 여부, 버전 등) | +| `stt:getModels` | handle | `void` | `STTModel[]` | LocalSTTService | 사용 가능한 Whisper 모델 목록 | +| `stt:getActiveModel` | handle | `void` | `string \| null` | LocalSTTService | 현재 활성 모델 ID | +| `stt:setModel` | handle | `SetSTTModelParams` | `void` | LocalSTTService | 사용할 Whisper 모델 변경 | +| `stt:downloadModel` | handle | `DownloadModelParams` | `void` | LocalSTTService | 모델 다운로드 시작 | +| `stt:cancelDownload` | handle | `void` | `void` | LocalSTTService | 진행 중인 모델 다운로드 취소 | +| `stt:getLanguage` | handle | `void` | `string` | LocalSTTService | STT 인식 언어 조회 | +| `stt:setLanguage` | handle | `SetSTTLanguageParams` | `void` | LocalSTTService | STT 인식 언어 변경 | +| `stt:statusChanged` | send | -- | `STTStatusChangedEvent` | LocalSTTService | STT 엔진 상태 변경 알림 | +| `stt:downloadProgress` | send | -- | `DownloadProgressEvent` | LocalSTTService | 모델 다운로드 진행률 | + +### 1.4 `tts:*` -- 로컬 TTS + +| 채널명 | 방향 | 파라미터 타입 | 반환 타입 | 담당 서비스 | 설명 | +|--------|------|--------------|----------|------------|------| +| `tts:speak` | handle | `TTSSpeakParams` | `TTSSpeakResult` | LocalTTSService | 텍스트를 음성으로 변환 및 재생 | +| `tts:stop` | handle | `void` | `void` | LocalTTSService | 현재 재생 중단 | +| `tts:getVoices` | handle | `void` | `TTSVoice[]` | LocalTTSService | 사용 가능한 음성 목록 | +| `tts:getActiveVoice` | handle | `void` | `string \| null` | LocalTTSService | 현재 활성 음성 ID | +| `tts:setVoice` | handle | `SetTTSVoiceParams` | `void` | LocalTTSService | 사용할 음성 변경 | +| `tts:getStatus` | handle | `void` | `TTSStatus` | LocalTTSService | TTS 엔진 상태 | +| `tts:downloadVoice` | handle | `DownloadVoiceParams` | `void` | LocalTTSService | 음성 모델 다운로드 | +| `tts:statusChanged` | send | -- | `TTSStatusChangedEvent` | LocalTTSService | TTS 엔진 상태 변경 알림 | +| `tts:speakingStateChanged` | send | -- | `SpeakingStateChangedEvent` | LocalTTSService | 재생 시작/완료 알림 | + +### 1.5 `llm:*` -- 로컬 LLM (Ollama) + +| 채널명 | 방향 | 파라미터 타입 | 반환 타입 | 담당 서비스 | 설명 | +|--------|------|--------------|----------|------------|------| +| `llm:getStatus` | handle | `void` | `LLMStatus` | LocalLLMService | Ollama 서버 연결 상태 | +| `llm:getModels` | handle | `void` | `LLMModel[]` | LocalLLMService | 사용 가능한 모델 목록 | +| `llm:getActiveModel` | handle | `void` | `string \| null` | LocalLLMService | 현재 활성 모델 ID | +| `llm:setModel` | handle | `SetLLMModelParams` | `void` | LocalLLMService | 사용할 모델 변경 | +| `llm:process` | handle | `LLMProcessParams` | `LLMProcessResult` | LocalLLMService | 텍스트 처리 (다듬기, 번역, 요약 등) | +| `llm:cancelProcess` | handle | `void` | `void` | LocalLLMService | 진행 중인 LLM 처리 취소 | +| `llm:getServerUrl` | handle | `void` | `string` | LocalLLMService | Ollama 서버 URL 조회 | +| `llm:setServerUrl` | handle | `SetServerUrlParams` | `void` | LocalLLMService | Ollama 서버 URL 변경 | +| `llm:pullModel` | handle | `PullModelParams` | `void` | LocalLLMService | Ollama 모델 pull 시작 | +| `llm:statusChanged` | send | -- | `LLMStatusChangedEvent` | LocalLLMService | Ollama 연결 상태 변경 알림 | +| `llm:processProgress` | send | -- | `LLMProcessProgressEvent` | LocalLLMService | LLM 처리 스트리밍 (토큰 단위) | +| `llm:pullProgress` | send | -- | `LLMPullProgressEvent` | LocalLLMService | 모델 pull 진행률 | + +### 1.6 `hotkey:*` -- 핫키 설정 + +| 채널명 | 방향 | 파라미터 타입 | 반환 타입 | 담당 서비스 | 설명 | +|--------|------|--------------|----------|------------|------| +| `hotkey:getDictationShortcut` | handle | `void` | `HotkeyBinding` | HotkeyService | 받아쓰기 핫키 조회 | +| `hotkey:setDictationShortcut` | handle | `SetHotkeyParams` | `void` | HotkeyService | 받아쓰기 핫키 변경 | +| `hotkey:getHandsFreeShortcut` | handle | `void` | `HotkeyBinding` | HotkeyService | 핸즈프리 모드 핫키 조회 | +| `hotkey:setHandsFreeShortcut` | handle | `SetHotkeyParams` | `void` | HotkeyService | 핸즈프리 모드 핫키 변경 | +| `hotkey:getCommandShortcut` | handle | `void` | `HotkeyBinding` | HotkeyService | 명령 모드 핫키 조회 | +| `hotkey:setCommandShortcut` | handle | `SetHotkeyParams` | `void` | HotkeyService | 명령 모드 핫키 변경 | +| `hotkey:isEnabled` | handle | `void` | `boolean` | HotkeyService | 핫키 활성화 상태 조회 | +| `hotkey:setEnabled` | handle | `SetEnabledParams` | `void` | HotkeyService | 핫키 전역 활성화/비활성화 | +| `hotkey:startRecording` | handle | `void` | `void` | HotkeyService | 핫키 녹화 모드 시작 (설정 UI용) | +| `hotkey:stopRecording` | handle | `void` | `HotkeyBinding \| null` | HotkeyService | 핫키 녹화 모드 종료 및 결과 반환 | +| `hotkey:triggered` | send | -- | `HotkeyTriggeredEvent` | HotkeyService | 핫키 입력 감지 알림 | +| `hotkey:recordingResult` | send | -- | `HotkeyRecordingResultEvent` | HotkeyService | 핫키 녹화 결과 알림 | + +### 1.7 `config:*` -- 설정 관리 + +| 채널명 | 방향 | 파라미터 타입 | 반환 타입 | 담당 서비스 | 설명 | +|--------|------|--------------|----------|------------|------| +| `config:get` | handle | `ConfigGetParams` | `unknown` | ConfigService | 설정값 조회 (키 기반) | +| `config:set` | handle | `ConfigSetParams` | `void` | ConfigService | 설정값 변경 | +| `config:getAll` | handle | `void` | `AppConfig` | ConfigService | 전체 설정 조회 | +| `config:reset` | handle | `ConfigResetParams` | `void` | ConfigService | 설정값 초기화 (키 지정 또는 전체) | +| `config:getTheme` | handle | `void` | `ThemeMode` | ConfigService | 테마 조회 (light/dark/auto) | +| `config:setTheme` | handle | `SetThemeParams` | `void` | ConfigService | 테마 변경 | +| `config:getLanguage` | handle | `void` | `string` | ConfigService | UI 언어 조회 | +| `config:setLanguage` | handle | `SetLanguageParams` | `void` | ConfigService | UI 언어 변경 | +| `config:getAutoLaunch` | handle | `void` | `boolean` | ConfigService | 시작 시 자동 실행 여부 | +| `config:setAutoLaunch` | handle | `SetAutoLaunchParams` | `void` | ConfigService | 시작 시 자동 실행 설정 | +| `config:getCloseToTray` | handle | `void` | `boolean` | ConfigService | 닫기 버튼 시 트레이로 최소화 여부 | +| `config:setCloseToTray` | handle | `SetCloseToTrayParams` | `void` | ConfigService | 트레이로 최소화 설정 | +| `config:changed` | send | -- | `ConfigChangedEvent` | ConfigService | 설정 변경 알림 | + +### 1.8 `history:*` -- 히스토리 + +| 채널명 | 방향 | 파라미터 타입 | 반환 타입 | 담당 서비스 | 설명 | +|--------|------|--------------|----------|------------|------| +| `history:getAll` | handle | `HistoryQueryParams` | `HistoryPage` | HistoryService | 히스토리 목록 조회 (페이지네이션) | +| `history:getById` | handle | `HistoryGetByIdParams` | `HistoryEntry \| null` | HistoryService | 단건 히스토리 조회 | +| `history:delete` | handle | `HistoryDeleteParams` | `void` | HistoryService | 히스토리 삭제 | +| `history:deleteAll` | handle | `void` | `void` | HistoryService | 전체 히스토리 삭제 | +| `history:search` | handle | `HistorySearchParams` | `HistoryPage` | HistoryService | 히스토리 전문 검색 | +| `history:export` | handle | `HistoryExportParams` | `string` | HistoryService | 히스토리 내보내기 (파일 경로 반환) | +| `history:added` | send | -- | `HistoryEntry` | HistoryService | 새 히스토리 추가 알림 | + +### 1.9 `dictionary:*` -- 사전 + +| 채널명 | 방향 | 파라미터 타입 | 반환 타입 | 담당 서비스 | 설명 | +|--------|------|--------------|----------|------------|------| +| `dictionary:getAll` | handle | `DictionaryQueryParams` | `DictionaryPage` | DictionaryService | 사전 항목 목록 조회 | +| `dictionary:add` | handle | `DictionaryAddParams` | `DictionaryEntry` | DictionaryService | 사전 항목 추가 | +| `dictionary:update` | handle | `DictionaryUpdateParams` | `DictionaryEntry` | DictionaryService | 사전 항목 수정 | +| `dictionary:delete` | handle | `DictionaryDeleteParams` | `void` | DictionaryService | 사전 항목 삭제 | +| `dictionary:import` | handle | `DictionaryImportParams` | `DictionaryImportResult` | DictionaryService | 사전 가져오기 (CSV/JSON) | +| `dictionary:export` | handle | `DictionaryExportParams` | `string` | DictionaryService | 사전 내보내기 (파일 경로 반환) | +| `dictionary:search` | handle | `DictionarySearchParams` | `DictionaryPage` | DictionaryService | 사전 검색 | + +### 1.10 `window:*` -- 윈도우 제어 + +| 채널명 | 방향 | 파라미터 타입 | 반환 타입 | 담당 서비스 | 설명 | +|--------|------|--------------|----------|------------|------| +| `window:minimize` | on | `void` | -- | WindowManager | 메인 윈도우 최소화 | +| `window:maximize` | on | `void` | -- | WindowManager | 메인 윈도우 최대화/복원 토글 | +| `window:close` | on | `void` | -- | WindowManager | 메인 윈도우 닫기 | +| `window:isMaximized` | handle | `void` | `boolean` | WindowManager | 최대화 상태 조회 | +| `window:showRecordingTip` | on | `ShowRecordingTipParams` | -- | WindowManager | RecordingTip 팝업 표시 | +| `window:hideRecordingTip` | on | `void` | -- | WindowManager | RecordingTip 팝업 숨김 | +| `window:showResultPopup` | on | `ShowResultPopupParams` | -- | WindowManager | ResultPopup 팝업 표시 | +| `window:hideResultPopup` | on | `void` | -- | WindowManager | ResultPopup 팝업 숨김 | +| `window:tipMeasured` | on | `TipMeasuredParams` | -- | WindowManager | RecordingTip 측정 완료 (2-phase 리사이즈) | +| `window:tipStateChanged` | send | -- | `TipStateChangedEvent` | WindowManager | RecordingTip 상태 변경 전달 | +| `window:tipPrepare` | send | -- | `TipPrepareEvent` | WindowManager | RecordingTip 측정 요청 (2-phase step 1) | +| `window:tipShow` | send | -- | `TipShowEvent` | WindowManager | RecordingTip 표시 요청 (2-phase step 2) | + +### 1.11 `system:*` -- 시스템 (권한, 알림, 외부 앱) + +| 채널명 | 방향 | 파라미터 타입 | 반환 타입 | 담당 서비스 | 설명 | +|--------|------|--------------|----------|------------|------| +| `system:getPlatform` | handle | `void` | `NodeJS.Platform` | SystemService | OS 플랫폼 조회 | +| `system:getVersion` | handle | `void` | `string` | SystemService | 앱 버전 조회 | +| `system:checkMicPermission` | handle | `void` | `PermissionStatus` | PermissionService | 마이크 권한 상태 확인 | +| `system:requestMicPermission` | handle | `void` | `PermissionStatus` | PermissionService | 마이크 권한 요청 | +| `system:showNotification` | handle | `ShowNotificationParams` | `void` | SystemService | 시스템 알림 표시 | +| `system:openExternal` | handle | `OpenExternalParams` | `void` | SystemService | 외부 URL/파일 열기 | +| `system:getActiveApp` | handle | `void` | `ActiveAppInfo \| null` | SystemService | 현재 활성 앱 정보 조회 | +| `system:insertText` | handle | `InsertTextParams` | `InsertTextResult` | TextInsertService | 활성 앱에 텍스트 삽입 (클립보드 방식) | +| `system:playSound` | handle | `PlaySoundParams` | `void` | SoundEffectService | 효과음 재생 | +| `system:setSoundEnabled` | handle | `SetSoundEnabledParams` | `void` | SoundEffectService | 효과음 on/off | +| `system:isSoundEnabled` | handle | `void` | `boolean` | SoundEffectService | 효과음 활성화 상태 | + +### 1.12 `stats:*` -- 통계 + +| 채널명 | 방향 | 파라미터 타입 | 반환 타입 | 담당 서비스 | 설명 | +|--------|------|--------------|----------|------------|------| +| `stats:getSummary` | handle | `void` | `StatsSummary` | StatsService | 전체 통계 요약 (총 시간, 단어 수 등) | +| `stats:getDaily` | handle | `StatsQueryParams` | `DailyStats[]` | StatsService | 일별 통계 | +| `stats:getWeekly` | handle | `StatsQueryParams` | `WeeklyStats[]` | StatsService | 주별 통계 | +| `stats:updated` | send | -- | `StatsSummary` | StatsService | 통계 갱신 알림 | + +--- + +## 2. `shared/ipc-channels.ts` 전체 코드 + +```typescript +// src/shared/ipc-channels.ts +// IPC 채널명 중앙 정의 — 모든 채널명은 이 파일에서만 정의한다. + +export const IPC_CHANNELS = { + VOICE: { + START_RECORDING: 'voice:startRecording', + STOP_RECORDING: 'voice:stopRecording', + CANCEL_RECORDING: 'voice:cancelRecording', + GET_STATE: 'voice:getState', + SET_MODE: 'voice:setMode', + GET_MODE: 'voice:getMode', + // Main → Renderer events + STATE_CHANGED: 'voice:stateChanged', + TRANSCRIPTION_DELTA: 'voice:transcriptionDelta', + TRANSCRIPTION_COMPLETE: 'voice:transcriptionComplete', + ERROR: 'voice:error', + AUDIO_LEVEL: 'voice:audioLevel', + }, + + AUDIO: { + GET_DEVICES: 'audio:getDevices', + GET_SELECTED_DEVICE: 'audio:getSelectedDevice', + SET_SELECTED_DEVICE: 'audio:setSelectedDevice', + TEST_DEVICE: 'audio:testDevice', + // Main → Renderer events + DEVICE_CHANGED: 'audio:deviceChanged', + }, + + STT: { + GET_STATUS: 'stt:getStatus', + GET_MODELS: 'stt:getModels', + GET_ACTIVE_MODEL: 'stt:getActiveModel', + SET_MODEL: 'stt:setModel', + DOWNLOAD_MODEL: 'stt:downloadModel', + CANCEL_DOWNLOAD: 'stt:cancelDownload', + GET_LANGUAGE: 'stt:getLanguage', + SET_LANGUAGE: 'stt:setLanguage', + // Main → Renderer events + STATUS_CHANGED: 'stt:statusChanged', + DOWNLOAD_PROGRESS: 'stt:downloadProgress', + }, + + TTS: { + SPEAK: 'tts:speak', + STOP: 'tts:stop', + GET_VOICES: 'tts:getVoices', + GET_ACTIVE_VOICE: 'tts:getActiveVoice', + SET_VOICE: 'tts:setVoice', + GET_STATUS: 'tts:getStatus', + DOWNLOAD_VOICE: 'tts:downloadVoice', + // Main → Renderer events + STATUS_CHANGED: 'tts:statusChanged', + SPEAKING_STATE_CHANGED: 'tts:speakingStateChanged', + }, + + LLM: { + GET_STATUS: 'llm:getStatus', + GET_MODELS: 'llm:getModels', + GET_ACTIVE_MODEL: 'llm:getActiveModel', + SET_MODEL: 'llm:setModel', + PROCESS: 'llm:process', + CANCEL_PROCESS: 'llm:cancelProcess', + GET_SERVER_URL: 'llm:getServerUrl', + SET_SERVER_URL: 'llm:setServerUrl', + PULL_MODEL: 'llm:pullModel', + // Main → Renderer events + STATUS_CHANGED: 'llm:statusChanged', + PROCESS_PROGRESS: 'llm:processProgress', + PULL_PROGRESS: 'llm:pullProgress', + }, + + HOTKEY: { + GET_DICTATION_SHORTCUT: 'hotkey:getDictationShortcut', + SET_DICTATION_SHORTCUT: 'hotkey:setDictationShortcut', + GET_HANDS_FREE_SHORTCUT: 'hotkey:getHandsFreeShortcut', + SET_HANDS_FREE_SHORTCUT: 'hotkey:setHandsFreeShortcut', + GET_COMMAND_SHORTCUT: 'hotkey:getCommandShortcut', + SET_COMMAND_SHORTCUT: 'hotkey:setCommandShortcut', + IS_ENABLED: 'hotkey:isEnabled', + SET_ENABLED: 'hotkey:setEnabled', + START_RECORDING: 'hotkey:startRecording', + STOP_RECORDING: 'hotkey:stopRecording', + // Main → Renderer events + TRIGGERED: 'hotkey:triggered', + RECORDING_RESULT: 'hotkey:recordingResult', + }, + + CONFIG: { + GET: 'config:get', + SET: 'config:set', + GET_ALL: 'config:getAll', + RESET: 'config:reset', + GET_THEME: 'config:getTheme', + SET_THEME: 'config:setTheme', + GET_LANGUAGE: 'config:getLanguage', + SET_LANGUAGE: 'config:setLanguage', + GET_AUTO_LAUNCH: 'config:getAutoLaunch', + SET_AUTO_LAUNCH: 'config:setAutoLaunch', + GET_CLOSE_TO_TRAY: 'config:getCloseToTray', + SET_CLOSE_TO_TRAY: 'config:setCloseToTray', + // Main → Renderer events + CHANGED: 'config:changed', + }, + + HISTORY: { + GET_ALL: 'history:getAll', + GET_BY_ID: 'history:getById', + DELETE: 'history:delete', + DELETE_ALL: 'history:deleteAll', + SEARCH: 'history:search', + EXPORT: 'history:export', + // Main → Renderer events + ADDED: 'history:added', + }, + + DICTIONARY: { + GET_ALL: 'dictionary:getAll', + ADD: 'dictionary:add', + UPDATE: 'dictionary:update', + DELETE: 'dictionary:delete', + IMPORT: 'dictionary:import', + EXPORT: 'dictionary:export', + SEARCH: 'dictionary:search', + }, + + WINDOW: { + MINIMIZE: 'window:minimize', + MAXIMIZE: 'window:maximize', + CLOSE: 'window:close', + IS_MAXIMIZED: 'window:isMaximized', + SHOW_RECORDING_TIP: 'window:showRecordingTip', + HIDE_RECORDING_TIP: 'window:hideRecordingTip', + SHOW_RESULT_POPUP: 'window:showResultPopup', + HIDE_RESULT_POPUP: 'window:hideResultPopup', + TIP_MEASURED: 'window:tipMeasured', + // Main → Renderer events + TIP_STATE_CHANGED: 'window:tipStateChanged', + TIP_PREPARE: 'window:tipPrepare', + TIP_SHOW: 'window:tipShow', + }, + + SYSTEM: { + GET_PLATFORM: 'system:getPlatform', + GET_VERSION: 'system:getVersion', + CHECK_MIC_PERMISSION: 'system:checkMicPermission', + REQUEST_MIC_PERMISSION: 'system:requestMicPermission', + SHOW_NOTIFICATION: 'system:showNotification', + OPEN_EXTERNAL: 'system:openExternal', + GET_ACTIVE_APP: 'system:getActiveApp', + INSERT_TEXT: 'system:insertText', + PLAY_SOUND: 'system:playSound', + SET_SOUND_ENABLED: 'system:setSoundEnabled', + IS_SOUND_ENABLED: 'system:isSoundEnabled', + }, + + STATS: { + GET_SUMMARY: 'stats:getSummary', + GET_DAILY: 'stats:getDaily', + GET_WEEKLY: 'stats:getWeekly', + // Main → Renderer events + UPDATED: 'stats:updated', + }, +} as const; + +// 타입 유틸리티: 채널명 유니온 추출 +type NestedValues = T extends Record + ? V extends string + ? V + : NestedValues + : never; + +export type IPCChannel = NestedValues; +``` + +--- + +## 3. `shared/types.ts` 전체 코드 + +```typescript +// src/shared/types.ts +// 모든 IPC 파라미터/반환 타입 정의 + +// ============================================================ +// Common +// ============================================================ + +export type ThemeMode = 'light' | 'dark' | 'auto'; + +export type VoiceMode = 'dictation' | 'hands-free'; + +export type PermissionStatus = 'granted' | 'denied' | 'unknown'; + +// ============================================================ +// Voice (음성 오케스트레이션) +// ============================================================ + +export enum RecognitionState { + IDLE = 'idle', + PREPARING = 'preparing', + CONNECTING = 'connecting', + READY = 'ready', + RECOGNIZING = 'recognizing', + COMPLETED = 'completed', + CANCELLED = 'cancelled', + ERROR = 'error', + DESTROYED = 'destroyed', +} + +export enum AudioState { + IDLE = 'idle', + INITIALIZING = 'initializing', + STREAMING = 'streaming', + STOPPED = 'stopped', +} + +export interface VoiceState { + recognitionState: RecognitionState; + audioState: AudioState; + mode: VoiceMode; + sessionId: string | null; + /** 현재 세션 녹음 시작 시각 (ms epoch), null이면 비활성 */ + recordingStartedAt: number | null; +} + +export interface StartRecordingParams { + /** 세션 ID (자동 생성 시 생략 가능) */ + sessionId?: string; + /** 마이크 디바이스 ID (생략 시 기본 디바이스) */ + deviceId?: string; +} + +export interface StartRecordingResult { + sessionId: string; +} + +export interface StopRecordingParams { + sessionId: string; +} + +export interface StopRecordingResult { + sessionId: string; + /** 최종 전사 텍스트 (완료 전이면 빈 문자열) */ + text: string; + /** 녹음 지속 시간 (ms) */ + durationMs: number; +} + +export interface CancelRecordingParams { + sessionId: string; +} + +export interface SetVoiceModeParams { + mode: VoiceMode; +} + +// Voice events (Main → Renderer) + +export interface VoiceStateChangedEvent { + previousState: RecognitionState; + currentState: RecognitionState; + audioState: AudioState; + sessionId: string | null; +} + +export interface TranscriptionDeltaEvent { + sessionId: string; + /** 중간 전사 텍스트 (누적) */ + text: string; + /** 마지막 델타 부분 */ + delta: string; + isFinal: boolean; +} + +export interface TranscriptionCompleteEvent { + sessionId: string; + text: string; + durationMs: number; + language: string; +} + +export interface VoiceErrorEvent { + sessionId: string | null; + errorCode: number; + message: string; +} + +export interface AudioLevelEvent { + /** 0.0 ~ 1.0 정규화된 오디오 레벨 */ + level: number; +} + +// ============================================================ +// Audio (디바이스 & 캡처) +// ============================================================ + +export interface AudioDevice { + deviceId: string; + label: string; + isDefault: boolean; +} + +export interface SetDeviceParams { + deviceId: string; +} + +export interface TestDeviceParams { + deviceId: string; + /** 테스트 지속 시간 (ms), 기본 2000 */ + durationMs?: number; +} + +export interface TestDeviceResult { + /** 평균 오디오 레벨 (0.0 ~ 1.0) */ + averageLevel: number; + /** 피크 오디오 레벨 */ + peakLevel: number; + /** 오디오 데이터를 받았는지 여부 */ + hasAudio: boolean; +} + +export interface AudioDeviceChangedEvent { + devices: AudioDevice[]; + /** 변경 유형 */ + type: 'added' | 'removed' | 'default-changed'; +} + +// ============================================================ +// STT (로컬 Whisper) +// ============================================================ + +export enum STTEngineState { + NOT_INSTALLED = 'not-installed', + DOWNLOADING = 'downloading', + LOADING = 'loading', + READY = 'ready', + PROCESSING = 'processing', + ERROR = 'error', +} + +export interface STTStatus { + engineState: STTEngineState; + activeModel: string | null; + /** faster-whisper 또는 whisper.cpp 버전 */ + engineVersion: string | null; + /** GPU 가속 사용 여부 */ + gpuAccelerated: boolean; +} + +export interface STTModel { + id: string; + name: string; + /** 모델 크기 (bytes) */ + sizeBytes: number; + /** 다운로드 완료 여부 */ + downloaded: boolean; + /** 지원 언어 목록 (ISO 639-1) */ + languages: string[]; + /** 상대적 정확도 (1-5, 5가 가장 높음) */ + accuracy: number; + /** 상대적 속도 (1-5, 5가 가장 빠름) */ + speed: number; +} + +export interface SetSTTModelParams { + modelId: string; +} + +export interface DownloadModelParams { + modelId: string; +} + +export interface SetSTTLanguageParams { + /** ISO 639-1 언어 코드 (예: 'ko', 'en', 'auto') */ + language: string; +} + +export interface STTStatusChangedEvent { + status: STTStatus; +} + +export interface DownloadProgressEvent { + modelId: string; + /** 0 ~ 100 */ + percent: number; + /** 다운로드된 바이트 */ + downloadedBytes: number; + /** 전체 바이트 */ + totalBytes: number; + /** 초당 바이트 */ + bytesPerSecond: number; +} + +// ============================================================ +// TTS (로컬 TTS) +// ============================================================ + +export enum TTSEngineState { + NOT_INSTALLED = 'not-installed', + LOADING = 'loading', + READY = 'ready', + SPEAKING = 'speaking', + ERROR = 'error', +} + +export interface TTSStatus { + engineState: TTSEngineState; + activeVoice: string | null; + engineVersion: string | null; +} + +export interface TTSVoice { + id: string; + name: string; + language: string; + /** 음성 성별 */ + gender: 'male' | 'female' | 'neutral'; + /** 다운로드 완료 여부 */ + downloaded: boolean; + sizeBytes: number; +} + +export interface TTSSpeakParams { + text: string; + /** 음성 ID (생략 시 활성 음성) */ + voiceId?: string; + /** 재생 속도 (0.5 ~ 2.0, 기본 1.0) */ + speed?: number; +} + +export interface TTSSpeakResult { + /** 생성된 오디오 지속 시간 (ms) */ + durationMs: number; +} + +export interface SetTTSVoiceParams { + voiceId: string; +} + +export interface DownloadVoiceParams { + voiceId: string; +} + +export interface TTSStatusChangedEvent { + status: TTSStatus; +} + +export interface SpeakingStateChangedEvent { + isSpeaking: boolean; + /** 현재/마지막 재생 텍스트 */ + text: string; +} + +// ============================================================ +// LLM (Ollama) +// ============================================================ + +export enum LLMConnectionState { + DISCONNECTED = 'disconnected', + CONNECTING = 'connecting', + CONNECTED = 'connected', + ERROR = 'error', +} + +export interface LLMStatus { + connectionState: LLMConnectionState; + serverUrl: string; + activeModel: string | null; + /** Ollama 서버 버전 */ + serverVersion: string | null; +} + +export interface LLMModel { + id: string; + name: string; + /** 모델 크기 (bytes) */ + sizeBytes: number; + /** 파라미터 수 문자열 (예: '7B', '13B') */ + parameterSize: string; + /** 양자화 레벨 (예: 'Q4_K_M') */ + quantization: string; + /** 수정 시각 (ISO 8601) */ + modifiedAt: string; +} + +export type LLMAction = + | 'refine' // 텍스트 다듬기 + | 'translate' // 번역 + | 'summarize' // 요약 + | 'expand' // 확장 + | 'grammar' // 문법 교정 + | 'custom'; // 커스텀 프롬프트 + +export interface LLMProcessParams { + text: string; + action: LLMAction; + /** translate 시 대상 언어 */ + targetLanguage?: string; + /** custom 시 프롬프트 */ + customPrompt?: string; + /** 사용할 모델 ID (생략 시 활성 모델) */ + modelId?: string; +} + +export interface LLMProcessResult { + originalText: string; + processedText: string; + action: LLMAction; + /** 처리 시간 (ms) */ + processingTimeMs: number; + /** 사용된 토큰 수 */ + tokenCount: number; +} + +export interface SetLLMModelParams { + modelId: string; +} + +export interface SetServerUrlParams { + url: string; +} + +export interface PullModelParams { + modelName: string; +} + +export interface LLMStatusChangedEvent { + status: LLMStatus; +} + +export interface LLMProcessProgressEvent { + /** 누적 생성 텍스트 */ + text: string; + /** 마지막 토큰 */ + token: string; + /** 완료 여부 */ + done: boolean; +} + +export interface LLMPullProgressEvent { + modelName: string; + status: string; + /** 0 ~ 100 */ + percent: number; + /** 다운로드된 바이트 */ + downloadedBytes: number; + totalBytes: number; +} + +// ============================================================ +// Hotkey (핫키) +// ============================================================ + +export interface HotkeyBinding { + /** uiohook 키코드 */ + keyCode: number; + /** Ctrl 수식자 */ + ctrl: boolean; + /** Alt 수식자 */ + alt: boolean; + /** Shift 수식자 */ + shift: boolean; + /** Meta(Win) 수식자 */ + meta: boolean; + /** 표시용 문자열 (예: 'Right Alt') */ + displayLabel: string; +} + +export interface SetHotkeyParams { + binding: HotkeyBinding; +} + +export interface SetEnabledParams { + enabled: boolean; +} + +export type HotkeyAction = 'dictation' | 'hands-free' | 'command'; + +export interface HotkeyTriggeredEvent { + action: HotkeyAction; + /** 'pressed' | 'released' — hold-to-talk용 */ + type: 'pressed' | 'released'; + /** 더블프레스 여부 (300ms 이내) */ + isDoublePress: boolean; +} + +export interface HotkeyRecordingResultEvent { + binding: HotkeyBinding | null; + /** 시스템 예약 키 충돌 시 사유 */ + conflictReason: string | null; +} + +// ============================================================ +// Config (설정) +// ============================================================ + +export interface AppConfig { + // UI + theme: ThemeMode; + language: string; + closeToTray: boolean; + autoLaunch: boolean; + soundEnabled: boolean; + + // Audio + selectedDeviceId: string | null; + + // STT + sttModelId: string; + sttLanguage: string; + + // TTS + ttsVoiceId: string | null; + ttsSpeed: number; + + // LLM + ollamaServerUrl: string; + llmModelId: string | null; + defaultLLMAction: LLMAction; + + // Hotkey + dictationShortcut: HotkeyBinding; + handsFreeShortcut: HotkeyBinding; + commandShortcut: HotkeyBinding; + hotkeyEnabled: boolean; + + // Text Insert + insertMethod: 'clipboard' | 'keyboard'; + autoInsert: boolean; + + // History + maxHistoryEntries: number; +} + +export interface ConfigGetParams { + key: keyof AppConfig; +} + +export interface ConfigSetParams { + key: keyof AppConfig; + value: AppConfig[keyof AppConfig]; +} + +export interface ConfigResetParams { + /** 초기화할 키 (생략 시 전체 초기화) */ + key?: keyof AppConfig; +} + +export interface SetThemeParams { + theme: ThemeMode; +} + +export interface SetLanguageParams { + language: string; +} + +export interface SetAutoLaunchParams { + enabled: boolean; +} + +export interface SetCloseToTrayParams { + enabled: boolean; +} + +export interface ConfigChangedEvent { + key: keyof AppConfig; + value: AppConfig[keyof AppConfig]; + previousValue: AppConfig[keyof AppConfig]; +} + +// ============================================================ +// History (히스토리) +// ============================================================ + +export interface HistoryEntry { + id: string; + /** 원본 전사 텍스트 */ + originalText: string; + /** LLM 다듬기/번역 결과 (미처리 시 null) */ + polishedText: string | null; + /** 포커스 앱 실행 경로 */ + focusedApp: string | null; + /** 포커스 앱 이름 */ + focusedAppName: string | null; + /** 포커스 윈도우 타이틀 */ + focusedAppWindowTitle: string | null; + /** 녹음 모드 */ + mode: 'dictation' | 'translate' | 'command'; + /** 세션 상태 */ + status: 'completed' | 'cancelled' | 'error'; + /** 에러 코드 (실패 시) */ + errorCode: string | null; + /** 녹음 파일 경로 */ + audioLocalPath: string | null; + /** 녹음 시간 (초) */ + duration: number; + /** Whisper 감지 언어 */ + detectedLanguage: string | null; + /** 마이크 디바이스 ID */ + micDevice: string | null; + /** 단어 수 */ + wordCount: number; + /** 사용된 Whisper 모델명 */ + sttModel: string | null; + /** 사용된 Ollama 모델명 */ + llmModel: string | null; + /** STT 처리 시간 (ms) */ + sttLatencyMs: number | null; + /** LLM 처리 시간 (ms) */ + llmLatencyMs: number | null; + createdAt: number; + updatedAt: number; + appVersion: string; +} + +export interface HistoryQueryParams { + /** 페이지 번호 (0부터) */ + page: number; + /** 페이지 크기 */ + pageSize: number; + /** 정렬 기준 */ + sortBy?: 'createdAt' | 'durationMs' | 'wordCount'; + /** 정렬 방향 */ + sortOrder?: 'asc' | 'desc'; +} + +export interface HistoryPage { + entries: HistoryEntry[]; + total: number; + page: number; + pageSize: number; + totalPages: number; +} + +export interface HistoryGetByIdParams { + id: string; +} + +export interface HistoryDeleteParams { + id: string; +} + +export interface HistorySearchParams { + query: string; + page: number; + pageSize: number; +} + +export interface HistoryExportParams { + format: 'json' | 'csv'; + /** 내보내기 시작 날짜 (ISO 8601, 생략 시 전체) */ + from?: string; + /** 내보내기 종료 날짜 */ + to?: string; +} + +// ============================================================ +// Dictionary (사전) +// ============================================================ + +export interface DictionaryEntry { + id: string; + /** 단어/구문 */ + word: string; + /** 발음 힌트 (선택) */ + pronunciation: string | null; + /** 카테고리 */ + category: 'user' | 'auto' | 'technical'; + /** 사용 횟수 */ + usageCount: number; + /** 마지막 사용 시각 */ + lastUsedAt: number | null; + createdAt: number; + updatedAt: number; +} + +export interface DictionaryQueryParams { + page: number; + pageSize: number; + sortBy?: 'word' | 'category' | 'usageCount' | 'createdAt'; + sortOrder?: 'asc' | 'desc'; +} + +export interface DictionaryPage { + entries: DictionaryEntry[]; + total: number; + page: number; + pageSize: number; + totalPages: number; +} + +export interface DictionaryAddParams { + word: string; + pronunciation?: string; + category?: 'user' | 'auto' | 'technical'; +} + +export interface DictionaryUpdateParams { + id: string; + word?: string; + pronunciation?: string; + category?: 'user' | 'auto' | 'technical'; +} + +export interface DictionaryDeleteParams { + id: string; +} + +export interface DictionaryImportParams { + /** 파일 경로 */ + filePath: string; + format: 'json' | 'csv'; +} + +export interface DictionaryImportResult { + imported: number; + skipped: number; + errors: number; +} + +export interface DictionaryExportParams { + format: 'json' | 'csv'; +} + +export interface DictionarySearchParams { + query: string; + page: number; + pageSize: number; +} + +// ============================================================ +// Window (윈도우 제어) +// ============================================================ + +export type RecordingTipState = + | 'opening' + | 'recording' + | 'thinking' + | 'result' + | 'error'; + +export interface ShowRecordingTipParams { + state: RecordingTipState; + /** 결과 텍스트 (state='result' 시) */ + text?: string; + /** 에러 메시지 (state='error' 시) */ + errorMessage?: string; +} + +export interface ShowResultPopupParams { + text: string; + /** 자동 숨김 시간 (ms), 0이면 수동 닫기만 */ + autoHideMs?: number; +} + +export interface TipMeasuredParams { + /** 측정된 너비 (px) */ + width: number; + /** 측정된 높이 (px) */ + height: number; +} + +export interface TipStateChangedEvent { + state: RecordingTipState; + text?: string; + errorMessage?: string; +} + +export interface TipPrepareEvent { + state: RecordingTipState; + text?: string; +} + +export interface TipShowEvent { + state: RecordingTipState; +} + +// ============================================================ +// System (시스템) +// ============================================================ + +export interface ActiveAppInfo { + /** 앱 실행 파일명 */ + name: string; + /** 윈도우 제목 */ + title: string; + /** 프로세스 ID */ + pid: number; +} + +export interface ShowNotificationParams { + title: string; + body: string; + /** 'info' | 'warning' | 'error' */ + type?: 'info' | 'warning' | 'error'; +} + +export interface OpenExternalParams { + url: string; +} + +export interface InsertTextParams { + text: string; + /** 삽입 방법 (생략 시 설정 기본값) */ + method?: 'clipboard' | 'keyboard'; +} + +export interface InsertTextResult { + success: boolean; + /** 삽입된 문자 수 */ + insertedLength: number; +} + +export type SoundEffect = + | 'recording-start' + | 'recording-stop' + | 'transcription-complete' + | 'error' + | 'notification'; + +export interface PlaySoundParams { + sound: SoundEffect; +} + +export interface SetSoundEnabledParams { + enabled: boolean; +} + +// ============================================================ +// Stats (통계) +// ============================================================ + +export interface StatsSummary { + /** 총 녹음 시간 (ms) */ + totalRecordingTimeMs: number; + /** 총 단어 수 */ + totalWordCount: number; + /** 총 세션 수 */ + totalSessionCount: number; + /** 오늘 녹음 시간 (ms) */ + todayRecordingTimeMs: number; + /** 오늘 단어 수 */ + todayWordCount: number; + /** 오늘 세션 수 */ + todaySessionCount: number; + /** 연속 사용 일수 */ + streakDays: number; +} + +export interface StatsQueryParams { + /** 조회 시작일 (ISO 8601) */ + from: string; + /** 조회 종료일 */ + to: string; +} + +export interface DailyStats { + date: string; + recordingTimeMs: number; + wordCount: number; + sessionCount: number; +} + +export interface WeeklyStats { + /** 주 시작일 (월요일) */ + weekStart: string; + recordingTimeMs: number; + wordCount: number; + sessionCount: number; +} +``` + +--- + +## 4. `shared/errors.ts` 에러 코드 전체 설계 + +Speakly의 900+ 에러 코드 체계를 참고하되, 클라우드/인증 관련을 제거하고 로컬 서비스에 맞게 재설계한다. + +### 에러 코드 범위 규칙 + +| 범위 | 카테고리 | +|------|---------| +| 0 | 성공 | +| 100-199 | STT (Whisper) | +| 200-299 | TTS | +| 300-399 | LLM (Ollama) | +| 400-499 | Audio (마이크/캡처) | +| 500-599 | Hotkey | +| 600-699 | TextInsert (텍스트 삽입) | +| 700-799 | History / Dictionary (DB) | +| 800-899 | Config (설정) | +| 900-999 | System / Window | + +```typescript +// src/shared/errors.ts + +export enum ErrorCode { + // === Success === + Success = 0, + + // === STT (100-199) === + STTEngineNotInstalled = 100, + STTModelNotFound = 101, + STTModelNotLoaded = 102, + STTModelLoadFailed = 103, + STTModelDownloadFailed = 104, + STTModelDownloadCancelled = 105, + STTTranscriptionFailed = 110, + STTTranscriptionTimeout = 111, + STTTranscriptionCancelled = 112, + STTNoAudioData = 113, + STTAudioTooShort = 114, + STTLanguageNotSupported = 120, + STTSidecarSpawnFailed = 130, + STTSidecarCrashed = 131, + STTSidecarCommunicationFailed = 132, + STTGPUNotAvailable = 140, + + // === TTS (200-299) === + TTSEngineNotInstalled = 200, + TTSVoiceNotFound = 201, + TTSVoiceNotLoaded = 202, + TTSVoiceLoadFailed = 203, + TTSVoiceDownloadFailed = 204, + TTSSynthesisFailed = 210, + TTSPlaybackFailed = 211, + TTSPlaybackInterrupted = 212, + TTSTextTooLong = 220, + TTSTextEmpty = 221, + + // === LLM / Ollama (300-399) === + LLMServerUnreachable = 300, + LLMServerConnectionFailed = 301, + LLMServerTimeout = 302, + LLMModelNotFound = 310, + LLMModelNotLoaded = 311, + LLMModelLoadFailed = 312, + LLMModelPullFailed = 313, + LLMModelPullCancelled = 314, + LLMProcessingFailed = 320, + LLMProcessingTimeout = 321, + LLMProcessingCancelled = 322, + LLMResponseParseFailed = 323, + LLMInvalidAction = 330, + LLMPromptTooLong = 331, + + // === Audio (400-499) === + AudioDeviceNotFound = 400, + AudioDeviceAccessDenied = 401, + AudioDeviceBusy = 402, + AudioCaptureStartFailed = 410, + AudioCaptureStopFailed = 411, + AudioCaptureFailed = 412, + AudioNoPermission = 420, + AudioStreamError = 430, + AudioBufferOverflow = 431, + + // === Hotkey (500-599) === + HotkeyRegistrationFailed = 500, + HotkeyConflict = 501, + HotkeySystemReserved = 502, + HotkeyHookInitFailed = 510, + HotkeyHookCrashed = 511, + + // === TextInsert (600-699) === + TextInsertFailed = 600, + TextInsertClipboardSaveFailed = 601, + TextInsertClipboardRestoreFailed = 602, + TextInsertKeySimulationFailed = 603, + TextInsertNoActiveWindow = 610, + TextInsertTargetAppNotResponding = 611, + + // === History / Dictionary / DB (700-799) === + DBOpenFailed = 700, + DBMigrationFailed = 701, + DBQueryFailed = 702, + DBWriteFailed = 703, + HistoryNotFound = 710, + HistoryExportFailed = 711, + DictionaryNotFound = 720, + DictionaryDuplicate = 721, + DictionaryImportFailed = 722, + DictionaryExportFailed = 723, + DictionaryImportInvalidFormat = 724, + + // === Config (800-899) === + ConfigReadFailed = 800, + ConfigWriteFailed = 801, + ConfigInvalidValue = 802, + ConfigKeyNotFound = 803, + ConfigResetFailed = 804, + ConfigMigrationFailed = 810, + + // === System / Window (900-999) === + WindowCreationFailed = 900, + WindowNotFound = 901, + TrayCreationFailed = 910, + NotificationFailed = 920, + PermissionDenied = 930, + ExternalOpenFailed = 940, + SoundPlayFailed = 950, + AppAlreadyRunning = 960, + UnknownError = 999, +} + +/** + * D3RO-VOICE 표준 에러 객체 (Speakly NXError 패턴) + * 모든 IPC 에러 응답은 이 형태로 전달된다. + */ +export class D3ROError extends Error { + readonly code: ErrorCode; + readonly details?: Record; + + constructor(code: ErrorCode, message: string, details?: Record) { + super(message); + this.name = 'D3ROError'; + this.code = code; + this.details = details; + } + + toJSON(): D3ROErrorJSON { + return { + code: this.code, + message: this.message, + details: this.details, + }; + } + + static fromJSON(json: D3ROErrorJSON): D3ROError { + return new D3ROError(json.code, json.message, json.details); + } +} + +export interface D3ROErrorJSON { + code: ErrorCode; + message: string; + details?: Record; +} + +/** + * IPC 핸들러에서 사용하는 표준 응답 래퍼. + * 성공 시 { success: true, data }, 실패 시 { success: false, error } + */ +export type IPCResult = + | { success: true; data: T } + | { success: false; error: D3ROErrorJSON }; + +/** + * IPCResult 헬퍼 함수 + */ +export function ipcSuccess(data: T): IPCResult { + return { success: true, data }; +} + +export function ipcError(code: ErrorCode, message: string, details?: Record): IPCResult { + return { success: false, error: { code, message, details } }; +} +``` + +--- + +## 5. `preload/index.ts` 전체 API 설계 + +contextBridge로 노출할 `window.electronAPI` 구조. 렌더러는 이 객체를 통해서만 메인 프로세스와 통신한다. + +```typescript +// src/preload/index.ts +import { contextBridge, ipcRenderer } from 'electron'; +import { IPC_CHANNELS } from '../shared/ipc-channels'; +import type { + // Voice + StartRecordingParams, StartRecordingResult, + StopRecordingParams, StopRecordingResult, + CancelRecordingParams, VoiceState, SetVoiceModeParams, VoiceMode, + VoiceStateChangedEvent, TranscriptionDeltaEvent, TranscriptionCompleteEvent, + VoiceErrorEvent, AudioLevelEvent, + // Audio + AudioDevice, SetDeviceParams, TestDeviceParams, TestDeviceResult, + AudioDeviceChangedEvent, + // STT + STTStatus, STTModel, SetSTTModelParams, DownloadModelParams, + SetSTTLanguageParams, STTStatusChangedEvent, DownloadProgressEvent, + // TTS + TTSStatus, TTSVoice, TTSSpeakParams, TTSSpeakResult, + SetTTSVoiceParams, DownloadVoiceParams, + TTSStatusChangedEvent, SpeakingStateChangedEvent, + // LLM + LLMStatus, LLMModel, SetLLMModelParams, LLMProcessParams, LLMProcessResult, + SetServerUrlParams, PullModelParams, + LLMStatusChangedEvent, LLMProcessProgressEvent, LLMPullProgressEvent, + // Hotkey + HotkeyBinding, SetHotkeyParams, SetEnabledParams, + HotkeyTriggeredEvent, HotkeyRecordingResultEvent, + // Config + AppConfig, ConfigGetParams, ConfigSetParams, ConfigResetParams, + SetThemeParams, SetLanguageParams, SetAutoLaunchParams, SetCloseToTrayParams, + ThemeMode, ConfigChangedEvent, + // History + HistoryEntry, HistoryQueryParams, HistoryPage, HistoryGetByIdParams, + HistoryDeleteParams, HistorySearchParams, HistoryExportParams, + // Dictionary + DictionaryEntry, DictionaryQueryParams, DictionaryPage, + DictionaryAddParams, DictionaryUpdateParams, DictionaryDeleteParams, + DictionaryImportParams, DictionaryImportResult, DictionaryExportParams, + DictionarySearchParams, + // Window + ShowRecordingTipParams, ShowResultPopupParams, TipMeasuredParams, + TipStateChangedEvent, TipPrepareEvent, TipShowEvent, + // System + ActiveAppInfo, ShowNotificationParams, OpenExternalParams, + InsertTextParams, InsertTextResult, PlaySoundParams, SetSoundEnabledParams, + PermissionStatus, + // Stats + StatsSummary, StatsQueryParams, DailyStats, WeeklyStats, +} from '../shared/types'; +import type { IPCResult } from '../shared/errors'; + +// 타입 안전한 invoke 헬퍼 +function invoke(channel: string, ...args: unknown[]): Promise> { + return ipcRenderer.invoke(channel, ...args); +} + +// 타입 안전한 send 헬퍼 (fire-and-forget) +function send(channel: string, ...args: unknown[]): void { + ipcRenderer.send(channel, ...args); +} + +// 타입 안전한 이벤트 리스너 헬퍼 +type Unsubscribe = () => void; +function on(channel: string, callback: (data: T) => void): Unsubscribe { + const listener = (_event: Electron.IpcRendererEvent, data: T) => callback(data); + ipcRenderer.on(channel, listener); + return () => ipcRenderer.removeListener(channel, listener); +} + +const electronAPI = { + // ── Voice ────────────────────────────────────────────── + voice: { + startRecording: (params: StartRecordingParams) => + invoke(IPC_CHANNELS.VOICE.START_RECORDING, params), + stopRecording: (params: StopRecordingParams) => + invoke(IPC_CHANNELS.VOICE.STOP_RECORDING, params), + cancelRecording: (params: CancelRecordingParams) => + invoke(IPC_CHANNELS.VOICE.CANCEL_RECORDING, params), + getState: () => + invoke(IPC_CHANNELS.VOICE.GET_STATE), + setMode: (params: SetVoiceModeParams) => + invoke(IPC_CHANNELS.VOICE.SET_MODE, params), + getMode: () => + invoke(IPC_CHANNELS.VOICE.GET_MODE), + + onStateChanged: (cb: (e: VoiceStateChangedEvent) => void): Unsubscribe => + on(IPC_CHANNELS.VOICE.STATE_CHANGED, cb), + onTranscriptionDelta: (cb: (e: TranscriptionDeltaEvent) => void): Unsubscribe => + on(IPC_CHANNELS.VOICE.TRANSCRIPTION_DELTA, cb), + onTranscriptionComplete: (cb: (e: TranscriptionCompleteEvent) => void): Unsubscribe => + on(IPC_CHANNELS.VOICE.TRANSCRIPTION_COMPLETE, cb), + onError: (cb: (e: VoiceErrorEvent) => void): Unsubscribe => + on(IPC_CHANNELS.VOICE.ERROR, cb), + onAudioLevel: (cb: (e: AudioLevelEvent) => void): Unsubscribe => + on(IPC_CHANNELS.VOICE.AUDIO_LEVEL, cb), + }, + + // ── Audio ────────────────────────────────────────────── + audio: { + getDevices: () => + invoke(IPC_CHANNELS.AUDIO.GET_DEVICES), + getSelectedDevice: () => + invoke(IPC_CHANNELS.AUDIO.GET_SELECTED_DEVICE), + setSelectedDevice: (params: SetDeviceParams) => + invoke(IPC_CHANNELS.AUDIO.SET_SELECTED_DEVICE, params), + testDevice: (params: TestDeviceParams) => + invoke(IPC_CHANNELS.AUDIO.TEST_DEVICE, params), + + onDeviceChanged: (cb: (e: AudioDeviceChangedEvent) => void): Unsubscribe => + on(IPC_CHANNELS.AUDIO.DEVICE_CHANGED, cb), + }, + + // ── STT ──────────────────────────────────────────────── + stt: { + getStatus: () => + invoke(IPC_CHANNELS.STT.GET_STATUS), + getModels: () => + invoke(IPC_CHANNELS.STT.GET_MODELS), + getActiveModel: () => + invoke(IPC_CHANNELS.STT.GET_ACTIVE_MODEL), + setModel: (params: SetSTTModelParams) => + invoke(IPC_CHANNELS.STT.SET_MODEL, params), + downloadModel: (params: DownloadModelParams) => + invoke(IPC_CHANNELS.STT.DOWNLOAD_MODEL, params), + cancelDownload: () => + invoke(IPC_CHANNELS.STT.CANCEL_DOWNLOAD), + getLanguage: () => + invoke(IPC_CHANNELS.STT.GET_LANGUAGE), + setLanguage: (params: SetSTTLanguageParams) => + invoke(IPC_CHANNELS.STT.SET_LANGUAGE, params), + + onStatusChanged: (cb: (e: STTStatusChangedEvent) => void): Unsubscribe => + on(IPC_CHANNELS.STT.STATUS_CHANGED, cb), + onDownloadProgress: (cb: (e: DownloadProgressEvent) => void): Unsubscribe => + on(IPC_CHANNELS.STT.DOWNLOAD_PROGRESS, cb), + }, + + // ── TTS ──────────────────────────────────────────────── + tts: { + speak: (params: TTSSpeakParams) => + invoke(IPC_CHANNELS.TTS.SPEAK, params), + stop: () => + invoke(IPC_CHANNELS.TTS.STOP), + getVoices: () => + invoke(IPC_CHANNELS.TTS.GET_VOICES), + getActiveVoice: () => + invoke(IPC_CHANNELS.TTS.GET_ACTIVE_VOICE), + setVoice: (params: SetTTSVoiceParams) => + invoke(IPC_CHANNELS.TTS.SET_VOICE, params), + getStatus: () => + invoke(IPC_CHANNELS.TTS.GET_STATUS), + downloadVoice: (params: DownloadVoiceParams) => + invoke(IPC_CHANNELS.TTS.DOWNLOAD_VOICE, params), + + onStatusChanged: (cb: (e: TTSStatusChangedEvent) => void): Unsubscribe => + on(IPC_CHANNELS.TTS.STATUS_CHANGED, cb), + onSpeakingStateChanged: (cb: (e: SpeakingStateChangedEvent) => void): Unsubscribe => + on(IPC_CHANNELS.TTS.SPEAKING_STATE_CHANGED, cb), + }, + + // ── LLM ──────────────────────────────────────────────── + llm: { + getStatus: () => + invoke(IPC_CHANNELS.LLM.GET_STATUS), + getModels: () => + invoke(IPC_CHANNELS.LLM.GET_MODELS), + getActiveModel: () => + invoke(IPC_CHANNELS.LLM.GET_ACTIVE_MODEL), + setModel: (params: SetLLMModelParams) => + invoke(IPC_CHANNELS.LLM.SET_MODEL, params), + process: (params: LLMProcessParams) => + invoke(IPC_CHANNELS.LLM.PROCESS, params), + cancelProcess: () => + invoke(IPC_CHANNELS.LLM.CANCEL_PROCESS), + getServerUrl: () => + invoke(IPC_CHANNELS.LLM.GET_SERVER_URL), + setServerUrl: (params: SetServerUrlParams) => + invoke(IPC_CHANNELS.LLM.SET_SERVER_URL, params), + pullModel: (params: PullModelParams) => + invoke(IPC_CHANNELS.LLM.PULL_MODEL, params), + + onStatusChanged: (cb: (e: LLMStatusChangedEvent) => void): Unsubscribe => + on(IPC_CHANNELS.LLM.STATUS_CHANGED, cb), + onProcessProgress: (cb: (e: LLMProcessProgressEvent) => void): Unsubscribe => + on(IPC_CHANNELS.LLM.PROCESS_PROGRESS, cb), + onPullProgress: (cb: (e: LLMPullProgressEvent) => void): Unsubscribe => + on(IPC_CHANNELS.LLM.PULL_PROGRESS, cb), + }, + + // ── Hotkey ───────────────────────────────────────────── + hotkey: { + getDictationShortcut: () => + invoke(IPC_CHANNELS.HOTKEY.GET_DICTATION_SHORTCUT), + setDictationShortcut: (params: SetHotkeyParams) => + invoke(IPC_CHANNELS.HOTKEY.SET_DICTATION_SHORTCUT, params), + getHandsFreeShortcut: () => + invoke(IPC_CHANNELS.HOTKEY.GET_HANDS_FREE_SHORTCUT), + setHandsFreeShortcut: (params: SetHotkeyParams) => + invoke(IPC_CHANNELS.HOTKEY.SET_HANDS_FREE_SHORTCUT, params), + getCommandShortcut: () => + invoke(IPC_CHANNELS.HOTKEY.GET_COMMAND_SHORTCUT), + setCommandShortcut: (params: SetHotkeyParams) => + invoke(IPC_CHANNELS.HOTKEY.SET_COMMAND_SHORTCUT, params), + isEnabled: () => + invoke(IPC_CHANNELS.HOTKEY.IS_ENABLED), + setEnabled: (params: SetEnabledParams) => + invoke(IPC_CHANNELS.HOTKEY.SET_ENABLED, params), + startRecording: () => + invoke(IPC_CHANNELS.HOTKEY.START_RECORDING), + stopRecording: () => + invoke(IPC_CHANNELS.HOTKEY.STOP_RECORDING), + + onTriggered: (cb: (e: HotkeyTriggeredEvent) => void): Unsubscribe => + on(IPC_CHANNELS.HOTKEY.TRIGGERED, cb), + onRecordingResult: (cb: (e: HotkeyRecordingResultEvent) => void): Unsubscribe => + on(IPC_CHANNELS.HOTKEY.RECORDING_RESULT, cb), + }, + + // ── Config ───────────────────────────────────────────── + config: { + get: (params: ConfigGetParams) => + invoke(IPC_CHANNELS.CONFIG.GET, params), + set: (params: ConfigSetParams) => + invoke(IPC_CHANNELS.CONFIG.SET, params), + getAll: () => + invoke(IPC_CHANNELS.CONFIG.GET_ALL), + reset: (params: ConfigResetParams) => + invoke(IPC_CHANNELS.CONFIG.RESET, params), + getTheme: () => + invoke(IPC_CHANNELS.CONFIG.GET_THEME), + setTheme: (params: SetThemeParams) => + invoke(IPC_CHANNELS.CONFIG.SET_THEME, params), + getLanguage: () => + invoke(IPC_CHANNELS.CONFIG.GET_LANGUAGE), + setLanguage: (params: SetLanguageParams) => + invoke(IPC_CHANNELS.CONFIG.SET_LANGUAGE, params), + getAutoLaunch: () => + invoke(IPC_CHANNELS.CONFIG.GET_AUTO_LAUNCH), + setAutoLaunch: (params: SetAutoLaunchParams) => + invoke(IPC_CHANNELS.CONFIG.SET_AUTO_LAUNCH, params), + getCloseToTray: () => + invoke(IPC_CHANNELS.CONFIG.GET_CLOSE_TO_TRAY), + setCloseToTray: (params: SetCloseToTrayParams) => + invoke(IPC_CHANNELS.CONFIG.SET_CLOSE_TO_TRAY, params), + + onChanged: (cb: (e: ConfigChangedEvent) => void): Unsubscribe => + on(IPC_CHANNELS.CONFIG.CHANGED, cb), + }, + + // ── History ──────────────────────────────────────────── + history: { + getAll: (params: HistoryQueryParams) => + invoke(IPC_CHANNELS.HISTORY.GET_ALL, params), + getById: (params: HistoryGetByIdParams) => + invoke(IPC_CHANNELS.HISTORY.GET_BY_ID, params), + delete: (params: HistoryDeleteParams) => + invoke(IPC_CHANNELS.HISTORY.DELETE, params), + deleteAll: () => + invoke(IPC_CHANNELS.HISTORY.DELETE_ALL), + search: (params: HistorySearchParams) => + invoke(IPC_CHANNELS.HISTORY.SEARCH, params), + export: (params: HistoryExportParams) => + invoke(IPC_CHANNELS.HISTORY.EXPORT, params), + + onAdded: (cb: (e: HistoryEntry) => void): Unsubscribe => + on(IPC_CHANNELS.HISTORY.ADDED, cb), + }, + + // ── Dictionary ───────────────────────────────────────── + dictionary: { + getAll: (params: DictionaryQueryParams) => + invoke(IPC_CHANNELS.DICTIONARY.GET_ALL, params), + add: (params: DictionaryAddParams) => + invoke(IPC_CHANNELS.DICTIONARY.ADD, params), + update: (params: DictionaryUpdateParams) => + invoke(IPC_CHANNELS.DICTIONARY.UPDATE, params), + delete: (params: DictionaryDeleteParams) => + invoke(IPC_CHANNELS.DICTIONARY.DELETE, params), + import: (params: DictionaryImportParams) => + invoke(IPC_CHANNELS.DICTIONARY.IMPORT, params), + export: (params: DictionaryExportParams) => + invoke(IPC_CHANNELS.DICTIONARY.EXPORT, params), + search: (params: DictionarySearchParams) => + invoke(IPC_CHANNELS.DICTIONARY.SEARCH, params), + }, + + // ── Window ───────────────────────────────────────────── + window: { + minimize: () => + send(IPC_CHANNELS.WINDOW.MINIMIZE), + maximize: () => + send(IPC_CHANNELS.WINDOW.MAXIMIZE), + close: () => + send(IPC_CHANNELS.WINDOW.CLOSE), + isMaximized: () => + invoke(IPC_CHANNELS.WINDOW.IS_MAXIMIZED), + showRecordingTip: (params: ShowRecordingTipParams) => + send(IPC_CHANNELS.WINDOW.SHOW_RECORDING_TIP, params), + hideRecordingTip: () => + send(IPC_CHANNELS.WINDOW.HIDE_RECORDING_TIP), + showResultPopup: (params: ShowResultPopupParams) => + send(IPC_CHANNELS.WINDOW.SHOW_RESULT_POPUP, params), + hideResultPopup: () => + send(IPC_CHANNELS.WINDOW.HIDE_RESULT_POPUP), + tipMeasured: (params: TipMeasuredParams) => + send(IPC_CHANNELS.WINDOW.TIP_MEASURED, params), + + onTipStateChanged: (cb: (e: TipStateChangedEvent) => void): Unsubscribe => + on(IPC_CHANNELS.WINDOW.TIP_STATE_CHANGED, cb), + onTipPrepare: (cb: (e: TipPrepareEvent) => void): Unsubscribe => + on(IPC_CHANNELS.WINDOW.TIP_PREPARE, cb), + onTipShow: (cb: (e: TipShowEvent) => void): Unsubscribe => + on(IPC_CHANNELS.WINDOW.TIP_SHOW, cb), + }, + + // ── System ───────────────────────────────────────────── + system: { + getPlatform: () => + invoke(IPC_CHANNELS.SYSTEM.GET_PLATFORM), + getVersion: () => + invoke(IPC_CHANNELS.SYSTEM.GET_VERSION), + checkMicPermission: () => + invoke(IPC_CHANNELS.SYSTEM.CHECK_MIC_PERMISSION), + requestMicPermission: () => + invoke(IPC_CHANNELS.SYSTEM.REQUEST_MIC_PERMISSION), + showNotification: (params: ShowNotificationParams) => + invoke(IPC_CHANNELS.SYSTEM.SHOW_NOTIFICATION, params), + openExternal: (params: OpenExternalParams) => + invoke(IPC_CHANNELS.SYSTEM.OPEN_EXTERNAL, params), + getActiveApp: () => + invoke(IPC_CHANNELS.SYSTEM.GET_ACTIVE_APP), + insertText: (params: InsertTextParams) => + invoke(IPC_CHANNELS.SYSTEM.INSERT_TEXT, params), + playSound: (params: PlaySoundParams) => + invoke(IPC_CHANNELS.SYSTEM.PLAY_SOUND, params), + setSoundEnabled: (params: SetSoundEnabledParams) => + invoke(IPC_CHANNELS.SYSTEM.SET_SOUND_ENABLED, params), + isSoundEnabled: () => + invoke(IPC_CHANNELS.SYSTEM.IS_SOUND_ENABLED), + }, + + // ── Stats ────────────────────────────────────────────── + stats: { + getSummary: () => + invoke(IPC_CHANNELS.STATS.GET_SUMMARY), + getDaily: (params: StatsQueryParams) => + invoke(IPC_CHANNELS.STATS.GET_DAILY, params), + getWeekly: (params: StatsQueryParams) => + invoke(IPC_CHANNELS.STATS.GET_WEEKLY, params), + + onUpdated: (cb: (e: StatsSummary) => void): Unsubscribe => + on(IPC_CHANNELS.STATS.UPDATED, cb), + }, +} as const; + +// contextBridge로 렌더러에 노출 +contextBridge.exposeInMainWorld('electronAPI', electronAPI); + +// 렌더러에서 사용할 타입 선언 +export type ElectronAPI = typeof electronAPI; +``` + +### 렌더러 타입 선언 (`src/renderer/electron.d.ts`) + +```typescript +// src/renderer/electron.d.ts +import type { ElectronAPI } from '../preload/index'; + +declare global { + interface Window { + electronAPI: ElectronAPI; + } +} +``` + +--- + +## 6. 채널 통계 요약 + +| 카테고리 | handle | on (R->M) | send (M->R) | 합계 | +|---------|--------|-----------|-------------|------| +| voice | 6 | 0 | 5 | 11 | +| audio | 4 | 0 | 1 | 5 | +| stt | 8 | 0 | 2 | 10 | +| tts | 7 | 0 | 2 | 9 | +| llm | 9 | 0 | 3 | 12 | +| hotkey | 10 | 0 | 2 | 12 | +| config | 12 | 0 | 1 | 13 | +| history | 6 | 0 | 1 | 7 | +| dictionary | 7 | 0 | 0 | 7 | +| window | 1 | 8 | 3 | 12 | +| system | 11 | 0 | 0 | 11 | +| stats | 3 | 0 | 1 | 4 | +| **합계** | **84** | **8** | **21** | **113** | + +Speakly 대비: +- 제거: 클라우드 인증, WebSocket STT, 텔레메트리, 피드백, 업데이트, GenSpark 서비스 등 (~90개) +- 추가: 로컬 STT/TTS/LLM 관리, 모델 다운로드/pull, 통계 (~30개) + +--- + +## 7. 설계 결정 사항 + +### 7.1 IPCResult 래퍼 패턴 +모든 `handle` 채널은 `IPCResult` 를 반환한다. 이는 Speakly의 NXError 패턴을 발전시킨 것으로, 렌더러에서 try/catch 없이 `success` 필드로 분기할 수 있다. + +### 7.2 이벤트 구독 패턴 +`on*` 메서드는 `Unsubscribe` 함수를 반환한다. React 컴포넌트에서 `useEffect` cleanup으로 사용: +```typescript +useEffect(() => { + const unsub = window.electronAPI.voice.onStateChanged((e) => { + setState(e.currentState); + }); + return unsub; +}, []); +``` + +### 7.3 채널명 규칙 +- 형식: `${namespace}:${action}` (camelCase) +- handle 채널: 동사로 시작 (`get`, `set`, `start`, `stop`, `cancel`, `delete`) +- send 이벤트: 과거분사 또는 명사 (`changed`, `progress`, `complete`, `added`) + +### 7.4 Vanilla JS 팝업 통신 +RecordingTip, ResultPopup 등 Vanilla JS 팝업은 별도 preload 스크립트가 필요하다. 동일한 `IPC_CHANNELS` 상수를 사용하되, `window:tip*` 채널만 노출한다. diff --git a/docs/design/03-db-and-ui.md b/docs/design/03-db-and-ui.md new file mode 100644 index 0000000..517b973 --- /dev/null +++ b/docs/design/03-db-and-ui.md @@ -0,0 +1,1131 @@ +# 03. DB 스키마 & UI 컴포넌트 명세서 + +## 1. SQLite 데이터베이스 스키마 + +D3RO-VOICE는 better-sqlite3 + drizzle-orm을 사용한다. Speakly의 `genspark-flow.db` 스키마를 참조하되, 클라우드 관련 컬럼은 제거하고 로컬 전용 필드를 추가한다. + +### 1.1 history 테이블 + +세션(녹음 → 전사 → 다듬기) 단위로 한 행씩 저장한다. + +```sql +CREATE TABLE history ( + id TEXT PRIMARY KEY, -- nanoid, sessionId와 동일 + original_text TEXT NOT NULL, -- STT 원본 전사 텍스트 + polished_text TEXT, -- LLM 다듬기/번역 결과 (NULL = 미사용) + focused_app TEXT, -- 포커스 앱 실행 경로 + focused_app_name TEXT, -- 포커스 앱 이름 (예: 'Code') + focused_app_window_title TEXT, -- 포커스 윈도우 타이틀 + mode TEXT NOT NULL DEFAULT 'dictation', -- 'dictation' | 'translate' | 'command' + status TEXT NOT NULL DEFAULT 'completed', -- 'completed' | 'cancelled' | 'error' + error_code TEXT, -- 실패 시 에러 코드 (D3ROError 코드) + audio_local_path TEXT, -- 녹음 파일 경로 (NULL = 저장 안 함) + duration REAL NOT NULL, -- 녹음 시간(초) + detected_language TEXT, -- Whisper 감지 언어 코드 (예: 'ko', 'en') + mic_device TEXT, -- 마이크 디바이스 ID + word_count INTEGER NOT NULL DEFAULT 0, -- 단어 수 + stt_model TEXT, -- 사용된 Whisper 모델명 + llm_model TEXT, -- 사용된 Ollama 모델명 + stt_latency_ms INTEGER, -- STT 처리 시간(ms) + llm_latency_ms INTEGER, -- LLM 처리 시간(ms) + created_at INTEGER NOT NULL, -- Unix timestamp (ms) + updated_at INTEGER NOT NULL, -- Unix timestamp (ms) + app_version TEXT NOT NULL DEFAULT '1.0.0' +); + +CREATE INDEX idx_history_created_at ON history(created_at DESC); +CREATE INDEX idx_history_status ON history(status); +CREATE INDEX idx_history_detected_language ON history(detected_language); +CREATE INDEX idx_history_focused_app_name ON history(focused_app_name); +CREATE INDEX idx_history_mode ON history(mode); +``` + +**Speakly 대비 변경점:** +- 제거: `user_id`, `focused_app_bundle_id` (macOS 전용), `window_web_title/domain/url`, `audio_metadata`, `mic_device_info`, `selected_text` +- 추가: `error_code`, `stt_model`, `llm_model`, `stt_latency_ms`, `llm_latency_ms` +- 인덱스: `user_id` 복합 인덱스 → 단일 컬럼 인덱스로 단순화 (로컬 단일 사용자) + +### 1.2 dictionary 테이블 + +사용자 커스텀 단어 사전. STT 정확도 향상 및 자동 교정에 사용한다. + +```sql +CREATE TABLE dictionary ( + id TEXT PRIMARY KEY, -- nanoid + word TEXT NOT NULL, -- 단어/구문 + pronunciation TEXT, -- 발음 힌트 (선택) + category TEXT NOT NULL DEFAULT 'user', -- 'user' | 'auto' | 'technical' + usage_count INTEGER NOT NULL DEFAULT 0, -- 사용 횟수 + last_used_at INTEGER, -- 마지막 사용 시각 (Unix timestamp ms) + created_at INTEGER NOT NULL, -- Unix timestamp (ms) + updated_at INTEGER NOT NULL -- Unix timestamp (ms) +); + +CREATE UNIQUE INDEX idx_dictionary_word_category ON dictionary(word, category); +CREATE INDEX idx_dictionary_created_at ON dictionary(created_at); +CREATE INDEX idx_dictionary_usage_count ON dictionary(usage_count DESC); +``` + +**Speakly 대비 변경점:** +- 제거: `user_id`, `dict_type` → `category`로 통합 (로컬 전용이므로) +- 제거: `dict_sync_meta` 테이블 전체 (클라우드 동기화 불필요) +- 추가: `idx_dictionary_usage_count` 인덱스 (자주 쓰는 단어 우선) + +### 1.3 stats 테이블 + +전역 통계. 싱글턴 row (id=1)로 운영한다. + +```sql +CREATE TABLE stats ( + id INTEGER PRIMARY KEY CHECK (id = 1), -- 항상 1 + total_duration REAL NOT NULL DEFAULT 0, -- 누적 녹음 시간(초) + total_words INTEGER NOT NULL DEFAULT 0, -- 누적 단어 수 + session_count INTEGER NOT NULL DEFAULT 0, -- 누적 세션 수 + streak_days INTEGER NOT NULL DEFAULT 0, -- 연속 사용 일수 + last_session_at INTEGER, -- 마지막 세션 시각 + last_updated INTEGER NOT NULL -- Unix timestamp (ms) +); +``` + +**Speakly 대비 변경점:** +- 추가: `streak_days`, `last_session_at` (대시보드 통계용) +- `CHECK (id = 1)` 제약으로 싱글턴 보장 + +### 1.4 config 테이블 + +키-값 저장소. electron-store의 JSON 파일 대안으로 사용하지 않고, 이 프로젝트에서는 electron-store를 그대로 사용한다 (CLAUDE.md 기술 스택). config 테이블은 **정의하지 않는다**. + +> **참고**: 설정은 `electron-store`로 관리한다. DB에 별도 config 테이블을 두지 않는 이유: +> - electron-store는 JSON 기반으로 중첩 구조를 자연스럽게 지원 +> - 스키마 검증, 기본값, 마이그레이션을 라이브러리가 처리 +> - 설정 파일은 사용자가 직접 편집 가능 (config.json) + +--- + +### 1.5 drizzle-orm TypeScript 스키마 + +```typescript +// src/main/db/schema.ts + +import { sqliteTable, text, integer, real, index, uniqueIndex } from 'drizzle-orm/sqlite-core'; +import { sql } from 'drizzle-orm'; + +// ── history ────────────────────────────────────────────── +export const history = sqliteTable('history', { + id: text('id').primaryKey(), + originalText: text('original_text').notNull(), + polishedText: text('polished_text'), + focusedApp: text('focused_app'), + focusedAppName: text('focused_app_name'), + focusedAppWindowTitle: text('focused_app_window_title'), + mode: text('mode', { enum: ['dictation', 'translate', 'command'] }).notNull().default('dictation'), + status: text('status', { enum: ['completed', 'cancelled', 'error'] }).notNull().default('completed'), + errorCode: text('error_code'), + audioLocalPath: text('audio_local_path'), + duration: real('duration').notNull(), + detectedLanguage: text('detected_language'), + micDevice: text('mic_device'), + wordCount: integer('word_count').notNull().default(0), + sttModel: text('stt_model'), + llmModel: text('llm_model'), + sttLatencyMs: integer('stt_latency_ms'), + llmLatencyMs: integer('llm_latency_ms'), + createdAt: integer('created_at').notNull(), + updatedAt: integer('updated_at').notNull(), + appVersion: text('app_version').notNull().default('1.0.0'), +}, (table) => [ + index('idx_history_created_at').on(table.createdAt), + index('idx_history_status').on(table.status), + index('idx_history_detected_language').on(table.detectedLanguage), + index('idx_history_focused_app_name').on(table.focusedAppName), + index('idx_history_mode').on(table.mode), +]); + +// ── dictionary ─────────────────────────────────────────── +export const dictionary = sqliteTable('dictionary', { + id: text('id').primaryKey(), + word: text('word').notNull(), + pronunciation: text('pronunciation'), + category: text('category', { enum: ['user', 'auto', 'technical'] }).notNull().default('user'), + usageCount: integer('usage_count').notNull().default(0), + lastUsedAt: integer('last_used_at'), + createdAt: integer('created_at').notNull(), + updatedAt: integer('updated_at').notNull(), +}, (table) => [ + uniqueIndex('idx_dictionary_word_category').on(table.word, table.category), + index('idx_dictionary_created_at').on(table.createdAt), + index('idx_dictionary_usage_count').on(table.usageCount), +]); + +// ── stats ──────────────────────────────────────────────── +export const stats = sqliteTable('stats', { + id: integer('id').primaryKey(), + totalDuration: real('total_duration').notNull().default(0), + totalWords: integer('total_words').notNull().default(0), + sessionCount: integer('session_count').notNull().default(0), + streakDays: integer('streak_days').notNull().default(0), + lastSessionAt: integer('last_session_at'), + lastUpdated: integer('last_updated').notNull(), +}); + +// ── 타입 추출 ──────────────────────────────────────────── +export type History = typeof history.$inferSelect; +export type NewHistory = typeof history.$inferInsert; +export type Dictionary = typeof dictionary.$inferSelect; +export type NewDictionary = typeof dictionary.$inferInsert; +export type Stats = typeof stats.$inferSelect; +``` + +### 1.6 DB 초기화 코드 + +```typescript +// src/main/db/index.ts + +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { migrate } from 'drizzle-orm/better-sqlite3/migrator'; +import { app } from 'electron'; +import path from 'path'; +import * as schema from './schema'; + +let db: ReturnType; + +export function initDatabase(): typeof db { + const dbPath = path.join(app.getPath('userData'), 'd3ro-voice.db'); + const sqlite = new Database(dbPath); + + sqlite.pragma('journal_mode = WAL'); + sqlite.pragma('foreign_keys = ON'); + sqlite.pragma('busy_timeout = 5000'); + + db = drizzle(sqlite, { schema }); + migrate(db, { migrationsFolder: path.join(__dirname, 'migrations') }); + + // stats 싱글턴 초기화 + sqlite.exec(` + INSERT OR IGNORE INTO stats (id, total_duration, total_words, session_count, streak_days, last_updated) + VALUES (1, 0, 0, 0, 0, ${Date.now()}) + `); + + return db; +} + +export function getDatabase(): typeof db { + return db; +} +``` + +--- + +## 2. React 컴포넌트 명세 + +### 2.1 컴포넌트 트리 + +``` +App + ├── ThemeProvider (light/dark/auto) + │ ├── CssBaseline + │ └── AppLayout + │ ├── AppDrawer (240px, permanent variant) + │ │ ├── DrawerHeader (로고 + 앱 이름) + │ │ ├── NavItems + │ │ │ ├── NavItem[Dashboard] + │ │ │ ├── NavItem[History] + │ │ │ ├── NavItem[Dictionary] + │ │ │ └── NavItem[CustomCommand] + │ │ └── DrawerBottomBar + │ │ └── SettingsButton (→ Settings Modal) + │ └── ContentArea + │ ├── Dashboard + │ │ ├── StatsCard (총 녹음시간, 총 단어수, 세션수, 연속일) + │ │ ├── RecentSessions (최근 5개) + │ │ └── QuickActions (녹음 시작 버튼) + │ ├── HistoryPage + │ │ ├── SearchBar + │ │ ├── FilterBar (mode, status, language) + │ │ ├── HistoryList + │ │ │ └── HistoryItem (원본/다듬기 텍스트, 메타데이터) + │ │ └── Pagination + │ ├── DictionaryPage + │ │ ├── SearchBar + │ │ ├── AddWordDialog + │ │ └── WordList + │ │ └── WordItem (단어, 발음, 카테고리, 사용횟수) + │ ├── CustomCommandPage + │ │ ├── CommandList + │ │ └── CommandEditor + │ └── SettingsModal + │ ├── GeneralTab (테마, 언어, 자동실행) + │ ├── AudioTab (마이크 선택, 녹음 설정) + │ ├── SttTab (Whisper 모델 선택, 언어) + │ ├── LlmTab (Ollama 엔드포인트, 모델) + │ ├── TtsTab (Kokoro TTS 음성 선택, 속도, edge-tts 폴백) + │ ├── HotkeyTab (단축키 설정) + │ └── AboutTab (버전, 라이선스) +``` + +### 2.2 컴포넌트 상세 명세 + +#### App (루트) + +```typescript +// src/renderer/components/App.tsx + +interface AppState { + currentRoute: 'dashboard' | 'history' | 'dictionary' | 'customCommand'; + settingsOpen: boolean; + theme: 'light' | 'dark' | 'auto'; +} +``` + +| 항목 | 내용 | +|------|------| +| Props | 없음 (루트) | +| 내부 상태 | `currentRoute`, `settingsOpen`, `theme` | +| IPC 호출 | `config:getTheme` (초기화 시) | +| 이벤트 구독 | `config:themeChanged` | +| 렌더링 조건 | `currentRoute`로 switch 분기 (React Router 미사용) | +| MUI 컴포넌트 | `ThemeProvider`, `CssBaseline`, `Box` | + +#### AppDrawer + +```typescript +interface AppDrawerProps { + currentRoute: string; + onNavigate: (route: string) => void; + onSettingsOpen: () => void; +} +``` + +| 항목 | 내용 | +|------|------| +| Props | `currentRoute`, `onNavigate`, `onSettingsOpen` | +| 내부 상태 | 없음 | +| IPC 호출 | 없음 | +| 이벤트 구독 | 없음 | +| MUI 컴포넌트 | `Drawer` (permanent, 240px), `List`, `ListItemButton`, `ListItemIcon`, `ListItemText`, `Divider` | + +#### Dashboard + +```typescript +interface DashboardProps { + // 없음 +} + +interface DashboardState { + stats: Stats | null; + recentSessions: History[]; + isLoading: boolean; +} +``` + +| 항목 | 내용 | +|------|------| +| Props | 없음 | +| 내부 상태 | `stats`, `recentSessions`, `isLoading` | +| IPC 호출 | `db:getStats`, `db:getRecentHistory(5)` | +| 이벤트 구독 | `voice:sessionCompleted` (실시간 갱신) | +| 렌더링 조건 | `isLoading` → Skeleton, `stats === null` → 초기화 안내 | +| MUI 컴포넌트 | `Card`, `CardContent`, `Typography`, `Grid2`, `Skeleton`, `Button`, `Chip` | + +#### HistoryPage + +```typescript +interface HistoryPageState { + items: History[]; + total: number; + page: number; + pageSize: number; + search: string; + filters: { + mode: string | null; + status: string | null; + language: string | null; + }; + isLoading: boolean; +} +``` + +| 항목 | 내용 | +|------|------| +| Props | 없음 | +| 내부 상태 | `items`, `total`, `page`, `pageSize`, `search`, `filters`, `isLoading` | +| IPC 호출 | `db:searchHistory({ search, filters, page, pageSize })`, `db:deleteHistory(id)`, `db:retrySession(id)` | +| 이벤트 구독 | `voice:sessionCompleted` (리스트 갱신) | +| 렌더링 조건 | `isLoading` → Skeleton, `items.length === 0` → EmptyState | +| MUI 컴포넌트 | `TextField` (검색), `Select`/`MenuItem` (필터), `List`, `ListItem`, `IconButton`, `Pagination`, `Tooltip` | + +#### HistoryItem + +```typescript +interface HistoryItemProps { + item: History; + onDelete: (id: string) => void; + onRetry: (id: string) => void; + onCopy: (text: string) => void; +} +``` + +| 항목 | 내용 | +|------|------| +| Props | `item`, `onDelete`, `onRetry`, `onCopy` | +| 내부 상태 | `expanded: boolean`, `copied: boolean` | +| IPC 호출 | 없음 (부모 위임) | +| 렌더링 조건 | `item.polishedText` 존재 시 원본/다듬기 둘 다 표시, `item.status === 'error'` → 에러 칩 + 재시도 버튼 | +| MUI 컴포넌트 | `ListItem`, `ListItemText`, `Collapse`, `Chip`, `IconButton`, `Typography` | + +#### DictionaryPage + +```typescript +interface DictionaryPageState { + words: Dictionary[]; + search: string; + addDialogOpen: boolean; + isLoading: boolean; +} +``` + +| 항목 | 내용 | +|------|------| +| Props | 없음 | +| 내부 상태 | `words`, `search`, `addDialogOpen`, `isLoading` | +| IPC 호출 | `db:getDictionary({ search })`, `db:addWord(word)`, `db:deleteWord(id)`, `db:updateWord(id, data)` | +| 이벤트 구독 | 없음 | +| MUI 컴포넌트 | `TextField`, `Button`, `Dialog`, `DialogTitle`, `DialogContent`, `DialogActions`, `List`, `ListItem`, `IconButton` | + +#### SettingsModal + +```typescript +interface SettingsModalProps { + open: boolean; + onClose: () => void; +} + +interface SettingsModalState { + activeTab: 'general' | 'audio' | 'stt' | 'llm' | 'tts' | 'hotkey' | 'about'; + config: Record; + isDirty: boolean; +} +``` + +| 항목 | 내용 | +|------|------| +| Props | `open`, `onClose` | +| 내부 상태 | `activeTab`, `config`, `isDirty` | +| IPC 호출 | `config:getAll`, `config:set(key, value)`, `audio:getDevices`, `stt:getModels`, `llm:getModels`, `tts:getVoices` | +| 이벤트 구독 | `audio:devicesChanged` | +| 렌더링 조건 | `activeTab`으로 탭 패널 분기 | +| MUI 컴포넌트 | `Dialog` (fullWidth, maxWidth='md'), `Tabs`, `Tab`, `TabPanel` (커스텀), `TextField`, `Select`, `Switch`, `Slider`, `Button` | + +--- + +## 3. Vanilla JS 팝업 명세 + +팝업은 React 번들을 로드하지 않는다. 개별 HTML + 순수 JS로 빠른 로딩을 보장한다. + +### 3.1 RecordingTip 팝업 + +녹음 중 커서 근처에 표시되는 소형 팝업. 웨이브 바 애니메이션으로 녹음 상태를 시각적으로 표현한다. + +**파일 위치**: `src/renderer/popups/recording-tip/` + +#### HTML 구조 + +```html + + + + + + + +
+
+ +
+
+ +
+ 0:00 +
+ + + + + + +
+
+ + + +``` + +#### DOM 이벤트 핸들러 + +| 이벤트 | 대상 | 동작 | +|--------|------|------| +| `DOMContentLoaded` | `window` | 웨이브 바 9개 생성, IPC 리스너 등록 | +| `click` | `#container` | 녹음 취소 IPC 전송 | + +#### IPC 통신 채널 + +| 채널 | 방향 | 데이터 | 용도 | +|------|------|--------|------| +| `tip:prepare` | main→renderer | `{ state, params }` | 상태 전환 준비 (숨겨진 상태에서 측정) | +| `tip:show` | main→renderer | `{ state }` | 리사이즈 완료 후 표시 | +| `tip:hide` | main→renderer | — | 팝업 숨기기 | +| `tip:audioLevel` | main→renderer | `{ level: number }` | 오디오 레벨 (0.0~1.0) | +| `tip:measured` | renderer→main | `{ width, height }` | 측정된 콘텐츠 크기 전달 | +| `voice:cancel` | renderer→main | — | 녹음 취소 요청 | + +#### 상태별 렌더링 + +``` +recording → #recording-view 표시, 웨이브 바 애니메이션 활성, 경과 시간 카운터 +thinking → #thinking-view 표시, 프로그레스 바 애니메이션 +error → #error-view 표시, 에러 메시지, 3초 후 자동 숨김 +``` + +#### 웨이브 바 애니메이션 상세 + +```javascript +// 파라미터 +const BAR_COUNT = 9; +const UPDATE_INTERVAL = 100; // ms +const MIN_HEIGHT = 2; // px +const MAX_HEIGHT = 28; // px +const SMOOTHING = 0.5; // 보간 계수 +const RANDOM_FACTOR = 0.35; // ±35% 변동 + +// 코사인 분포 가중치 (중앙이 가장 높음) +const weights = Array.from({ length: BAR_COUNT }, (_, n) => { + const center = (BAR_COUNT - 1) / 2; // 4 + const normalized = (n - center) / center; // -1 ~ +1 + return Math.cos(normalized * Math.PI / 2); + // 결과: [0, 0.383, 0.707, 0.924, 1, 0.924, 0.707, 0.383, 0] +}); + +// 매 100ms 마다 실행 +let currentHeights = new Array(BAR_COUNT).fill(MIN_HEIGHT); + +function updateBars(audioLevel) { + for (let i = 0; i < BAR_COUNT; i++) { + const baseTarget = audioLevel * MAX_HEIGHT * weights[i]; + const randomized = baseTarget * (1 + (Math.random() - 0.5) * 2 * RANDOM_FACTOR); + const target = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, randomized)); + + // 스무딩 보간 + currentHeights[i] += (target - currentHeights[i]) * SMOOTHING; + bars[i].style.height = `${currentHeights[i]}px`; + } +} + +// setInterval(updateBars, UPDATE_INTERVAL) — tip:audioLevel 이벤트 수신 시 audioLevel 갱신 +``` + +#### Thinking 프로그레스 바 + +```javascript +// 시간 기반 점근 수렴: 95%에서 정체 +let thinkingStartTime = 0; + +function startThinking() { + thinkingStartTime = performance.now(); + requestAnimationFrame(updateThinkingProgress); +} + +function updateThinkingProgress() { + const elapsed = (performance.now() - thinkingStartTime) / 1000; // 초 + const progress = Math.min(95, (1 - 1 / (1 + 1.5 * elapsed)) * 100); + progressBar.style.width = `${progress}%`; + + if (progress < 95) { + requestAnimationFrame(updateThinkingProgress); + } +} + +// 완료 시 → 100%로 빠르게 채운 뒤 hide +function completeThinking() { + progressBar.style.transition = 'width 200ms ease-out'; + progressBar.style.width = '100%'; + setTimeout(() => hide(), 300); +} +``` + +#### CSS 애니메이션 스펙 + +```css +.recording-tip { + background: rgba(0, 0, 0, 0.85); + border-radius: 8px; + padding: 8px 12px; + display: flex; + align-items: center; + gap: 8px; + backdrop-filter: blur(10px); + transition: opacity 150ms ease-in-out; +} + +.wave-bars { + display: flex; + align-items: center; + gap: 2px; + height: 32px; +} + +.wave-bar { + width: 3px; + background: #1F5DF2; /* primary color */ + border-radius: 1.5px; + transition: height 100ms ease-out; +} + +.progress-bar { + height: 3px; + background: #1F5DF2; + border-radius: 1.5px; + transition: width 100ms linear; +} + +.view { display: flex; align-items: center; gap: 8px; } +.view.hidden { display: none; } +``` + +### 3.2 ResultPopup 팝업 + +전사/다듬기 결과를 표시하는 팝업. 커서 근처에 나타나며 자동으로 사라진다. + +**파일 위치**: `src/renderer/popups/result-popup/` + +#### HTML 구조 + +```html + + + + + + + +
+
+
+
+ + +
+
+
+ + + +``` + +#### DOM 이벤트 핸들러 + +| 이벤트 | 대상 | 동작 | +|--------|------|------| +| `DOMContentLoaded` | `window` | IPC 리스너 등록 | +| `click` | `#copy-btn` | 텍스트 클립보드 복사, `.copied` 클래스 2초 | +| `click` | `#retry-btn` | `voice:retry` IPC 전송 | +| `mouseenter` | `#container` | auto-close 타이머 일시정지 | +| `mouseleave` | `#container` | auto-close 타이머 재개 | + +#### IPC 통신 채널 + +| 채널 | 방향 | 데이터 | 용도 | +|------|------|--------|------| +| `result:prepare` | main→renderer | `{ text, mode }` | 결과 텍스트 세팅 + 크기 측정 | +| `result:show` | main→renderer | — | 리사이즈 완료 후 표시 | +| `result:hide` | main→renderer | — | 팝업 숨기기 | +| `result:measured` | renderer→main | `{ width, height }` | 측정된 크기 전달 | +| `voice:retry` | renderer→main | — | 재시도 요청 | + +#### 2-Phase 리사이즈 + 높이 측정 + +```javascript +// Phase 1: prepare — 숨겨진 상태에서 콘텐츠 렌더링 후 크기 측정 +window.electronAPI.on('result:prepare', ({ text, mode }) => { + resultText.textContent = text; + container.className = `result-popup ${mode}`; + + // requestAnimationFrame으로 레이아웃 완료 대기 후 측정 + requestAnimationFrame(() => { + requestAnimationFrame(() => { + const rect = container.getBoundingClientRect(); + window.electronAPI.send('result:measured', { + width: Math.ceil(rect.width), + height: Math.ceil(rect.height), + }); + }); + }); +}); + +// Phase 2: show — 메인 프로세스가 윈도우 리사이즈 완료 후 호출 +window.electronAPI.on('result:show', () => { + container.classList.add('visible'); + startAutoCloseTimer(5000); // 5초 후 자동 닫기 +}); +``` + +#### 복사 버튼 + `.copied` 클래스 + +```javascript +copyBtn.addEventListener('click', () => { + navigator.clipboard.writeText(resultText.textContent); + copyBtn.classList.add('copied'); + copyIcon.classList.add('hidden'); + checkIcon.classList.remove('hidden'); + + setTimeout(() => { + copyBtn.classList.remove('copied'); + copyIcon.classList.remove('hidden'); + checkIcon.classList.add('hidden'); + }, 2000); +}); +``` + +#### Auto-close 제어 + +```javascript +let autoCloseTimer = null; +let remainingTime = 0; +let lastTick = 0; + +function startAutoCloseTimer(ms) { + remainingTime = ms; + lastTick = Date.now(); + autoCloseTimer = setInterval(() => { + remainingTime -= (Date.now() - lastTick); + lastTick = Date.now(); + if (remainingTime <= 0) { + clearInterval(autoCloseTimer); + window.electronAPI.send('result:hide'); + } + }, 100); +} + +container.addEventListener('mouseenter', () => { + clearInterval(autoCloseTimer); // 호버 중 타이머 정지 +}); + +container.addEventListener('mouseleave', () => { + startAutoCloseTimer(remainingTime > 0 ? remainingTime : 2000); // 남은 시간 또는 2초 +}); +``` + +#### CSS 애니메이션 + +```css +.result-popup { + background: #FFFFFF; + border: 1px solid rgba(0, 0, 0, 0.08); + border-radius: 12px; + padding: 12px 16px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12); + opacity: 0; + transform: translateY(4px); + transition: opacity 200ms ease-out, transform 200ms ease-out; + max-width: 400px; +} + +.result-popup.visible { + opacity: 1; + transform: translateY(0); +} + +.action-btn { /* ... */ } +.action-btn.copied { + color: #4CAF50; + transition: color 200ms ease; +} + +/* 다크모드 */ +@media (prefers-color-scheme: dark) { + .result-popup { + background: #1E1E1E; + border-color: rgba(255, 255, 255, 0.08); + } +} +``` + +--- + +## 4. MUI 테마 전체 정의 + +Speakly 테마 분석 결과를 기반으로 한 완전한 createTheme() 코드. + +```typescript +// src/renderer/theme.ts + +import { createTheme, type ThemeOptions } from '@mui/material/styles'; + +const commonOptions: ThemeOptions = { + typography: { + fontFamily: [ + '-apple-system', + 'BlinkMacSystemFont', + '"Segoe UI"', + 'Roboto', + '"Helvetica Neue"', + 'Arial', + 'sans-serif', + ].join(','), + h4: { fontWeight: 600, fontSize: '1.5rem' }, + h5: { fontWeight: 600, fontSize: '1.25rem' }, + h6: { fontWeight: 600, fontSize: '1rem' }, + subtitle1: { fontWeight: 500 }, + body1: { fontSize: '0.9375rem' }, + body2: { fontSize: '0.8125rem' }, + button: { textTransform: 'none' as const, fontWeight: 500 }, + }, + shape: { + borderRadius: 12, + }, + components: { + MuiButton: { + defaultProps: { + disableElevation: true, + }, + styleOverrides: { + root: { + textTransform: 'none', + fontWeight: 500, + borderRadius: 8, + padding: '8px 16px', + }, + containedPrimary: { + '&:hover': { boxShadow: '0 2px 8px rgba(31, 93, 242, 0.3)' }, + }, + }, + }, + MuiCard: { + defaultProps: { + elevation: 0, + }, + styleOverrides: { + root: { + borderRadius: 12, + border: '1px solid', + }, + }, + }, + MuiDrawer: { + styleOverrides: { + paper: { + width: 240, + borderRight: 'none', + }, + }, + }, + MuiListItemButton: { + styleOverrides: { + root: { + borderRadius: 8, + marginLeft: 8, + marginRight: 8, + '&.Mui-selected': { + fontWeight: 600, + }, + }, + }, + }, + MuiDialog: { + styleOverrides: { + paper: { + borderRadius: 16, + }, + }, + }, + MuiTextField: { + defaultProps: { + size: 'small', + variant: 'outlined', + }, + }, + MuiChip: { + styleOverrides: { + root: { + borderRadius: 6, + fontWeight: 500, + }, + }, + }, + MuiTooltip: { + defaultProps: { + arrow: true, + }, + }, + }, +}; + +export const lightTheme = createTheme({ + ...commonOptions, + palette: { + mode: 'light', + primary: { + main: 'rgb(31, 93, 242)', // #1F5DF2 + light: 'rgb(71, 133, 255)', + dark: 'rgb(20, 65, 180)', + contrastText: '#FFFFFF', + }, + secondary: { + main: 'rgb(108, 117, 125)', + light: 'rgb(173, 181, 189)', + dark: 'rgb(73, 80, 87)', + }, + background: { + default: '#F9F9F9', + paper: '#FFFFFF', + }, + text: { + primary: 'rgba(0, 0, 0, 0.87)', + secondary: 'rgba(0, 0, 0, 0.6)', + }, + divider: 'rgba(0, 0, 0, 0.08)', + error: { + main: '#D32F2F', + light: '#EF5350', + }, + success: { + main: '#2E7D32', + light: '#4CAF50', + }, + warning: { + main: '#ED6C02', + }, + }, + components: { + ...commonOptions.components, + MuiCard: { + ...commonOptions.components?.MuiCard, + styleOverrides: { + root: { + borderRadius: 12, + border: '1px solid rgba(0, 0, 0, 0.08)', + backgroundColor: '#FFFFFF', + }, + }, + }, + }, +}); + +export const darkTheme = createTheme({ + ...commonOptions, + palette: { + mode: 'dark', + primary: { + main: 'rgb(71, 133, 255)', // 밝은 블루 (다크모드에서 가독성) + light: 'rgb(120, 170, 255)', + dark: 'rgb(31, 93, 242)', + contrastText: '#FFFFFF', + }, + secondary: { + main: 'rgb(173, 181, 189)', + light: 'rgb(206, 212, 218)', + dark: 'rgb(108, 117, 125)', + }, + background: { + default: '#121212', + paper: '#1E1E1E', + }, + text: { + primary: 'rgba(255, 255, 255, 0.87)', + secondary: 'rgba(255, 255, 255, 0.6)', + }, + divider: 'rgba(255, 255, 255, 0.08)', + error: { + main: '#EF5350', + light: '#FF7961', + }, + success: { + main: '#4CAF50', + light: '#66BB6A', + }, + warning: { + main: '#FFA726', + }, + }, + components: { + ...commonOptions.components, + MuiCard: { + ...commonOptions.components?.MuiCard, + styleOverrides: { + root: { + borderRadius: 12, + border: '1px solid rgba(255, 255, 255, 0.08)', + backgroundColor: '#1E1E1E', + }, + }, + }, + }, +}); + +// 테마 선택 유틸 +export function getTheme(mode: 'light' | 'dark'): typeof lightTheme { + return mode === 'dark' ? darkTheme : lightTheme; +} +``` + +--- + +## 5. 에러 UI 패턴 + +### 5.1 에러 분류 + +| 카테고리 | 에러 코드 | 재시도 가능 | UI 표시 위치 | +|----------|-----------|------------|-------------| +| STT | `STT_MODEL_NOT_FOUND` | X | SettingsModal 유도 | +| STT | `STT_PROCESS_CRASHED` | O | RecordingTip → error 상태 | +| STT | `STT_TIMEOUT` | O | RecordingTip → error 상태 | +| LLM | `LLM_CONNECTION_FAILED` | O | RecordingTip → error 상태 | +| LLM | `LLM_MODEL_NOT_FOUND` | X | SettingsModal 유도 | +| LLM | `LLM_TIMEOUT` | O | RecordingTip → error 상태 | +| Audio | `AUDIO_DEVICE_NOT_FOUND` | X | 시스템 토스트 | +| Audio | `AUDIO_PERMISSION_DENIED` | X | 시스템 토스트 + 권한 안내 | +| Audio | `AUDIO_TOO_SHORT` | X | RecordingTip → 무시 (자동 숨김) | +| Hotkey | `HOTKEY_REGISTER_FAILED` | X | SettingsModal 유도 | +| TextInsert | `INSERT_FAILED` | O | ResultPopup → 에러 상태 | +| DB | `DB_WRITE_FAILED` | X | 백그라운드 로그만 | + +### 5.2 RecordingTip 에러 표시 + +```javascript +function showError(errorCode, message) { + hideAllViews(); + errorView.classList.remove('hidden'); + errorText.textContent = message; + + const isRetryable = RETRYABLE_ERRORS.has(errorCode); + const hideDelay = isRetryable ? 10000 : 3000; + + if (isRetryable) { + errorText.textContent += ' (다시 시도해주세요)'; + } + + setTimeout(() => { + window.electronAPI.send('tip:hide'); + }, hideDelay); +} + +const RETRYABLE_ERRORS = new Set([ + 'STT_PROCESS_CRASHED', + 'STT_TIMEOUT', + 'LLM_CONNECTION_FAILED', + 'LLM_TIMEOUT', + 'INSERT_FAILED', +]); +``` + +### 5.3 메인 앱 에러 표시 (React) + +```typescript +// Snackbar 기반 토스트 알림 +interface ErrorToastProps { + error: { code: string; message: string } | null; + onClose: () => void; +} + +// 자동 숨김 타이밍 +const AUTO_HIDE_DURATION: Record = { + default: 3000, // 일반 에러: 3초 + retryable: 10000, // 재시도 가능: 10초 + critical: null as never, // 치명적: 수동 닫기만 +}; +``` + +### 5.4 에러 흐름 요약 + +``` +에러 발생 (main process) + ├── RecordingTip 활성 중? + │ ├── YES → tip:prepare({ state: 'error', params: { code, message } }) + │ │ → 3초/10초 후 자동 숨김 + │ └── NO → webContents.send('app:error', { code, message }) + │ → React Snackbar 표시 + │ + ├── 설정 유도 필요? + │ └── YES → 에러 메시지에 "설정 열기" 액션 버튼 포함 + │ → 클릭 시 SettingsModal 해당 탭으로 이동 + │ + └── 로그 기록 (항상) + → logger.error({ code, message, stack }) +``` + +--- + +## 부록: Speakly 원본 스키마 참조 + +아래는 Speakly `genspark-flow.db`에서 추출한 원본 스키마이다. D3RO-VOICE 스키마 설계 시 참조 자료로 사용했다. + +```sql +-- Speakly 원본 (참조용, D3RO-VOICE에서 직접 사용하지 않음) + +CREATE TABLE history ( + id TEXT PRIMARY KEY, + original_text TEXT NOT NULL, + polished_text TEXT, + focused_app TEXT, + focused_app_name TEXT, + focused_app_bundle_id TEXT, + focused_app_window_title TEXT, + window_web_title TEXT, + window_web_domain TEXT, + window_web_url TEXT, + mode TEXT NOT NULL DEFAULT 'dictation', + status TEXT NOT NULL DEFAULT 'completed', + audio_local_path TEXT, + audio_metadata TEXT, + duration REAL NOT NULL, + detected_language TEXT, + mic_device TEXT, + mic_device_info TEXT, + word_count INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + user_id TEXT, + app_version TEXT NOT NULL DEFAULT '1.0.0', + selected_text TEXT +); + +CREATE TABLE dictionary ( + id TEXT PRIMARY KEY, + word TEXT NOT NULL, + pronunciation TEXT, + dict_type TEXT NOT NULL DEFAULT 'user', + user_id TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + usage_count INTEGER NOT NULL DEFAULT 0, + last_used_at INTEGER +); + +CREATE TABLE stats ( + id INTEGER PRIMARY KEY, + total_duration REAL NOT NULL DEFAULT 0, + total_words INTEGER NOT NULL DEFAULT 0, + session_count INTEGER NOT NULL DEFAULT 0, + last_updated INTEGER NOT NULL +); + +CREATE TABLE dict_sync_meta ( + user_id TEXT NOT NULL, + dict_type TEXT NOT NULL, + cloud_mtime TEXT, + PRIMARY KEY (user_id, dict_type) +); +``` diff --git a/docs/design/04-verification-report.md b/docs/design/04-verification-report.md new file mode 100644 index 0000000..9b35f8f --- /dev/null +++ b/docs/design/04-verification-report.md @@ -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` 상태가 빠져 있어 상태 전이 다이어그램 수정이 필요하다. diff --git a/docs/design/05-external-engine-integration.md b/docs/design/05-external-engine-integration.md new file mode 100644 index 0000000..29656f5 --- /dev/null +++ b/docs/design/05-external-engine-integration.md @@ -0,0 +1,1158 @@ +# D3RO-VOICE 외부 AI 엔진 연동 상세 설계서 + +> 버전: 1.0 +> 작성일: 2026-04-04 +> 기반: 01-service-specifications.md의 LocalSTT, LocalLLM, LocalTTS 서비스 명세 + +--- + +## 목차 + +1. [faster-whisper STT Sidecar](#1-faster-whisper-stt-sidecar) +2. [Ollama LLM REST API](#2-ollama-llm-rest-api) +3. [TTS 엔진 (Kokoro + edge-tts)](#3-tts-엔진-kokoro--edge-tts) +4. [통합 시퀀스 다이어그램](#4-통합-시퀀스-다이어그램) +5. [에러 시나리오 & 복구 전략](#5-에러-시나리오--복구-전략) +6. [모델 추천 & 한국어 성능](#6-모델-추천--한국어-성능) + +--- + +## 1. faster-whisper STT Sidecar + +### 1.1 아키텍처 결정: Python HTTP Sidecar + +**선택: 로컬 HTTP 서버 (FastAPI)** + +| 방식 | 장점 | 단점 | 판정 | +|------|------|------|------| +| stdin/stdout JSON Lines | 단순, 의존성 없음 | 바이너리(오디오) 전송 비효율, 에러 핸들링 복잡 | △ | +| 로컬 HTTP 서버 | 표준 프로토콜, 멀티파트 오디오 전송, health check 용이 | 포트 관리 필요 | **◎ 채택** | +| WebSocket | 양방향 스트리밍 | 과도한 복잡성 | △ | + +**채택 근거:** +- HTTP는 Electron의 `fetch()` API와 자연스럽게 호환 +- health check (`GET /health`)가 HTTP 수준에서 가능 +- 오디오 데이터를 multipart/form-data로 효율적 전송 +- 향후 remote-whisper (GPU 서버)로 전환 시 코드 변경 최소화 + +### 1.2 Sidecar Python 서버 설계 + +```python +# sidecar/whisper_server.py +from fastapi import FastAPI, UploadFile, File +from faster_whisper import WhisperModel +import uvicorn +import sys +import json + +app = FastAPI() +model: WhisperModel | None = None + +@app.get("/health") +async def health(): + return { + "status": "ready" if model else "loading", + "model": current_model_id, + "device": current_device + } + +@app.post("/load") +async def load_model(body: dict): + """모델 로드/교체""" + global model, current_model_id + model_id = body["model_id"] # "base", "small", "medium", "large-v3" + device = body.get("device", "auto") # "auto", "cpu", "cuda" + compute_type = body.get("compute_type", "int8") + + model = WhisperModel( + model_id, + device=device, + compute_type=compute_type, + download_root=body.get("model_dir", "./models") + ) + current_model_id = model_id + return {"status": "loaded", "model_id": model_id} + +@app.post("/transcribe") +async def transcribe( + audio: UploadFile = File(...), + language: str = "auto", + initial_prompt: str = "", + vad_filter: bool = True +): + """PCM16 16kHz mono 오디오를 전사""" + import numpy as np + import io + + audio_bytes = await audio.read() + audio_np = np.frombuffer(audio_bytes, dtype=np.int16).astype(np.float32) / 32768.0 + + segments, info = model.transcribe( + audio_np, + beam_size=5, + language=None if language == "auto" else language, + initial_prompt=initial_prompt or None, + vad_filter=vad_filter, + vad_parameters=dict(min_silence_duration_ms=500), + word_timestamps=False + ) + + result_segments = [] + full_text = "" + for seg in segments: + result_segments.append({ + "text": seg.text, + "start": seg.start, + "end": seg.end, + "confidence": seg.avg_log_prob + }) + full_text += seg.text + + return { + "text": full_text.strip(), + "segments": result_segments, + "language": info.language, + "language_probability": info.language_probability, + "duration": info.duration + } + +@app.post("/shutdown") +async def shutdown(): + """Graceful shutdown""" + import asyncio + asyncio.get_event_loop().call_later(0.5, sys.exit, 0) + return {"status": "shutting_down"} + +if __name__ == "__main__": + port = int(sys.argv[1]) if len(sys.argv) > 1 else 0 # 0 = 랜덤 포트 + uvicorn.run(app, host="127.0.0.1", port=port) +``` + +### 1.3 Electron Main Process에서 Sidecar 관리 + +```typescript +// src/main/services/stt/WhisperSidecar.ts + +import { spawn, ChildProcess } from 'child_process'; +import { EventEmitter } from 'events'; +import getPort from 'get-port'; + +interface SidecarOptions { + pythonPath: string; // 내장 Python 또는 시스템 Python + scriptPath: string; // sidecar/whisper_server.py 경로 + modelDir: string; // 모델 다운로드 디렉토리 +} + +class WhisperSidecar extends EventEmitter { + private process: ChildProcess | null = null; + private port: number = 0; + private baseUrl: string = ''; + + async start(options: SidecarOptions): Promise { + this.port = await getPort(); // 사용 가능한 랜덤 포트 확보 + + this.process = spawn(options.pythonPath, [ + options.scriptPath, + String(this.port), + ], { + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, PYTHONUNBUFFERED: '1' }, + }); + + this.baseUrl = `http://127.0.0.1:${this.port}`; + + // stderr 로깅 + this.process.stderr?.on('data', (data: Buffer) => { + const msg = data.toString(); + // uvicorn 시작 로그에서 포트 확인 + if (msg.includes('Uvicorn running')) { + this.emit('ready'); + } + }); + + // 크래시 감지 & 자동 재시작 + this.process.on('exit', (code, signal) => { + if (code !== 0 && signal !== 'SIGTERM') { + this.emit('crash', { code, signal }); + this.scheduleRestart(options); + } + }); + + // 시작 후 health check 폴링 + await this.waitForReady(10_000); + } + + private async waitForReady(timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const res = await fetch(`${this.baseUrl}/health`); + if (res.ok) return; + } catch { /* 아직 시작 안 됨 */ } + await new Promise(r => setTimeout(r, 200)); + } + throw new Error('Whisper sidecar failed to start within timeout'); + } + + private restartCount = 0; + private scheduleRestart(options: SidecarOptions): void { + if (this.restartCount >= 3) { + this.emit('error', { code: 'STT_SIDECAR_CRASH', message: 'Sidecar crashed 3 times' }); + return; + } + this.restartCount++; + const delay = Math.min(1000 * Math.pow(2, this.restartCount), 10_000); + setTimeout(() => this.start(options), delay); + } + + async loadModel(modelId: string, device = 'auto'): Promise { + const res = await fetch(`${this.baseUrl}/load`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model_id: modelId, device }), + }); + if (!res.ok) throw new Error(`Model load failed: ${res.statusText}`); + } + + async transcribe(audioBuffer: Buffer, options: { + language?: string; + initialPrompt?: string; + vadFilter?: boolean; + } = {}): Promise { + const formData = new FormData(); + formData.append('audio', new Blob([audioBuffer]), 'audio.pcm'); + if (options.language) formData.append('language', options.language); + if (options.initialPrompt) formData.append('initial_prompt', options.initialPrompt); + formData.append('vad_filter', String(options.vadFilter ?? true)); + + const res = await fetch(`${this.baseUrl}/transcribe`, { + method: 'POST', + body: formData, + }); + + if (!res.ok) throw new Error(`Transcription failed: ${res.statusText}`); + return res.json(); + } + + async shutdown(): Promise { + try { + await fetch(`${this.baseUrl}/shutdown`, { method: 'POST' }); + } catch { /* 이미 종료됨 */ } + this.process?.kill('SIGTERM'); + this.process = null; + } +} +``` + +### 1.4 모델 다운로드 & 관리 + +faster-whisper는 Hugging Face Hub에서 CTranslate2 변환 모델을 자동 다운로드한다. + +```typescript +// 모델 크기별 정보 +const WHISPER_MODELS = { + 'tiny': { size: 75_000_000, vram: 1_000, rtf_cpu: 6.0 }, + 'base': { size: 145_000_000, vram: 1_000, rtf_cpu: 4.0 }, + 'small': { size: 484_000_000, vram: 2_000, rtf_cpu: 2.0 }, + 'medium': { size: 1_530_000_000, vram: 5_000, rtf_cpu: 1.0 }, + 'large-v3': { size: 3_090_000_000, vram: 10_000, rtf_cpu: 0.5 }, + 'turbo': { size: 1_620_000_000, vram: 6_000, rtf_cpu: 0.8 }, +} as const; + +// 모델 디렉토리: {app.getPath('userData')}/models/whisper/ +// faster-whisper가 download_root 옵션으로 자동 관리 +// 다운로드 진행률은 Python 측에서 huggingface_hub 콜백 사용 +``` + +**모델 다운로드 진행률 전달:** + +```python +# sidecar에 다운로드 전용 엔드포인트 추가 +@app.post("/download") +async def download_model(body: dict): + """모델 다운로드 (SSE로 진행률 스트리밍)""" + from starlette.responses import StreamingResponse + + async def progress_stream(): + from huggingface_hub import snapshot_download + import threading + + # snapshot_download는 동기이므로 스레드에서 실행 + # tqdm 콜백 훅으로 진행률 전달 + yield json.dumps({"status": "downloading", "progress": 0}) + "\n" + + snapshot_download( + f"Systran/faster-whisper-{body['model_id']}", + local_dir=f"./models/faster-whisper-{body['model_id']}" + ) + + yield json.dumps({"status": "complete", "progress": 100}) + "\n" + + return StreamingResponse(progress_stream(), media_type="application/x-ndjson") +``` + +### 1.5 실시간 전사 (transcription_delta) 가능 여부 + +**결론: 제한적으로 가능** + +faster-whisper 자체는 진정한 스트리밍을 지원하지 않는다. `transcribe()`는 전체 오디오를 받아 세그먼트를 generator로 반환하지만, 각 세그먼트는 30초 윈도우 단위로 처리된다. + +**의사-스트리밍 구현 전략:** + +``` +[오디오 버퍼 누적 중] + │ + ├── 매 2-3초마다 현재까지의 버퍼로 transcribe → delta 이벤트 + │ (마지막 세그먼트는 불완전할 수 있으므로 isFinal: false) + │ + └── 녹음 종료 시 전체 버퍼로 최종 transcribe → isFinal: true +``` + +이 접근은 WhisperLive/whisper_streaming 프로젝트가 사용하는 패턴과 동일하다. 지연시간은 세그먼트 단위(~2-5초)이며, 실시간 자막 수준의 즉각적 피드백은 아니다. + +**D3RO-VOICE에서의 실용적 선택:** +- 녹음 완료 후 일괄 전사 (기본 모드) — 가장 정확 +- 녹음 중 중간 델타 표시 (옵션) — UI 피드백용, 최종 결과와 다를 수 있음 + +### 1.6 대안 분석 + +#### whisper.cpp Node.js 바인딩 + +| 패키지 | 최종 업데이트 | 특징 | 판정 | +|--------|-------------|------|------| +| `@fugood/whisper.node` | 2026-03 | 활발한 유지보수, whisper.rn과 API 호환 | 후보 | +| `whisper-node` | 2023-11 | 방치됨 | ✗ | +| `nodejs-whisper` | 2025-05 | CLI 래퍼, 프로세스 스폰 | △ | +| `smart-whisper` | 2025 | 모델 자동 오프로딩 | △ | + +**`@fugood/whisper.node` 장점:** Python 의존성 제거, Electron 네이티브 모듈로 직접 통합 +**단점:** GPU 가속 제한적 (CUDA 지원은 빌드 필요), faster-whisper 대비 성능 약간 열세 + +**권장:** MVP에서는 faster-whisper Python sidecar 사용, 향후 `@fugood/whisper.node`로 마이그레이션 검토 + +#### Ollama에 Whisper 로드 가능 여부 + +**불가능.** Ollama는 텍스트 생성 LLM 전용이며, 오디오→텍스트 모달리티를 지원하지 않는다. + +--- + +## 2. Ollama LLM REST API + +### 2.1 엔드포인트 전체 명세 + +기본 URL: `http://localhost:11434` + +#### POST /api/generate — 텍스트 생성 + +```typescript +// 요청 +interface OllamaGenerateRequest { + model: string; // 필수: "qwen3:4b" + prompt: string; // 필수 + suffix?: string; // FIM (Fill-in-Middle) 지원 모델용 + system?: string; // 시스템 프롬프트 + format?: 'json' | object; // JSON 모드 또는 JSON schema + stream?: boolean; // 기본 true + options?: { + temperature?: number; // 0.0~2.0, 기본 0.8 + top_p?: number; // 0.0~1.0 + top_k?: number; // 기본 40 + num_predict?: number; // 최대 토큰 수, 기본 -1(무제한) + stop?: string[]; // stop 시퀀스 + }; + keep_alive?: string; // 모델 메모리 유지: "5m", "0"(즉시 해제) +} + +// 스트리밍 응답 (NDJSON, 각 줄이 JSON 객체) +interface OllamaGenerateStreamChunk { + model: string; + created_at: string; // ISO 8601 + response: string; // 생성된 토큰 + done: boolean; +} + +// 최종 응답 (done: true일 때 추가 필드) +interface OllamaGenerateFinalChunk extends OllamaGenerateStreamChunk { + done: true; + total_duration: number; // 나노초 + load_duration: number; // 나노초 + prompt_eval_count: number; // 프롬프트 토큰 수 + prompt_eval_duration: number;// 나노초 + eval_count: number; // 생성 토큰 수 + eval_duration: number; // 나노초 + context: number[]; // 컨텍스트 토큰 (후속 요청에 재사용 가능) +} +``` + +#### POST /api/chat — 대화형 생성 + +```typescript +interface OllamaChatRequest { + model: string; + messages: Array<{ + role: 'system' | 'user' | 'assistant'; + content: string; + images?: string[]; // base64 이미지 (멀티모달 모델) + }>; + stream?: boolean; + format?: 'json' | object; + tools?: OllamaTool[]; // 함수 호출 + options?: OllamaModelOptions; + keep_alive?: string; +} + +// 스트리밍 응답 +interface OllamaChatStreamChunk { + model: string; + created_at: string; + message: { + role: 'assistant'; + content: string; // 토큰 조각 + }; + done: boolean; +} +``` + +#### GET /api/tags — 로컬 모델 목록 + +```typescript +// GET http://localhost:11434/api/tags +interface OllamaTagsResponse { + models: Array<{ + name: string; // "qwen3:4b" + model: string; + modified_at: string; // ISO 8601 + size: number; // 바이트 + digest: string; // SHA256 + details: { + parent_model: string; + format: string; // "gguf" + family: string; // "qwen3" + parameter_size: string; // "4B" + quantization_level: string; // "Q4_K_M" + }; + }>; +} +``` + +#### POST /api/pull — 모델 다운로드 + +```typescript +// 요청 +interface OllamaPullRequest { + name: string; // "qwen3:4b" + stream?: boolean; // 기본 true +} + +// 스트리밍 응답 (진행률) +interface OllamaPullStreamChunk { + status: string; // "pulling manifest", "downloading sha256:...", "success" + digest?: string; + total?: number; // 전체 바이트 + completed?: number; // 완료 바이트 +} +``` + +#### POST /api/show — 모델 정보 + +```typescript +// POST http://localhost:11434/api/show +// 요청: { "name": "qwen3:4b" } +interface OllamaShowResponse { + modelfile: string; + parameters: string; + template: string; + details: { + parent_model: string; + format: string; + family: string; + parameter_size: string; + quantization_level: string; + }; + model_info: Record; +} +``` + +#### DELETE /api/delete — 모델 삭제 + +```typescript +// DELETE http://localhost:11434/api/delete +// 요청: { "name": "qwen3:4b" } +// 응답: 200 OK (성공) | 404 Not Found +``` + +### 2.2 Node.js 클라이언트 구현 + +```typescript +// src/main/services/llm/OllamaClient.ts + +const OLLAMA_BASE = 'http://localhost:11434'; + +class OllamaClient { + private abortController: AbortController | null = null; + + /** Ollama 서버 가용성 확인 */ + async checkAvailability(): Promise { + try { + const res = await fetch(`${OLLAMA_BASE}/api/tags`, { + signal: AbortSignal.timeout(3000), + }); + return res.ok; + } catch { + return false; + } + } + + /** 모델 목록 조회 */ + async getModels(): Promise { + const res = await fetch(`${OLLAMA_BASE}/api/tags`); + if (!res.ok) throw this.toServiceError(res); + return res.json(); + } + + /** 비스트리밍 생성 */ + async generate(prompt: string, options: GenerateOptions = {}): Promise { + const res = await fetch(`${OLLAMA_BASE}/api/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: options.model ?? 'qwen3:4b', + prompt, + system: options.systemPrompt, + stream: false, + options: { + temperature: options.temperature ?? 0.7, + num_predict: options.maxTokens ?? 2048, + top_p: options.topP, + top_k: options.topK, + }, + }), + }); + + if (!res.ok) throw this.toServiceError(res); + const data = await res.json(); + + return { + text: data.response, + model: data.model, + promptTokens: data.prompt_eval_count ?? 0, + completionTokens: data.eval_count ?? 0, + totalDuration: Math.round((data.total_duration ?? 0) / 1_000_000), // ns → ms + }; + } + + /** 스트리밍 생성 */ + stream( + prompt: string, + options: Omit, + onToken: (token: string, done: boolean) => void, + ): AbortController { + const controller = new AbortController(); + this.abortController = controller; + + const run = async () => { + const res = await fetch(`${OLLAMA_BASE}/api/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: options.model ?? 'qwen3:4b', + prompt, + system: options.systemPrompt, + stream: true, + options: { + temperature: options.temperature ?? 0.7, + num_predict: options.maxTokens ?? 2048, + }, + }), + signal: controller.signal, + }); + + if (!res.ok) throw this.toServiceError(res); + + const reader = res.body!.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop()!; // 마지막 불완전한 줄 보존 + + for (const line of lines) { + if (!line.trim()) continue; + const chunk = JSON.parse(line) as OllamaGenerateStreamChunk; + onToken(chunk.response, chunk.done); + } + } + }; + + run().catch(err => { + if (err.name !== 'AbortError') { + onToken('', true); // 에러 시에도 done 신호 + } + }); + + return controller; + } + + /** 모델 다운로드 (진행률 콜백) */ + async pullModel( + name: string, + onProgress: (status: string, completed: number, total: number) => void, + ): Promise { + const res = await fetch(`${OLLAMA_BASE}/api/pull`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, stream: true }), + }); + + const reader = res.body!.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop()!; + + for (const line of lines) { + if (!line.trim()) continue; + const chunk = JSON.parse(line) as OllamaPullStreamChunk; + onProgress(chunk.status, chunk.completed ?? 0, chunk.total ?? 0); + } + } + } + + /** 현재 스트리밍 취소 */ + abort(): void { + this.abortController?.abort(); + this.abortController = null; + } + + private toServiceError(res: Response): ServiceError { + return { + code: res.status === 404 ? 'LLM_MODEL_NOT_FOUND' : 'LLM_CONNECTION_FAILED', + message: `Ollama API error: ${res.status} ${res.statusText}`, + }; + } +} +``` + +### 2.3 시스템 프롬프트 설계 + +```typescript +const SYSTEM_PROMPTS = { + /** 텍스트 다듬기 (문체 교정) */ + polish_formal: `당신은 한국어 텍스트 교정 전문가입니다. +사용자가 제공하는 음성 인식 결과를 자연스럽고 격식체인 문장으로 다듬어주세요. +- 구어체를 문어체로 변환 +- 불필요한 반복이나 말더듬 제거 +- 맞춤법과 문법 교정 +- 원본 의미를 절대 변경하지 마세요 +교정된 텍스트만 출력하세요. 설명이나 부연은 붙이지 마세요.`, + + polish_casual: `당신은 한국어 텍스트 교정 전문가입니다. +사용자가 제공하는 음성 인식 결과를 자연스러운 구어체로 다듬어주세요. +- 말더듬, 반복어 제거 +- 맞춤법 교정 +- 자연스러운 대화체 유지 +교정된 텍스트만 출력하세요.`, + + /** 번역 */ + translate: (targetLang: string) => `You are a professional translator. +Translate the following text to ${targetLang}. +Output ONLY the translated text, nothing else.`, + + /** 요약 */ + summarize: `당신은 요약 전문가입니다. +사용자가 제공하는 텍스트를 핵심만 간결하게 요약해주세요. +3문장 이내로 요약하세요. 요약문만 출력하세요.`, + + /** 사용자 정의 명령어용 래퍼 */ + custom: (userPrompt: string) => `다음 지시에 따라 텍스트를 처리해주세요: +${userPrompt} +처리된 텍스트만 출력하세요.`, +} as const; +``` + +### 2.4 가용성 모니터링 & 에러 처리 + +```typescript +// Ollama 가용성 폴링 +class OllamaHealthMonitor { + private intervalId: NodeJS.Timeout | null = null; + private _available = false; + + start(onChanged: (available: boolean) => void): void { + this.intervalId = setInterval(async () => { + const client = new OllamaClient(); + const nowAvailable = await client.checkAvailability(); + if (nowAvailable !== this._available) { + this._available = nowAvailable; + onChanged(nowAvailable); + } + }, 5_000); + } + + stop(): void { + if (this.intervalId) clearInterval(this.intervalId); + } + + get available(): boolean { return this._available; } +} +``` + +**에러 시나리오:** + +| 상황 | 감지 방법 | 대응 | +|------|-----------|------| +| Ollama 미실행 | `fetch` ECONNREFUSED | UI에 "Ollama를 실행해주세요" 알림, 설치 링크 제공 | +| 모델 미다운로드 | 404 응답 또는 `/api/tags`에 없음 | 모델 다운로드 UI 표시, `/api/pull` 호출 | +| 생성 중 타임아웃 | AbortController.timeout | 사용자에게 재시도 옵션 제공 | +| 스트리밍 중 연결 끊김 | reader에서 에러 | 부분 결과 보존, 에러 표시 | +| VRAM 부족 | Ollama 에러 응답 | 더 작은 모델 추천 | + +--- + +## 3. TTS 엔진 (Kokoro + edge-tts) + +### 3.1 아키텍처 결정: 이중 TTS 전략 + +**Piper TTS는 채택하지 않는다.** +- 공식 리포지토리가 2025-10-06에 아카이브됨 +- 한국어 음성 모델 공식 미지원 (커뮤니티 학습 모델만 존재) +- 후속 프로젝트(piper1-gpl)도 한국어 지원 불확실 + +**채택 전략:** + +| 엔진 | 역할 | 한국어 | 오프라인 | 품질 | +|------|------|--------|---------|------| +| **Kokoro TTS** (kokoro-onnx) | 기본 TTS | ◎ 지원 (6개 언어 중 하나) | ◎ 완전 오프라인 | ◎ 82M 파라미터, 고품질 | +| **edge-tts** | 폴백 TTS | ◎ 다수 한국어 음성 | ✗ 인터넷 필요 | ◎◎ Microsoft 클라우드 품질 | + +**우선순위:** Kokoro (오프라인) → edge-tts (고품질 폴백) → 없음 (TTS 비활성) + +### 3.2 Kokoro TTS Sidecar 설계 + +Kokoro는 Python 기반이므로 Whisper sidecar와 같은 프로세스에서 호스팅 가능하다. + +```python +# sidecar/tts_server.py (또는 whisper_server.py에 통합) +from kokoro_onnx import Kokoro +import soundfile as sf +import numpy as np +import io + +kokoro: Kokoro | None = None + +@app.post("/tts/load") +async def tts_load(body: dict): + """Kokoro 모델 로드""" + global kokoro + kokoro = Kokoro( + model_path=body.get("model_path", "kokoro-v1_0.onnx"), + voices_path=body.get("voices_path", "voices-v1_0.bin") + ) + return {"status": "loaded"} + +@app.post("/tts/synthesize") +async def tts_synthesize(body: dict): + """텍스트를 PCM 오디오로 변환""" + text = body["text"] + voice = body.get("voice", "kf_default") # 한국어 여성 기본 + speed = body.get("speed", 1.0) + + samples, sample_rate = kokoro.create( + text=text, + voice=voice, + speed=speed, + lang="ko" # 한국어 + ) + + # PCM16으로 변환하여 반환 + pcm16 = (samples * 32767).astype(np.int16) + + buf = io.BytesIO() + sf.write(buf, pcm16, sample_rate, format='WAV') + buf.seek(0) + + from starlette.responses import Response + return Response( + content=buf.read(), + media_type="audio/wav", + headers={"X-Sample-Rate": str(sample_rate)} + ) + +@app.get("/tts/voices") +async def tts_voices(): + """사용 가능한 음성 목록""" + return { + "voices": [ + {"id": "kf_default", "name": "Korean Female", "language": "ko", "gender": "female"}, + {"id": "km_default", "name": "Korean Male", "language": "ko", "gender": "male"}, + # Kokoro 82M 모델의 한국어 음성들 + ] + } +``` + +### 3.3 edge-tts 폴백 (Node.js) + +edge-tts에는 Node.js 구현(`@nicepkg/edge-tts` 또는 `edge-tts` npm)이 존재한다. + +```typescript +// src/main/services/tts/EdgeTTSClient.ts + +// edge-tts npm 패키지 사용 (andresayac/edge-tts) +import { MsEdgeTTS } from 'edge-tts'; + +class EdgeTTSClient { + async synthesize(text: string, voice = 'ko-KR-SunHiNeural'): Promise { + const tts = new MsEdgeTTS(); + await tts.setMetadata(voice, 'audio-24khz-48kbitrate-mono-mp3'); + + const { audioStream } = await tts.toStream(text); + const chunks: Buffer[] = []; + + for await (const chunk of audioStream) { + chunks.push(Buffer.from(chunk)); + } + + return Buffer.concat(chunks); + } + + async getVoices(): Promise> { + // 한국어 음성 목록 + return [ + { id: 'ko-KR-SunHiNeural', name: '선히 (여성)', locale: 'ko-KR' }, + { id: 'ko-KR-InJoonNeural', name: '인준 (남성)', locale: 'ko-KR' }, + { id: 'ko-KR-BongJinNeural', name: '봉진 (남성)', locale: 'ko-KR' }, + { id: 'ko-KR-GookMinNeural', name: '국민 (남성)', locale: 'ko-KR' }, + { id: 'ko-KR-JiMinNeural', name: '지민 (여성)', locale: 'ko-KR' }, + { id: 'ko-KR-SeoHyeonNeural', name: '서현 (여성)', locale: 'ko-KR' }, + { id: 'ko-KR-SoonBokNeural', name: '순복 (여성)', locale: 'ko-KR' }, + { id: 'ko-KR-YuJinNeural', name: '유진 (여성)', locale: 'ko-KR' }, + ]; + } +} +``` + +### 3.4 통합 TTS 서비스 + +```typescript +// src/main/services/tts/LocalTTSService.ts + +class LocalTTSServiceImpl implements ILocalTTSService { + private kokoroAvailable = false; + private edgeTTSAvailable = false; + + async speak(text: string, voiceId?: string, options?: TTSOptions): Promise { + let audioBuffer: Buffer; + + if (this.kokoroAvailable) { + // 1순위: Kokoro (오프라인) + audioBuffer = await this.sidecar.synthesize(text, voiceId, options); + } else if (this.edgeTTSAvailable) { + // 2순위: edge-tts (온라인) + audioBuffer = await this.edgeTTS.synthesize(text, voiceId); + } else { + throw { code: 'TTS_ENGINE_NOT_FOUND', message: 'No TTS engine available' }; + } + + // Web Audio API로 재생 (renderer에 IPC로 전달) + this.emit('audio-output', { + buffer: audioBuffer, + sampleRate: 24000, + isFinal: true, + }); + } +} +``` + +### 3.5 오디오 재생: Renderer에서 Web Audio API + +```typescript +// src/renderer/hooks/useAudioPlayer.ts + +function useAudioPlayer() { + const audioContextRef = useRef(null); + + const playPCM = async (wavBuffer: ArrayBuffer, sampleRate: number) => { + if (!audioContextRef.current) { + audioContextRef.current = new AudioContext({ sampleRate }); + } + const ctx = audioContextRef.current; + + // WAV 디코딩 + const audioBuffer = await ctx.decodeAudioData(wavBuffer); + + const source = ctx.createBufferSource(); + source.buffer = audioBuffer; + source.connect(ctx.destination); + source.start(); + + return new Promise(resolve => { + source.onended = () => resolve(); + }); + }; + + return { playPCM }; +} +``` + +--- + +## 4. 통합 시퀀스 다이어그램 + +### 4.1 전체 파이프라인: 핫키 → 텍스트 삽입 + +``` +User HotkeyService VoiceModeService AudioCapture WhisperSidecar OllamaClient TextInsert + │ │ │ │ │ │ │ + │── Press hotkey ──────►│ │ │ │ │ │ + │ │── keyDown ─────►│ │ │ │ │ + │ │ │ │ │ │ │ + │ │ │── [1] 병렬 시작 ─┤ │ │ │ + │ │ │ startSession() │ │ │ │ + │ │ │ │ │ │ │ + │ │ │── checkModel() ─────────────────►│ │ │ + │ │ │ (이미 로드됨? → skip) │ │ │ + │ │ │ │ │ │ │ + │ │ │── start() ──────►│ │ │ │ + │ │ │ │── capturing ──►│ │ │ + │ │ │ │ (PCM16 60ms) │ │ │ + │ │ │ │ │ │ │ + │ │ │◄─ audio-data ────│ │ │ │ + │ │ │ [버퍼 누적] │ │ │ │ + │ │ │ │ │ │ │ + │◄─ audio-level ────────────────────────────────────────────│ │ │ │ + │ (UI 볼륨 미터) │ │ │ │ │ │ + │ │ │ │ │ │ │ + │── Release hotkey ────►│ │ │ │ │ │ + │ │── keyUp ───────►│ │ │ │ │ + │ │ │── stop() ───────►│ │ │ │ + │ │ │ │── stopped ────►│ │ │ + │ │ │ │ │ │ │ + │ │ │── [2] 이중 조건 플러시 ────────────┤ │ │ + │ │ │ (모델 ready + 오디오 ready) │ │ │ + │ │ │ │ │ │ + │ │ │── POST /transcribe ──────────────►│ │ │ + │ │ │ (PCM16 전체 버퍼) │ │ │ + │ │ │ │ │ │ + │ │ │◄── { text: "안녕하세요" } ─────────│ │ │ + │ │ │ │ │ │ + │ │ │── [3] LLM 후처리 (옵션) ──────────────────────────►│ │ + │ │ │ POST /api/generate │ │ + │ │ │ system: polish_formal │ │ + │ │ │ prompt: "안녕하세요" │ │ + │ │ │ │ │ + │◄─ processing-update ──────────────────────◄── stream tokens ────────────────────────────────│ │ + │ (UI 스트리밍 표시) │ │ │ │ + │ │ │◄── { text: "안녕하세요." } ────────────────────────│ │ + │ │ │ │ + │ │ │── [4] insertText() ──────────────────────────────────────────────►│ + │ │ │ (클립보드 + Ctrl+V) │ + │ │ │ │ + │ │ │◄── success ──────────────────────────────────────────────────────│ + │ │ │ │ + │ │ │── [5] HistoryService.save() │ + │ │ │ │ + │◄─ session-completed ──────────────────── │ │ +``` + +### 4.2 타이밍 예상 (medium 모델, 5초 녹음 기준) + +| 단계 | 소요 시간 | 비고 | +|------|----------|------| +| 녹음 | 사용자 제어 | PTT: 누르는 동안 | +| STT 전사 | 1-3초 | CPU medium 모델, 5초 오디오 기준 | +| LLM 후처리 | 1-2초 | Qwen3 4B, 짧은 텍스트 | +| 텍스트 삽입 | <100ms | 클립보드 + 키 입력 시뮬레이션 | +| **총 지연** | **~2-5초** | LLM 후처리 미사용 시 ~1-3초 | + +### 4.3 이중 조건 플러시 상세 + +``` + ┌──────────────────────────────────────────┐ + │ VoiceModeService │ + │ │ + startSession() │ ┌──────────┐ ┌──────────────────┐ │ + ─────────────► │ │ 모델 로딩 │ │ 오디오 버퍼링 │ │ + │ │ │ │ │ │ + │ │ sidecar │ │ AudioCapture │ │ + │ │ /load │ │ audio-data 누적 │ │ + │ │ │ │ │ │ + │ │ ▼ │ │ ▼ │ │ + │ │ modelReady│ │ bufferReady │ │ + │ │ = true │ │ = true │ │ + │ └────┬─────┘ └────────┬─────────┘ │ + │ │ │ │ + │ └────────┬────────────┘ │ + │ │ │ + │ ▼ │ + │ tryFlushAll() │ + │ if (modelReady && bufferReady) { │ + │ → POST /transcribe │ + │ } │ + └──────────────────────────────────────────┘ +``` + +--- + +## 5. 에러 시나리오 & 복구 전략 + +### 5.1 STT (Whisper Sidecar) + +| 에러 | 감지 | 복구 | +|------|------|------| +| Python 미설치 | sidecar spawn 실패 | "Python 3.10+ 설치 필요" 안내, 내장 Python 번들 검토 | +| sidecar 크래시 | `exit` 이벤트, code ≠ 0 | 지수 백오프 재시작 (최대 3회) | +| 모델 다운로드 실패 | `/load` HTTP 에러 | 네트워크 확인 안내, 재시도 버튼 | +| GPU 메모리 부족 | CUDA OOM 에러 | `device: "cpu"` 폴백, 더 작은 모델 추천 | +| 전사 타임아웃 | 30초 이상 응답 없음 | AbortController로 취소, "다시 시도" 안내 | +| 잘못된 오디오 포맷 | 전사 결과 비어있음 | PCM16 16kHz mono 검증 로직 추가 | + +### 5.2 LLM (Ollama) + +| 에러 | 감지 | 복구 | +|------|------|------| +| Ollama 미실행 | ECONNREFUSED | LLM 후처리 스킵, 원본 텍스트 사용 | +| 모델 미다운로드 | 404 또는 tags에 없음 | 모델 다운로드 UI 표시 | +| 생성 무한루프 | eval_count > maxTokens | AbortController 취소 | +| 느린 응답 | 토큰 간격 > 10초 | 타임아웃 후 부분 결과 사용 | + +### 5.3 TTS + +| 에러 | 감지 | 복구 | +|------|------|------| +| Kokoro 모델 없음 | `/tts/load` 실패 | edge-tts 폴백 | +| edge-tts 네트워크 실패 | fetch 에러 | TTS 비활성, 텍스트만 표시 | +| 오디오 재생 실패 | AudioContext 에러 | 사용자에게 오디오 장치 확인 안내 | + +--- + +## 6. 모델 추천 & 한국어 성능 + +### 6.1 STT 모델 추천 + +| 시나리오 | 모델 | 크기 | CPU 성능 | 비고 | +|---------|------|------|---------|------| +| 빠른 응답 우선 | `base` | 145MB | RTF ~4x | 짧은 문장 위주, 약간의 오류 허용 | +| **균형 (기본 추천)** | **`small`** | 484MB | **RTF ~2x** | **한국어 인식률 양호, 합리적 속도** | +| 정확도 우선 | `medium` | 1.5GB | RTF ~1x | 한국어 전문 용어 포함 시 | +| GPU 사용자 | `large-v3` | 3.0GB | GPU에서 빠름 | 최고 정확도, VRAM 10GB+ 필요 | +| GPU + 속도 | `turbo` | 1.6GB | GPU 최적화 | large-v3에 근접한 정확도, 더 빠름 | + +**한국어 팁:** `language: "ko"` 명시 지정이 `"auto"`보다 정확도 높음. `initial_prompt`에 "한국어 음성입니다."를 넣으면 추가 개선. + +### 6.2 LLM 모델 추천 + +| 모델 | 크기 | 한국어 | RAM | 추천 용도 | +|------|------|--------|-----|----------| +| **`qwen3:4b`** | 2.7GB | ◎◎ 우수 | 4GB+ | **기본 추천. 한중일 강점** | +| `qwen3.5:4b` | ~3GB | ◎◎ 최상 | 4GB+ | 최신 모델, 한국어 특히 강화 | +| `gemma3:4b` | 3.3GB | ◎ 양호 | 4GB+ | Google 다국어, 140개 언어 | +| `llama3.2:3b` | 2.0GB | △ 보통 | 3GB+ | 영어 위주, 한국어 약함 | +| `phi-4-mini` | 2.5GB | △ 보통 | 3GB+ | 추론 강점, 한국어 제한적 | + +**판정:** Qwen3/3.5 계열이 한국어에서 압도적. Alibaba의 다국어 학습 데이터에 한중일이 충분히 포함됨. + +**텍스트 후처리에 권장 설정:** +```json +{ + "model": "qwen3:4b", + "temperature": 0.3, + "num_predict": 512, + "top_p": 0.9, + "stop": ["\n\n"] +} +``` +낮은 temperature(0.3)로 원문 의미 보존, 짧은 출력으로 빠른 응답. + +### 6.3 TTS 음성 추천 + +| 엔진 | 음성 | 품질 | 지연시간 | 비고 | +|------|------|------|---------|------| +| **Kokoro** | `kf_default` (한국어 여성) | ◎ | ~1초 (CPU) | 오프라인, 82M 경량 | +| edge-tts | `ko-KR-SunHiNeural` | ◎◎ | ~0.5초 + 네트워크 | Microsoft 품질, 인터넷 필요 | +| edge-tts | `ko-KR-InJoonNeural` | ◎◎ | ~0.5초 + 네트워크 | 남성 음성 | + +--- + +## 부록 A: Sidecar 프로세스 통합 설계 + +STT(Whisper)와 TTS(Kokoro)를 단일 Python sidecar로 통합 운영한다. + +``` +┌─────────────────────────────────────────────────┐ +│ unified_sidecar.py │ +│ (FastAPI on random port) │ +│ │ +│ GET /health → 전체 상태 │ +│ │ +│ ─── STT (faster-whisper) ─── │ +│ POST /stt/load → 모델 로드 │ +│ POST /stt/transcribe → 전사 │ +│ POST /stt/download → 모델 다운로드 (SSE) │ +│ │ +│ ─── TTS (kokoro-onnx) ─── │ +│ POST /tts/load → 음성 모델 로드 │ +│ POST /tts/synthesize → 합성 (WAV 반환) │ +│ GET /tts/voices → 음성 목록 │ +│ │ +│ POST /shutdown → 종료 │ +└─────────────────────────────────────────────────┘ +``` + +**장점:** +- 프로세스 1개만 관리 (spawn, health check, crash recovery 단일화) +- Python 환경/의존성 1세트 +- 포트 1개만 사용 + +**requirements.txt:** +``` +fastapi>=0.115 +uvicorn>=0.34 +faster-whisper>=1.1 +kokoro-onnx>=0.5 +numpy>=1.24 +soundfile>=0.12 +``` + +## 부록 B: Python 환경 관리 전략 + +| 전략 | 복잡도 | 사용자 경험 | 판정 | +|------|--------|------------|------| +| 시스템 Python 요구 | 낮음 | Python 설치 필요 | MVP용 | +| embedded Python 번들 | 중간 | 무설치 | **추후 채택** | +| conda/venv 자동 생성 | 중간 | 첫 실행 시 느림 | △ | +| Docker 컨테이너 | 높음 | Docker Desktop 필요 | ✗ | + +**MVP 전략:** +1. 앱 첫 실행 시 `python --version` 확인 +2. 없으면 Python 설치 안내 +3. venv 자동 생성 + pip install 실행 +4. 이후 실행에서는 venv 재사용 + +```typescript +// Python 환경 자동 설정 +async function ensurePythonEnv(appDir: string): Promise { + const venvPath = path.join(appDir, '.venv'); + const pythonPath = process.platform === 'win32' + ? path.join(venvPath, 'Scripts', 'python.exe') + : path.join(venvPath, 'bin', 'python'); + + if (!fs.existsSync(pythonPath)) { + // venv 생성 + await execAsync(`python -m venv "${venvPath}"`); + // 의존성 설치 + await execAsync(`"${pythonPath}" -m pip install -r "${path.join(appDir, 'sidecar', 'requirements.txt')}"`); + } + + return pythonPath; +} +``` diff --git a/docs/design/06-gap-analysis.md b/docs/design/06-gap-analysis.md new file mode 100644 index 0000000..5673d29 --- /dev/null +++ b/docs/design/06-gap-analysis.md @@ -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 | diff --git a/docs/design/07-voicemode-patterns.md b/docs/design/07-voicemode-patterns.md new file mode 100644 index 0000000..e66b7f8 --- /dev/null +++ b/docs/design/07-voicemode-patterns.md @@ -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 { + 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 { + 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` +- **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(); + private readonly MAX_RETRY = 3; + + async retrySession(historyId: string): Promise { + // 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 { + 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 { + // 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 전환 시 기존 오디오 유지 diff --git a/docs/design/08-design-system.md b/docs/design/08-design-system.md new file mode 100644 index 0000000..879184a --- /dev/null +++ b/docs/design/08-design-system.md @@ -0,0 +1,1095 @@ +# 08. D3RO-VOICE 디자인 시스템 + +> 시안 A (Meteorological Instrument)와 시안 B (VoiceOps Dashboard)를 융합한 전용 디자인 시스템. +> 03-db-and-ui.md의 MUI 테마를 완전 교체한다. + +--- + +## 1. 컬러 팔레트 + +### 1.1 CSS 커스텀 프로퍼티 + +```css +:root { + /* ── 배경 (시안 B 기반) ─────────────────────────── */ + --bg-app: #19191b; + --bg-card: #242427; + --bg-card-hover: #2a2a2d; + --bg-elevated: #2e2e32; + --bg-input: #1e1e21; + + /* ── 보더 ───────────────────────────────────────── */ + --border-subtle: rgba(255, 255, 255, 0.04); + --border-default: rgba(255, 255, 255, 0.08); + --border-strong: rgba(255, 255, 255, 0.12); + + /* ── 악센트: 앰버 (시안 A) ─────────────────────── */ + --accent-amber: #f25b29; + --accent-amber-dim: rgba(242, 91, 41, 0.15); + --accent-amber-glow: rgba(242, 91, 41, 0.6); + + /* ── 상태 태그 (시안 B) ─────────────────────────── */ + --tag-purple: #b854f5; + --tag-purple-bg: rgba(184, 84, 245, 0.12); + --tag-orange: #f59e0b; + --tag-orange-bg: rgba(245, 158, 11, 0.12); + --tag-red: #ef4444; + --tag-red-bg: rgba(239, 68, 68, 0.12); + --tag-green: #22c55e; + --tag-green-bg: rgba(34, 197, 94, 0.12); + + /* ── 텍스트 계층 (시안 B) ──────────────────────── */ + --text-primary: #ffffff; + --text-secondary: #8e8e93; + --text-label: #7c7c82; + --text-disabled: #4a4a4e; + + /* ── CRT 인광 (시안 A, RecordingTip 전용) ──────── */ + --crt-phosphor: #f25b29; + --crt-phosphor-dim: #c44a22; + --crt-scanline: rgba(0, 0, 0, 0.15); + --crt-bg: #242528; + + /* ── 시맨틱 ─────────────────────────────────────── */ + --color-error: #ef4444; + --color-success: #22c55e; + --color-warning: #f59e0b; + --color-info: #b854f5; +} +``` + +### 1.2 태그 색상 매핑 + +| 태그 | 배경 | 텍스트 | 용도 | +|------|------|--------|------| +| STT | `--tag-purple-bg` | `--tag-purple` | STT 관련 서비스/상태 | +| TTS | `--tag-orange-bg` | `--tag-orange` | TTS 관련 서비스/상태 | +| LIVE | `--tag-red-bg` | `--tag-red` | 실시간 녹음/스트리밍 | +| COMPLETE | `--tag-green-bg` | `--tag-green` | 완료 상태 | +| PROCESSING | `--tag-orange-bg` | `--tag-orange` | 처리 중 | +| ERROR | `--tag-red-bg` | `--tag-red` | 에러 상태 | + +--- + +## 2. 타이포그래피 + +### 2.1 폰트 스택 + +```css +:root { + /* 본문 (시안 B) */ + --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + "Helvetica Neue", Arial, sans-serif; + + /* 상태/모노 (시안 A) */ + --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, + "Liberation Mono", monospace; +} +``` + +### 2.2 크기 체계 + +| 토큰 | 크기 | 행간 | 용도 | +|------|------|------|------| +| `--text-hero` | 28px | 1.2 | 대시보드 메인 수치 | +| `--text-title` | 22px | 1.3 | 섹션 타이틀 | +| `--text-subtitle` | 18px | 1.4 | 카드 헤딩, 라이브 전사 | +| `--text-body` | 14px | 1.5 | 본문 텍스트 | +| `--text-small` | 12px | 1.4 | 보조 텍스트 | +| `--text-label` | 11px | 1.2 | 라벨, 태그 | + +### 2.3 라벨 스타일 + +```css +.label { + font-family: var(--font-sans); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-label); +} +``` + +### 2.4 모노 숫자 스타일 + +```css +.mono-value { + font-family: var(--font-mono); + font-size: 14px; + font-weight: 500; + letter-spacing: 0.02em; + color: var(--text-primary); + font-variant-numeric: tabular-nums; +} +``` + +--- + +## 3. 컴포넌트 스타일가이드 + +### 3.1 Card + +시안 B 기반. 전체 레이아웃의 기본 컨테이너. + +```css +.card { + background: var(--bg-card); + border-radius: 22px; + border-top: 1px solid var(--border-subtle); /* rgba(255,255,255,0.04) */ + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3); + padding: 24px; + transition: background-color 0.2s ease; +} + +.card:hover { + background: var(--bg-card-hover); +} +``` + +### 3.2 Tag / Badge + +시안 B 기반. pill 형태의 상태 태그. + +```css +.tag { + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: 999px; /* pill */ + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.1em; + line-height: 1; +} + +/* 변형 */ +.tag--purple { background: var(--tag-purple-bg); color: var(--tag-purple); } +.tag--orange { background: var(--tag-orange-bg); color: var(--tag-orange); } +.tag--red { background: var(--tag-red-bg); color: var(--tag-red); } +.tag--green { background: var(--tag-green-bg); color: var(--tag-green); } +``` + +### 3.3 Button + +시안 A의 물리 버튼 느낌. 눌림 피드백 중시. + +```css +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 10px 20px; + border: none; + border-radius: 10px; + font-family: var(--font-sans); + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: transform 0.05s linear, box-shadow 0.05s linear; + box-shadow: + 0 2px 0 rgba(0, 0, 0, 0.4), /* 돌출 그림자 */ + inset 0 1px 0 rgba(255, 255, 255, 0.06); /* 상단 하이라이트 */ +} + +.btn:active { + transform: translateY(2px); + box-shadow: + 0 0 0 rgba(0, 0, 0, 0.4), + inset 0 2px 4px rgba(0, 0, 0, 0.3); /* 눌림 inset */ +} + +/* Primary: 앰버 악센트 */ +.btn--primary { + background: var(--accent-amber); + color: #fff; +} +.btn--primary:hover { + background: #d94f24; +} + +/* Secondary: 다크 */ +.btn--secondary { + background: var(--bg-elevated); + color: var(--text-primary); +} +.btn--secondary:hover { + background: #353539; +} +``` + +### 3.4 LED Indicator + +시안 A 기반. 서비스 상태 표시등. + +```css +.led { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--text-disabled); + flex-shrink: 0; + transition: background 0.3s ease, box-shadow 0.3s ease; +} + +.led--active { + background: var(--tag-green); + box-shadow: 0 0 6px var(--tag-green), 0 0 12px rgba(34, 197, 94, 0.3); +} + +.led--warning { + background: var(--tag-orange); + box-shadow: 0 0 6px var(--tag-orange), 0 0 12px rgba(245, 158, 11, 0.3); +} + +.led--error { + background: var(--tag-red); + box-shadow: 0 0 6px var(--tag-red), 0 0 12px rgba(239, 68, 68, 0.3); +} + +.led--recording { + background: var(--accent-amber); + box-shadow: 0 0 6px var(--accent-amber-glow), 0 0 16px var(--accent-amber-dim); + animation: led-pulse 1.5s ease-in-out infinite; +} + +@keyframes led-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} +``` + +### 3.5 WaveformBar + +시안 B의 7개 바 레이아웃 + 시안 A의 앰버색. + +```css +.waveform { + display: flex; + align-items: center; + gap: 3px; + height: 32px; +} + +.waveform__bar { + width: 4px; + min-height: 3px; + border-radius: 2px; + background: var(--accent-amber); + transition: height 100ms ease-out; +} + +/* 7개 바 코사인 분포 가중치 */ +/* JS에서 scaleY로 높이 제어 */ +``` + +```javascript +// 웨이브폼 파라미터 +const WAVEFORM = { + BAR_COUNT: 7, + UPDATE_MS: 100, + MIN_SCALE: 0.1, // scaleY 최솟값 + MAX_SCALE: 1.0, // scaleY 최댓값 + SMOOTHING: 0.5, + RANDOM_FACTOR: 0.35, +}; + +// 코사인 분포 가중치 (중앙 최대) +const weights = Array.from({ length: WAVEFORM.BAR_COUNT }, (_, n) => { + const center = (WAVEFORM.BAR_COUNT - 1) / 2; // 3 + const normalized = (n - center) / center; // -1 ~ +1 + return Math.cos(normalized * Math.PI / 2); +}); +// 결과: [0, 0.50, 0.87, 1.0, 0.87, 0.50, 0] + +function updateWaveform(bars, audioLevel, currentScales) { + for (let i = 0; i < WAVEFORM.BAR_COUNT; i++) { + const base = audioLevel * weights[i]; + const randomized = base * (1 + (Math.random() - 0.5) * 2 * WAVEFORM.RANDOM_FACTOR); + const target = Math.max(WAVEFORM.MIN_SCALE, Math.min(WAVEFORM.MAX_SCALE, randomized)); + currentScales[i] += (target - currentScales[i]) * WAVEFORM.SMOOTHING; + bars[i].style.transform = `scaleY(${currentScales[i]})`; + } +} +``` + +### 3.6 RecordingTip (CRT 모드) + +시안 A의 CRT 인스트루먼트 느낌을 RecordingTip 팝업에 적용. + +```css +.recording-tip--crt { + background: var(--crt-bg); + border-radius: 12px; + padding: 10px 14px; + position: relative; + overflow: hidden; + box-shadow: + 0 0 20px rgba(242, 91, 41, 0.15), + inset 0 0 60px rgba(0, 0, 0, 0.3); +} + +/* 스캔라인 오버레이 */ +.recording-tip--crt::before { + content: ''; + position: absolute; + inset: 0; + background: repeating-linear-gradient( + 0deg, + transparent, + transparent 2px, + var(--crt-scanline) 2px, + var(--crt-scanline) 4px + ); + pointer-events: none; + z-index: 2; +} + +/* 비네팅 오버레이 */ +.recording-tip--crt::after { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient( + ellipse at center, + transparent 50%, + rgba(0, 0, 0, 0.4) 100% + ); + pointer-events: none; + z-index: 3; +} + +/* CRT 모드 웨이브바: 앰버 인광색 */ +.recording-tip--crt .waveform__bar { + background: var(--crt-phosphor); + box-shadow: 0 0 4px var(--accent-amber-glow); +} + +/* CRT 모드 텍스트 */ +.recording-tip--crt .duration, +.recording-tip--crt .thinking-label { + font-family: var(--font-mono); + font-size: 12px; + color: var(--crt-phosphor); + text-shadow: 0 0 6px var(--accent-amber-glow); + letter-spacing: 1.5px; +} + +/* 노이즈 텍스처 */ +.recording-tip--crt .noise-overlay { + position: absolute; + inset: 0; + opacity: 0.03; + mix-blend-mode: overlay; + pointer-events: none; + z-index: 4; + /* JS: canvas로 랜덤 픽셀 노이즈 생성 */ +} +``` + +### 3.7 Dashboard 레이아웃 + +시안 B의 카드 그리드 + 헤더 구조. + +```css +.dashboard { + max-width: 1200px; + margin: 0 auto; + padding: 40px; +} + +.dashboard__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 32px; +} + +.dashboard__header-title { + font-size: var(--text-title); + font-weight: 700; + color: var(--text-primary); +} + +.dashboard__header-subtitle { + font-size: var(--text-body); + color: var(--text-secondary); + margin-top: 4px; +} +``` + +### 3.8 StatusPanel + +시안 B의 서비스 상태 리스트 카드. + +```css +.status-panel { + /* .card 스타일 상속 */ +} + +.status-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 0; + border-bottom: 1px solid var(--border-subtle); +} + +.status-item:last-child { + border-bottom: none; +} + +.status-item__name { + font-size: var(--text-body); + font-weight: 500; + color: var(--text-primary); + flex: 1; +} + +.status-item__value { + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-secondary); +} +``` + +### 3.9 ProcessingOverlay + +시안 B의 blur + spinner 처리 중 표시. + +```css +.processing-overlay { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + background: rgba(25, 25, 27, 0.6); + backdrop-filter: blur(4px); + border-radius: 22px; /* 부모 card와 동일 */ + z-index: 10; +} + +.processing-overlay__spinner { + width: 24px; + height: 24px; + border: 2px solid var(--border-strong); + border-top-color: var(--accent-amber); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +.processing-overlay__label { + font-size: var(--text-label); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--text-secondary); +} + +@keyframes spin { + to { transform: rotate(360deg); } +} +``` + +--- + +## 4. 애니메이션 스펙 + +### 4.1 웨이브폼 애니메이션 + +시안 B의 `scaleY` pulse + 시안 A의 코사인 가중치. + +``` +방식: setInterval 100ms + smoothing 보간 +높이 계산: audioLevel * cosWeight[i] * (1 ± 35% random) +보간: current += (target - current) * 0.5 +CSS: transform: scaleY(n), transition: transform 100ms ease-out +바 개수: 7 +``` + +### 4.2 글리치 전환 (RecordingTip CRT) + +시안 A의 glitchAmount decay. 상태 전환 시 트리거. + +``` +트리거: 상태 전환 (recording → thinking, thinking → error 등) +초기값: glitchAmount = 1.0 +감쇠: glitchAmount *= 0.85 (매 프레임) +종료 조건: glitchAmount < 0.01 +효과: RGB 채널 분리 (셰이더) 또는 CSS translateX 떨림 +CSS 폴백: transform: translateX(random(-3, 3)px), opacity 깜빡임 +``` + +### 4.3 카드 hover + +```css +transition: background-color 0.2s ease; +``` + +### 4.4 버튼 press + +```css +transition: transform 0.05s linear, box-shadow 0.05s linear; +/* active: translateY(2px) */ +``` + +### 4.5 스피너 + +```css +animation: spin 1s linear infinite; +/* rotate(360deg) */ +``` + +### 4.6 프로그레스 바 (점근 수렴) + +```javascript +// 시간 기반: 95%에서 정체, 완료 시 100%로 snap +const elapsed = (performance.now() - startTime) / 1000; +const progress = Math.min(95, (1 - 1 / (1 + 1.5 * elapsed)) * 100); + +// 완료 시 +progressBar.style.transition = 'width 200ms ease-out'; +progressBar.style.width = '100%'; +``` + +### 4.7 LED pulse + +```css +@keyframes led-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} +/* duration: 1.5s, timing: ease-in-out, iteration: infinite */ +``` + +--- + +## 5. 레이아웃 그리드 + +### 5.1 메인 그리드 + +시안 B 기반. `max-width: 1200px`, 패딩 40px. + +```css +.layout { + display: grid; + max-width: 1200px; + margin: 0 auto; + padding: 40px; + gap: 24px; +} + +/* 상단 영역: 녹음 컨트롤(2fr) + 상태 패널(1fr) */ +.layout__top { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 24px; +} + +/* 카드 그리드: auto-fill */ +.layout__cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 24px; +} +``` + +### 5.2 반응형 + +```css +/* 태블릿 (< 900px) */ +@media (max-width: 900px) { + .layout { + padding: 24px; + } + .layout__top { + grid-template-columns: 1fr; + } +} + +/* 모바일 (< 600px) */ +@media (max-width: 600px) { + .layout { + padding: 16px; + gap: 16px; + } + .layout__cards { + grid-template-columns: 1fr; + } +} +``` + +--- + +## 6. MUI 테마 + +03-db-and-ui.md의 `createTheme()`을 이 디자인 시스템으로 완전 교체한다. +라이트 테마를 제거하고 다크 전용으로 운영한다. + +```typescript +// src/renderer/theme.ts + +import { createTheme } from '@mui/material/styles'; + +// ── CSS 변수와 동기화된 팔레트 상수 ────────────────── +const palette = { + bg: { + app: '#19191b', + card: '#242427', + cardHover: '#2a2a2d', + elevated: '#2e2e32', + input: '#1e1e21', + }, + accent: { + amber: '#f25b29', + amberDim: 'rgba(242, 91, 41, 0.15)', + }, + tag: { + purple: '#b854f5', + orange: '#f59e0b', + red: '#ef4444', + green: '#22c55e', + }, + text: { + primary: '#ffffff', + secondary: '#8e8e93', + label: '#7c7c82', + disabled: '#4a4a4e', + }, + border: { + subtle: 'rgba(255, 255, 255, 0.04)', + default: 'rgba(255, 255, 255, 0.08)', + strong: 'rgba(255, 255, 255, 0.12)', + }, +} as const; + +const fontSans = [ + '-apple-system', + 'BlinkMacSystemFont', + '"Segoe UI"', + 'Roboto', + '"Helvetica Neue"', + 'Arial', + 'sans-serif', +].join(','); + +const fontMono = [ + 'ui-monospace', + 'SFMono-Regular', + '"SF Mono"', + 'Menlo', + 'Consolas', + '"Liberation Mono"', + 'monospace', +].join(','); + +// ── 테마 정의 ──────────────────────────────────────── +export const theme = createTheme({ + palette: { + mode: 'dark', + primary: { + main: palette.accent.amber, // #f25b29 + light: '#ff7a4d', + dark: '#c44a22', + contrastText: '#ffffff', + }, + secondary: { + main: palette.tag.purple, // #b854f5 + light: '#d084ff', + dark: '#8a3cc4', + }, + error: { + main: palette.tag.red, // #ef4444 + light: '#ff7961', + }, + warning: { + main: palette.tag.orange, // #f59e0b + }, + success: { + main: palette.tag.green, // #22c55e + light: '#4ade80', + }, + background: { + default: palette.bg.app, // #19191b + paper: palette.bg.card, // #242427 + }, + text: { + primary: palette.text.primary, // #ffffff + secondary: palette.text.secondary, // #8e8e93 + disabled: palette.text.disabled, // #4a4a4e + }, + divider: palette.border.default, // rgba(255,255,255,0.08) + }, + + typography: { + fontFamily: fontSans, + h4: { fontWeight: 700, fontSize: '22px', lineHeight: 1.3 }, + h5: { fontWeight: 700, fontSize: '18px', lineHeight: 1.4 }, + h6: { fontWeight: 600, fontSize: '14px', lineHeight: 1.5 }, + subtitle1: { + fontWeight: 500, + fontSize: '18px', + lineHeight: 1.4, + }, + body1: { fontSize: '14px', lineHeight: 1.5 }, + body2: { fontSize: '12px', lineHeight: 1.4 }, + button: { + textTransform: 'none' as const, + fontWeight: 600, + fontSize: '14px', + }, + caption: { + fontSize: '11px', + fontWeight: 600, + letterSpacing: '0.1em', + textTransform: 'uppercase' as const, + color: palette.text.label, + }, + overline: { + fontSize: '11px', + fontWeight: 600, + letterSpacing: '0.1em', + textTransform: 'uppercase' as const, + lineHeight: 1.2, + }, + }, + + shape: { + borderRadius: 22, // 카드 기본 radius + }, + + components: { + MuiCssBaseline: { + styleOverrides: { + body: { + backgroundColor: palette.bg.app, + color: palette.text.primary, + }, + }, + }, + + MuiButton: { + defaultProps: { + disableElevation: true, + }, + styleOverrides: { + root: { + textTransform: 'none', + fontWeight: 600, + borderRadius: 10, + padding: '10px 20px', + transition: 'transform 0.05s linear, box-shadow 0.05s linear', + boxShadow: '0 2px 0 rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.06)', + '&:active': { + transform: 'translateY(2px)', + boxShadow: '0 0 0 rgba(0,0,0,0.4), inset 0 2px 4px rgba(0,0,0,0.3)', + }, + }, + containedPrimary: { + '&:hover': { + backgroundColor: '#d94f24', + }, + }, + containedSecondary: { + backgroundColor: palette.bg.elevated, + color: palette.text.primary, + '&:hover': { + backgroundColor: '#353539', + }, + }, + }, + }, + + MuiCard: { + defaultProps: { + elevation: 0, + }, + styleOverrides: { + root: { + backgroundColor: palette.bg.card, + borderRadius: 22, + borderTop: `1px solid ${palette.border.subtle}`, + boxShadow: '0 8px 30px rgba(0, 0, 0, 0.3)', + transition: 'background-color 0.2s ease', + '&:hover': { + backgroundColor: palette.bg.cardHover, + }, + }, + }, + }, + + MuiChip: { + styleOverrides: { + root: { + borderRadius: 999, + fontWeight: 700, + fontSize: '11px', + letterSpacing: '0.1em', + textTransform: 'uppercase', + height: 24, + }, + colorPrimary: { + backgroundColor: palette.accent.amberDim, + color: palette.accent.amber, + }, + colorSecondary: { + backgroundColor: 'rgba(184, 84, 245, 0.12)', + color: palette.tag.purple, + }, + colorSuccess: { + backgroundColor: 'rgba(34, 197, 94, 0.12)', + color: palette.tag.green, + }, + colorError: { + backgroundColor: 'rgba(239, 68, 68, 0.12)', + color: palette.tag.red, + }, + colorWarning: { + backgroundColor: 'rgba(245, 158, 11, 0.12)', + color: palette.tag.orange, + }, + }, + }, + + MuiDrawer: { + styleOverrides: { + paper: { + width: 240, + backgroundColor: palette.bg.app, + borderRight: `1px solid ${palette.border.subtle}`, + }, + }, + }, + + MuiListItemButton: { + styleOverrides: { + root: { + borderRadius: 10, + marginLeft: 8, + marginRight: 8, + '&.Mui-selected': { + backgroundColor: palette.accent.amberDim, + color: palette.accent.amber, + fontWeight: 600, + '&:hover': { + backgroundColor: 'rgba(242, 91, 41, 0.2)', + }, + }, + }, + }, + }, + + MuiDialog: { + styleOverrides: { + paper: { + backgroundColor: palette.bg.card, + borderRadius: 22, + border: `1px solid ${palette.border.subtle}`, + boxShadow: '0 16px 48px rgba(0, 0, 0, 0.5)', + }, + }, + }, + + MuiTextField: { + defaultProps: { + size: 'small', + variant: 'outlined', + }, + styleOverrides: { + root: { + '& .MuiOutlinedInput-root': { + backgroundColor: palette.bg.input, + borderRadius: 10, + '& fieldset': { + borderColor: palette.border.default, + }, + '&:hover fieldset': { + borderColor: palette.border.strong, + }, + '&.Mui-focused fieldset': { + borderColor: palette.accent.amber, + }, + }, + }, + }, + }, + + MuiTooltip: { + defaultProps: { + arrow: true, + }, + styleOverrides: { + tooltip: { + backgroundColor: palette.bg.elevated, + fontSize: '12px', + borderRadius: 8, + border: `1px solid ${palette.border.subtle}`, + }, + }, + }, + + MuiTabs: { + styleOverrides: { + indicator: { + backgroundColor: palette.accent.amber, + }, + }, + }, + + MuiTab: { + styleOverrides: { + root: { + textTransform: 'none', + fontWeight: 500, + fontSize: '14px', + '&.Mui-selected': { + color: palette.accent.amber, + fontWeight: 600, + }, + }, + }, + }, + }, +}); + +// 팔레트 상수를 외부에서 참조할 수 있도록 export +export { palette as d3roPalette, fontMono as d3roFontMono }; +``` + +--- + +## 7. RecordingTip CRT 효과 스펙 + +RecordingTip 팝업에 시안 A의 WebGL CRT 효과를 적용하기 위한 상세 스펙. + +### 7.1 구현 전략 + +RecordingTip은 경량 팝업이므로 WebGL 대신 **CSS + Canvas 2D** 조합으로 구현한다. +성능 문제 발생 시 WebGL fragment shader로 전환 가능하도록 인터페이스를 분리한다. + +- 스캔라인: CSS `repeating-linear-gradient` (::before) +- 비네팅: CSS `radial-gradient` (::after) +- 노이즈: Canvas 2D 랜덤 픽셀 (mix-blend-mode: overlay) +- 글리치: CSS transform + opacity 떨림 (JS 제어) + +### 7.2 WebGL Fragment Shader (고급 옵션) + +성능이 허용되거나 더 정교한 효과가 필요할 때 사용. + +```glsl +// crt.frag +precision mediump float; + +uniform float u_time; // elapsed seconds +uniform float u_glitch; // 0.0 ~ 1.0, 상태 전환 시 1.0에서 시작, ×0.85/frame 감쇠 +uniform float u_amplitude; // 오디오 레벨 0.0 ~ 1.0 +uniform float u_frequency; // 웨이브 주파수 (기본 4.0) +uniform vec2 u_resolution; // 캔버스 해상도 + +varying vec2 v_uv; + +// 의사 난수 +float rand(vec2 co) { + return fract(sin(dot(co, vec2(12.9898, 78.233))) * 43758.5453); +} + +void main() { + vec2 uv = v_uv; + + // ── 글리치: 수평 오프셋 ─────────────────────── + float glitchOffset = u_glitch * (rand(vec2(uv.y * 13.0, u_time)) - 0.5) * 0.05; + uv.x += glitchOffset; + + // ── 스캔라인 ────────────────────────────────── + float scanline = sin(uv.y * u_resolution.y * 1.5) * 0.04; + + // ── 앰버 인광색 기본 ────────────────────────── + vec3 amber = vec3(0.949, 0.357, 0.161); // #f25b29 + + // ── 웨이브 표현 (간략) ──────────────────────── + float wave = sin(uv.x * u_frequency * 6.2832 + u_time * 2.0) * u_amplitude * 0.5 + 0.5; + + // ── 비네팅 ──────────────────────────────────── + float dist = distance(uv, vec2(0.5)); + float vignette = smoothstep(0.7, 0.3, dist); + + // ── 노이즈 ──────────────────────────────────── + float noise = rand(uv + u_time * 0.01) * 0.05; + + // ── 합성 ────────────────────────────────────── + float intensity = wave * vignette - scanline + noise; + intensity = clamp(intensity, 0.0, 1.0); + + // ── RGB 채널 분리 (글리치 시) ────────────────── + float r = intensity; + float g = intensity; + float b = intensity; + if (u_glitch > 0.01) { + float shift = u_glitch * 0.01; + r = clamp(intensity + shift, 0.0, 1.0); + b = clamp(intensity - shift, 0.0, 1.0); + } + + gl_FragColor = vec4(amber * vec3(r, g, b), intensity * 0.8); +} +``` + +### 7.3 Uniform 변수 제어 + +```typescript +interface CrtUniforms { + u_time: number; // performance.now() / 1000 + u_glitch: number; // 상태 전환 시 1.0 → ×0.85/frame → 0.0 + u_amplitude: number; // audioLevel (0.0 ~ 1.0) + u_frequency: number; // 기본 4.0 + u_resolution: [number, number]; // canvas 크기 +} + +// 글리치 감쇠 로직 +let glitchAmount = 0; + +function triggerGlitch() { + glitchAmount = 1.0; +} + +function updateGlitch() { + glitchAmount *= 0.85; + if (glitchAmount < 0.01) glitchAmount = 0; +} +// updateGlitch()는 매 rAF에서 호출 +``` + +### 7.4 CSS 폴백 파라미터 (WebGL 미사용 시) + +| 효과 | CSS 구현 | 파라미터 | +|------|----------|----------| +| 스캔라인 | `repeating-linear-gradient(0deg, transparent 0 2px, rgba(0,0,0,0.15) 2px 4px)` | 간격 4px, 투명도 0.15 | +| 비네팅 | `radial-gradient(ellipse at center, transparent 50%, rgba(0,0,0,0.4) 100%)` | 중심 50% 투명 | +| 노이즈 | Canvas 2D: 4x4 블록, 랜덤 alpha 0~0.03, mix-blend-mode: overlay | 30fps 갱신 | +| 글리치 | `transform: translateX(${(Math.random()-0.5)*6*glitch}px)`, opacity 깜빡임 | 감쇠율 0.85/frame | +| 인광 glow | `text-shadow: 0 0 6px rgba(242,91,41,0.6)`, `box-shadow: 0 0 4px` | spread 4~6px | + +--- + +## 부록: 시안 A/B 요소 매핑 요약 + +| 컴포넌트 | 시안 A 요소 | 시안 B 요소 | D3RO-VOICE 적용 | +|----------|------------|------------|----------------| +| Card | 다크 섀시 #242528 | 카드 #242427, radius 22px | 시안 B 카드 스타일 | +| Button | 물리 눌림 (inset shadow) | - | 시안 A 물리 버튼 느낌 | +| Tag/Badge | - | pill, 색상코딩 | 시안 B 태그 시스템 | +| LED | 8px, glow | - | 시안 A LED | +| WaveformBar | cos 가중치 | 7개 바, scaleY | 시안 B 구조 + 시안 A 앰버색 | +| RecordingTip | CRT, 스캔라인, 글리치 | - | 시안 A CRT 전적용 | +| Dashboard | - | 카드 그리드, auto-fill | 시안 B 레이아웃 | +| StatusPanel | 각인 텍스트 | 서비스 상태 리스트 | 시안 B 리스트 + 시안 A 모노폰트 | +| ProcessingOverlay | - | blur(4px) + spinner | 시안 B | +| 컬러: 배경 | #242528 | #19191b, #242427 | 시안 B | +| 컬러: 악센트 | 앰버 #f25b29 | - | 시안 A | +| 타이포: 본문 | - | 시스템 폰트 | 시안 B | +| 타이포: 상태 | 모노스페이스, 각인 | - | 시안 A | diff --git a/docs/design/09-history-popup.md b/docs/design/09-history-popup.md new file mode 100644 index 0000000..a3c61e6 --- /dev/null +++ b/docs/design/09-history-popup.md @@ -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 필터 +- **핀 고정**: 자주 쓰는 항목 상단 고정 +- **미리보기**: 선택된 항목의 전체 텍스트를 팝업 확장으로 표시 +- **즐겨찾기**: 별표 표시 후 즐겨찾기만 보기 diff --git a/docs/phases/phase-1.md b/docs/phases/phase-1.md new file mode 100644 index 0000000..e815791 --- /dev/null +++ b/docs/phases/phase-1.md @@ -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` 통과 diff --git a/docs/phases/phase-2.md b/docs/phases/phase-2.md new file mode 100644 index 0000000..58cf70d --- /dev/null +++ b/docs/phases/phase-2.md @@ -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로 한국어 음성 전사 작동 +- [ ] 상태 머신 정상 전이 +- [ ] 오디오 버퍼링/플러시 정상 작동 diff --git a/docs/phases/phase-3.5.md b/docs/phases/phase-3.5.md new file mode 100644 index 0000000..9993e7e --- /dev/null +++ b/docs/phases/phase-3.5.md @@ -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) +- [ ] 디자인 시스템 준수 (앰버 악센트, 다크 카드) diff --git a/docs/phases/phase-3.md b/docs/phases/phase-3.md new file mode 100644 index 0000000..99015e9 --- /dev/null +++ b/docs/phases/phase-3.md @@ -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에서 핫키/마이크 변경 가능 diff --git a/docs/phases/phase-4.md b/docs/phases/phase-4.md new file mode 100644 index 0000000..c4c0d47 --- /dev/null +++ b/docs/phases/phase-4.md @@ -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 미가용 시 원본 텍스트 삽입으로 폴백 diff --git a/docs/phases/phase-5.md b/docs/phases/phase-5.md new file mode 100644 index 0000000..d57ce82 --- /dev/null +++ b/docs/phases/phase-5.md @@ -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` — nanoid로 ID 생성 + - `getById(id: string): Promise` + - `getList(filter: HistoryFilter): Promise<{ entries: History[]; total: number }>` — 페이지네이션 + - `update(id: string, data: Partial): Promise` + - `softDelete(id: string): Promise` — deleted 플래그 설정 + - `hardDelete(id: string): Promise` — 물리 삭제 + - `bulkDelete(ids: string[]): Promise` +- 검색: originalText, polishedText에 대한 LIKE 검색, 날짜 범위, 모드, 상태 필터 +- 통계 집계: + - `getStats(): Promise` — 총 항목, 총 녹음 시간, 언어별 통계 등 + - 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` + - `update(id: string, data: Partial): Promise` + - `delete(id: string): Promise` + - `getAll(filter?: DictionaryFilter): Promise` + - `search(query: string): Promise` — word 검색 + - `incrementUsage(id: string): Promise` — 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 네비게이션으로 각 화면 이동 가능 diff --git a/docs/phases/phase-6.md b/docs/phases/phase-6.md new file mode 100644 index 0000000..dff30c8 --- /dev/null +++ b/docs/phases/phase-6.md @@ -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): 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; + 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 리소스에서 로드됨 diff --git a/docs/phases/phase-7.md b/docs/phases/phase-7.md new file mode 100644 index 0000000..643cd91 --- /dev/null +++ b/docs/phases/phase-7.md @@ -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) +- [ ] (선택) 자동 업데이트 체크 작동 diff --git a/docs/re-findings/native-dll-patterns.md b/docs/re-findings/native-dll-patterns.md new file mode 100644 index 0000000..0e90379 --- /dev/null +++ b/docs/re-findings/native-dll-patterns.md @@ -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 | diff --git a/docs/re-findings/speakly-architecture.md b/docs/re-findings/speakly-architecture.md new file mode 100644 index 0000000..80522ed --- /dev/null +++ b/docs/re-findings/speakly-architecture.md @@ -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초 지연) diff --git a/docs/re-findings/text-insertion-patterns.md b/docs/re-findings/text-insertion-patterns.md new file mode 100644 index 0000000..6fddfe6 --- /dev/null +++ b/docs/re-findings/text-insertion-patterns.md @@ -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 추가 가능 diff --git a/docs/re-findings/ui-patterns.md b/docs/re-findings/ui-patterns.md new file mode 100644 index 0000000..379e195 --- /dev/null +++ b/docs/re-findings/ui-patterns.md @@ -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 문으로 렌더링 diff --git a/docs/re-findings/voice-pipeline-patterns.md b/docs/re-findings/voice-pipeline-patterns.md new file mode 100644 index 0000000..6032675 --- /dev/null +++ b/docs/re-findings/voice-pipeline-patterns.md @@ -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 불필요)