# 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 | **채택** | --- *끝.*