d3ro-voice/docs/design/00-master-architecture.md
윤찬 d397bcbf57 feat(desktop): LLM 기본 모델 qwen3:4b → gemma4:e4b 전면 전환 + think:false 안전장치
qwen3:4b가 reasoning 모델이라 <think>...</think> 블록을 길게 생성 →
stripReasoningBlocks 후 빈 문자열 → 원본 transcript fallback으로 끝나면서
LLM refine이 42초 걸리는 병목 발견. Google Gemma 4 e4b(4.5B effective
params, 2026-04-02 릴리스)로 교체. non-reasoning 기본 + Ollama v0.20+
think: false 파라미터로 2중 방어.

실측 결과: 받아쓰기 한 사이클 51.4s → 5.5s (9.3배 빠름).
  STT 500ms + LLM 3,925ms + insert 1,092ms.
refine 품질 정상 동작 확인: "테스트하는 중입니다" → "테스트하고 있습니다".

- LocalLLMService: 3개 fallback 기본값 변경(generate / streamGenerate /
  chatStream) + Ollama 요청 body에 think: false 명시 추가. non-reasoning
  모델은 무시, reasoning 모델은 thinking 토큰 차단. NO_THINK 주석을
  legacy 설명으로 업데이트 — qwen3/deepseek-r1 수동 선택자를 위한 3중
  방어(/no_think + think:false + stripReasoningBlocks) 명시.
- OnboardingModal / OllamaGuideModal: pull 명령어 갱신
- 테스트 fixture 갱신
- 12개 i18n locale JSON: settings.ollamaHint / ollama.step2.alt 키 업데이트
  (qwen3:4b → gemma4:e4b, qwen3:8b → gemma4:26b)
- 10개 site i18n locale TS + HowItWorks.tsx 파이프라인 시각화 — detail
  문자열 'qwen3 / llama3 / gemma3' → 'gemma4 / llama3.2 / phi4',
  파이프라인 라벨 'qwen3:4b @ localhost' → 'gemma4:e4b @ localhost'
- 설계서 00 LLMConfig 기본값 + CONFIG_DEFAULTS
- 설계서 05: 6개 API 스키마 예시, 2개 OllamaClient 코드 예시, LLM 모델
  추천 표 재정렬(gemma4:e4b 최상위, qwen3는 reasoning 경고와 함께 후순위),
  권장 JSON 설정에 think:false 추가
- phase-14 meeting mode 컨텍스트 윈도우 표 갱신
- V2-5 Mac 부트스트랩 가이드 pull 커맨드 갱신
- project_status.md Part 7 전체 섹션 추가
2026-04-12 09:47:08 +09:00

60 KiB

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 서비스별 이벤트 목록

// 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 구현 인터페이스

// 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 인터페이스

// 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 사용 기준

// ── 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 타입 안전성

// 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

// 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 기반)

// 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 에러 코드 범위 할당

// 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 기반

// 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;                 // 기본: 'gemma4:e4b' (non-reasoning, 권장)
  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: 'gemma4:e4b',
    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: 공유 타입 정의

// 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: 공유 상수

// 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 채택

끝.