- 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개 문서
1329 lines
60 KiB
Markdown
1329 lines
60 KiB
Markdown
# 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<void>;
|
|
}
|
|
|
|
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<void> {
|
|
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<void>;
|
|
}
|
|
|
|
// 종료 순서 (before-quit)
|
|
const BEFORE_QUIT_SEQUENCE: Destroyable[] = [
|
|
voiceModeService,
|
|
hotkeyService,
|
|
audioCaptureService,
|
|
];
|
|
|
|
// 종료 순서 (will-quit)
|
|
const WILL_QUIT_SEQUENCE: Array<{ name: string; fn: () => Promise<void> }> = [
|
|
{ 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<string, unknown> };
|
|
'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<K extends keyof HandleChannels>(
|
|
channel: K,
|
|
...args: HandleChannels[K]['args']
|
|
): Promise<HandleChannels[K]['return']>;
|
|
|
|
// send (on 채널, fire-and-forget)
|
|
send<K extends keyof SendChannels>(
|
|
channel: K,
|
|
...args: SendChannels[K]['args']
|
|
): void;
|
|
|
|
// on (main→renderer 수신)
|
|
on<K extends keyof ReceiveChannels>(
|
|
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 | **채택** |
|
|
|
|
---
|
|
|
|
*끝.*
|