d3ro-voice/docs/design/02-ipc-and-types.md
Yun Chan e24bb8378c 초기 프로젝트 설정: 하네스 시스템 + 설계서 + RE 노하우
- CLAUDE.md: 프로젝트 규칙, 기술 스택, 코딩 규칙, 페이즈 로드맵
- .claude/settings.json: 권한, 강제 훅 (매 프롬프트 설계서 규칙 주입)
- .claude/skills/: implement-phase, review-phase, scaffold, test-commit, debug
- .claude/agents/: electron-architect, voice-pipeline-expert, ui-specialist
- docs/design/00-09: 마스터 아키텍처, 서비스 명세(16개), IPC(113채널),
  DB스키마, UI컴포넌트, 검증리포트, 외부엔진연동, 갭분석,
  VoiceMode패턴, 디자인시스템, 히스토리팝업
- docs/phases/1-7+3.5: 전체 구현 페이즈 문서
- docs/re-findings/: Speakly RE 노하우 5개 문서
2026-04-05 01:03:03 +09:00

61 KiB

02. IPC 채널 & 타입 명세서

D3RO-VOICE의 모든 IPC 채널, TypeScript 타입, 에러 코드, preload API를 정의한다. Speakly의 ~200개 IPC 채널을 참고하되, 클라우드/인증/텔레메트리를 제거하고 로컬 서비스(STT, TTS, LLM)를 추가한다.


1. IPC 채널 전체 목록

방향 범례

기호 Electron API 설명
handle ipcMain.handle / ipcRenderer.invoke 양방향 (요청→응답)
on ipcMain.on 단방향 Renderer → Main (fire-and-forget)
send webContents.send 단방향 Main → Renderer (푸시 이벤트)

1.1 voice:* -- 음성 녹음/인식 오케스트레이션

채널명 방향 파라미터 타입 반환 타입 담당 서비스 설명
voice:startRecording handle StartRecordingParams StartRecordingResult VoiceModeService 녹음 세션 시작 (마이크 캡처 + STT 준비)
voice:stopRecording handle StopRecordingParams StopRecordingResult VoiceModeService 녹음 중단 및 최종 전사 요청
voice:cancelRecording handle CancelRecordingParams void VoiceModeService 녹음 취소 (결과 버림)
voice:getState handle void VoiceState VoiceModeService 현재 RecognitionState + AudioState 조회
voice:setMode handle SetVoiceModeParams void VoiceModeService 녹음 모드 변경 (dictation/hands-free)
voice:getMode handle void VoiceMode VoiceModeService 현재 녹음 모드 조회
voice:stateChanged send -- VoiceStateChangedEvent VoiceModeService 상태 전이 알림
voice:transcriptionDelta send -- TranscriptionDeltaEvent VoiceModeService 중간 전사 결과 스트리밍
voice:transcriptionComplete send -- TranscriptionCompleteEvent VoiceModeService 최종 전사 결과
voice:error send -- VoiceErrorEvent VoiceModeService 음성 처리 에러 알림
voice:audioLevel send -- AudioLevelEvent VoiceModeService 오디오 레벨 (웨이브 바 애니메이션용, ~100ms 간격)

1.2 audio:* -- 오디오 디바이스 & 캡처

채널명 방향 파라미터 타입 반환 타입 담당 서비스 설명
audio:getDevices handle void AudioDevice[] AudioCaptureService 사용 가능한 마이크 디바이스 목록
audio:getSelectedDevice handle void string | null AudioCaptureService 현재 선택된 디바이스 ID
audio:setSelectedDevice handle SetDeviceParams void AudioCaptureService 마이크 디바이스 변경
audio:testDevice handle TestDeviceParams TestDeviceResult AudioCaptureService 마이크 테스트 (짧은 캡처 후 레벨 반환)
audio:deviceChanged send -- AudioDeviceChangedEvent AudioCaptureService 디바이스 연결/해제 알림

1.3 stt:* -- 로컬 STT (Whisper)

채널명 방향 파라미터 타입 반환 타입 담당 서비스 설명
stt:getStatus handle void STTStatus LocalSTTService STT 엔진 상태 (모델 로딩 여부, 버전 등)
stt:getModels handle void STTModel[] LocalSTTService 사용 가능한 Whisper 모델 목록
stt:getActiveModel handle void string | null LocalSTTService 현재 활성 모델 ID
stt:setModel handle SetSTTModelParams void LocalSTTService 사용할 Whisper 모델 변경
stt:downloadModel handle DownloadModelParams void LocalSTTService 모델 다운로드 시작
stt:cancelDownload handle void void LocalSTTService 진행 중인 모델 다운로드 취소
stt:getLanguage handle void string LocalSTTService STT 인식 언어 조회
stt:setLanguage handle SetSTTLanguageParams void LocalSTTService STT 인식 언어 변경
stt:statusChanged send -- STTStatusChangedEvent LocalSTTService STT 엔진 상태 변경 알림
stt:downloadProgress send -- DownloadProgressEvent LocalSTTService 모델 다운로드 진행률

1.4 tts:* -- 로컬 TTS

채널명 방향 파라미터 타입 반환 타입 담당 서비스 설명
tts:speak handle TTSSpeakParams TTSSpeakResult LocalTTSService 텍스트를 음성으로 변환 및 재생
tts:stop handle void void LocalTTSService 현재 재생 중단
tts:getVoices handle void TTSVoice[] LocalTTSService 사용 가능한 음성 목록
tts:getActiveVoice handle void string | null LocalTTSService 현재 활성 음성 ID
tts:setVoice handle SetTTSVoiceParams void LocalTTSService 사용할 음성 변경
tts:getStatus handle void TTSStatus LocalTTSService TTS 엔진 상태
tts:downloadVoice handle DownloadVoiceParams void LocalTTSService 음성 모델 다운로드
tts:statusChanged send -- TTSStatusChangedEvent LocalTTSService TTS 엔진 상태 변경 알림
tts:speakingStateChanged send -- SpeakingStateChangedEvent LocalTTSService 재생 시작/완료 알림

1.5 llm:* -- 로컬 LLM (Ollama)

채널명 방향 파라미터 타입 반환 타입 담당 서비스 설명
llm:getStatus handle void LLMStatus LocalLLMService Ollama 서버 연결 상태
llm:getModels handle void LLMModel[] LocalLLMService 사용 가능한 모델 목록
llm:getActiveModel handle void string | null LocalLLMService 현재 활성 모델 ID
llm:setModel handle SetLLMModelParams void LocalLLMService 사용할 모델 변경
llm:process handle LLMProcessParams LLMProcessResult LocalLLMService 텍스트 처리 (다듬기, 번역, 요약 등)
llm:cancelProcess handle void void LocalLLMService 진행 중인 LLM 처리 취소
llm:getServerUrl handle void string LocalLLMService Ollama 서버 URL 조회
llm:setServerUrl handle SetServerUrlParams void LocalLLMService Ollama 서버 URL 변경
llm:pullModel handle PullModelParams void LocalLLMService Ollama 모델 pull 시작
llm:statusChanged send -- LLMStatusChangedEvent LocalLLMService Ollama 연결 상태 변경 알림
llm:processProgress send -- LLMProcessProgressEvent LocalLLMService LLM 처리 스트리밍 (토큰 단위)
llm:pullProgress send -- LLMPullProgressEvent LocalLLMService 모델 pull 진행률

1.6 hotkey:* -- 핫키 설정

채널명 방향 파라미터 타입 반환 타입 담당 서비스 설명
hotkey:getDictationShortcut handle void HotkeyBinding HotkeyService 받아쓰기 핫키 조회
hotkey:setDictationShortcut handle SetHotkeyParams void HotkeyService 받아쓰기 핫키 변경
hotkey:getHandsFreeShortcut handle void HotkeyBinding HotkeyService 핸즈프리 모드 핫키 조회
hotkey:setHandsFreeShortcut handle SetHotkeyParams void HotkeyService 핸즈프리 모드 핫키 변경
hotkey:getCommandShortcut handle void HotkeyBinding HotkeyService 명령 모드 핫키 조회
hotkey:setCommandShortcut handle SetHotkeyParams void HotkeyService 명령 모드 핫키 변경
hotkey:isEnabled handle void boolean HotkeyService 핫키 활성화 상태 조회
hotkey:setEnabled handle SetEnabledParams void HotkeyService 핫키 전역 활성화/비활성화
hotkey:startRecording handle void void HotkeyService 핫키 녹화 모드 시작 (설정 UI용)
hotkey:stopRecording handle void HotkeyBinding | null HotkeyService 핫키 녹화 모드 종료 및 결과 반환
hotkey:triggered send -- HotkeyTriggeredEvent HotkeyService 핫키 입력 감지 알림
hotkey:recordingResult send -- HotkeyRecordingResultEvent HotkeyService 핫키 녹화 결과 알림

1.7 config:* -- 설정 관리

채널명 방향 파라미터 타입 반환 타입 담당 서비스 설명
config:get handle ConfigGetParams unknown ConfigService 설정값 조회 (키 기반)
config:set handle ConfigSetParams void ConfigService 설정값 변경
config:getAll handle void AppConfig ConfigService 전체 설정 조회
config:reset handle ConfigResetParams void ConfigService 설정값 초기화 (키 지정 또는 전체)
config:getTheme handle void ThemeMode ConfigService 테마 조회 (light/dark/auto)
config:setTheme handle SetThemeParams void ConfigService 테마 변경
config:getLanguage handle void string ConfigService UI 언어 조회
config:setLanguage handle SetLanguageParams void ConfigService UI 언어 변경
config:getAutoLaunch handle void boolean ConfigService 시작 시 자동 실행 여부
config:setAutoLaunch handle SetAutoLaunchParams void ConfigService 시작 시 자동 실행 설정
config:getCloseToTray handle void boolean ConfigService 닫기 버튼 시 트레이로 최소화 여부
config:setCloseToTray handle SetCloseToTrayParams void ConfigService 트레이로 최소화 설정
config:changed send -- ConfigChangedEvent ConfigService 설정 변경 알림

1.8 history:* -- 히스토리

채널명 방향 파라미터 타입 반환 타입 담당 서비스 설명
history:getAll handle HistoryQueryParams HistoryPage HistoryService 히스토리 목록 조회 (페이지네이션)
history:getById handle HistoryGetByIdParams HistoryEntry | null HistoryService 단건 히스토리 조회
history:delete handle HistoryDeleteParams void HistoryService 히스토리 삭제
history:deleteAll handle void void HistoryService 전체 히스토리 삭제
history:search handle HistorySearchParams HistoryPage HistoryService 히스토리 전문 검색
history:export handle HistoryExportParams string HistoryService 히스토리 내보내기 (파일 경로 반환)
history:added send -- HistoryEntry HistoryService 새 히스토리 추가 알림

1.9 dictionary:* -- 사전

채널명 방향 파라미터 타입 반환 타입 담당 서비스 설명
dictionary:getAll handle DictionaryQueryParams DictionaryPage DictionaryService 사전 항목 목록 조회
dictionary:add handle DictionaryAddParams DictionaryEntry DictionaryService 사전 항목 추가
dictionary:update handle DictionaryUpdateParams DictionaryEntry DictionaryService 사전 항목 수정
dictionary:delete handle DictionaryDeleteParams void DictionaryService 사전 항목 삭제
dictionary:import handle DictionaryImportParams DictionaryImportResult DictionaryService 사전 가져오기 (CSV/JSON)
dictionary:export handle DictionaryExportParams string DictionaryService 사전 내보내기 (파일 경로 반환)
dictionary:search handle DictionarySearchParams DictionaryPage DictionaryService 사전 검색

1.10 window:* -- 윈도우 제어

채널명 방향 파라미터 타입 반환 타입 담당 서비스 설명
window:minimize on void -- WindowManager 메인 윈도우 최소화
window:maximize on void -- WindowManager 메인 윈도우 최대화/복원 토글
window:close on void -- WindowManager 메인 윈도우 닫기
window:isMaximized handle void boolean WindowManager 최대화 상태 조회
window:showRecordingTip on ShowRecordingTipParams -- WindowManager RecordingTip 팝업 표시
window:hideRecordingTip on void -- WindowManager RecordingTip 팝업 숨김
window:showResultPopup on ShowResultPopupParams -- WindowManager ResultPopup 팝업 표시
window:hideResultPopup on void -- WindowManager ResultPopup 팝업 숨김
window:tipMeasured on TipMeasuredParams -- WindowManager RecordingTip 측정 완료 (2-phase 리사이즈)
window:tipStateChanged send -- TipStateChangedEvent WindowManager RecordingTip 상태 변경 전달
window:tipPrepare send -- TipPrepareEvent WindowManager RecordingTip 측정 요청 (2-phase step 1)
window:tipShow send -- TipShowEvent WindowManager RecordingTip 표시 요청 (2-phase step 2)

1.11 system:* -- 시스템 (권한, 알림, 외부 앱)

채널명 방향 파라미터 타입 반환 타입 담당 서비스 설명
system:getPlatform handle void NodeJS.Platform SystemService OS 플랫폼 조회
system:getVersion handle void string SystemService 앱 버전 조회
system:checkMicPermission handle void PermissionStatus PermissionService 마이크 권한 상태 확인
system:requestMicPermission handle void PermissionStatus PermissionService 마이크 권한 요청
system:showNotification handle ShowNotificationParams void SystemService 시스템 알림 표시
system:openExternal handle OpenExternalParams void SystemService 외부 URL/파일 열기
system:getActiveApp handle void ActiveAppInfo | null SystemService 현재 활성 앱 정보 조회
system:insertText handle InsertTextParams InsertTextResult TextInsertService 활성 앱에 텍스트 삽입 (클립보드 방식)
system:playSound handle PlaySoundParams void SoundEffectService 효과음 재생
system:setSoundEnabled handle SetSoundEnabledParams void SoundEffectService 효과음 on/off
system:isSoundEnabled handle void boolean SoundEffectService 효과음 활성화 상태

1.12 stats:* -- 통계

채널명 방향 파라미터 타입 반환 타입 담당 서비스 설명
stats:getSummary handle void StatsSummary StatsService 전체 통계 요약 (총 시간, 단어 수 등)
stats:getDaily handle StatsQueryParams DailyStats[] StatsService 일별 통계
stats:getWeekly handle StatsQueryParams WeeklyStats[] StatsService 주별 통계
stats:updated send -- StatsSummary StatsService 통계 갱신 알림

2. shared/ipc-channels.ts 전체 코드

// src/shared/ipc-channels.ts
// IPC 채널명 중앙 정의 — 모든 채널명은 이 파일에서만 정의한다.

export const IPC_CHANNELS = {
  VOICE: {
    START_RECORDING: 'voice:startRecording',
    STOP_RECORDING: 'voice:stopRecording',
    CANCEL_RECORDING: 'voice:cancelRecording',
    GET_STATE: 'voice:getState',
    SET_MODE: 'voice:setMode',
    GET_MODE: 'voice:getMode',
    // Main → Renderer events
    STATE_CHANGED: 'voice:stateChanged',
    TRANSCRIPTION_DELTA: 'voice:transcriptionDelta',
    TRANSCRIPTION_COMPLETE: 'voice:transcriptionComplete',
    ERROR: 'voice:error',
    AUDIO_LEVEL: 'voice:audioLevel',
  },

  AUDIO: {
    GET_DEVICES: 'audio:getDevices',
    GET_SELECTED_DEVICE: 'audio:getSelectedDevice',
    SET_SELECTED_DEVICE: 'audio:setSelectedDevice',
    TEST_DEVICE: 'audio:testDevice',
    // Main → Renderer events
    DEVICE_CHANGED: 'audio:deviceChanged',
  },

  STT: {
    GET_STATUS: 'stt:getStatus',
    GET_MODELS: 'stt:getModels',
    GET_ACTIVE_MODEL: 'stt:getActiveModel',
    SET_MODEL: 'stt:setModel',
    DOWNLOAD_MODEL: 'stt:downloadModel',
    CANCEL_DOWNLOAD: 'stt:cancelDownload',
    GET_LANGUAGE: 'stt:getLanguage',
    SET_LANGUAGE: 'stt:setLanguage',
    // Main → Renderer events
    STATUS_CHANGED: 'stt:statusChanged',
    DOWNLOAD_PROGRESS: 'stt:downloadProgress',
  },

  TTS: {
    SPEAK: 'tts:speak',
    STOP: 'tts:stop',
    GET_VOICES: 'tts:getVoices',
    GET_ACTIVE_VOICE: 'tts:getActiveVoice',
    SET_VOICE: 'tts:setVoice',
    GET_STATUS: 'tts:getStatus',
    DOWNLOAD_VOICE: 'tts:downloadVoice',
    // Main → Renderer events
    STATUS_CHANGED: 'tts:statusChanged',
    SPEAKING_STATE_CHANGED: 'tts:speakingStateChanged',
  },

  LLM: {
    GET_STATUS: 'llm:getStatus',
    GET_MODELS: 'llm:getModels',
    GET_ACTIVE_MODEL: 'llm:getActiveModel',
    SET_MODEL: 'llm:setModel',
    PROCESS: 'llm:process',
    CANCEL_PROCESS: 'llm:cancelProcess',
    GET_SERVER_URL: 'llm:getServerUrl',
    SET_SERVER_URL: 'llm:setServerUrl',
    PULL_MODEL: 'llm:pullModel',
    // Main → Renderer events
    STATUS_CHANGED: 'llm:statusChanged',
    PROCESS_PROGRESS: 'llm:processProgress',
    PULL_PROGRESS: 'llm:pullProgress',
  },

  HOTKEY: {
    GET_DICTATION_SHORTCUT: 'hotkey:getDictationShortcut',
    SET_DICTATION_SHORTCUT: 'hotkey:setDictationShortcut',
    GET_HANDS_FREE_SHORTCUT: 'hotkey:getHandsFreeShortcut',
    SET_HANDS_FREE_SHORTCUT: 'hotkey:setHandsFreeShortcut',
    GET_COMMAND_SHORTCUT: 'hotkey:getCommandShortcut',
    SET_COMMAND_SHORTCUT: 'hotkey:setCommandShortcut',
    IS_ENABLED: 'hotkey:isEnabled',
    SET_ENABLED: 'hotkey:setEnabled',
    START_RECORDING: 'hotkey:startRecording',
    STOP_RECORDING: 'hotkey:stopRecording',
    // Main → Renderer events
    TRIGGERED: 'hotkey:triggered',
    RECORDING_RESULT: 'hotkey:recordingResult',
  },

  CONFIG: {
    GET: 'config:get',
    SET: 'config:set',
    GET_ALL: 'config:getAll',
    RESET: 'config:reset',
    GET_THEME: 'config:getTheme',
    SET_THEME: 'config:setTheme',
    GET_LANGUAGE: 'config:getLanguage',
    SET_LANGUAGE: 'config:setLanguage',
    GET_AUTO_LAUNCH: 'config:getAutoLaunch',
    SET_AUTO_LAUNCH: 'config:setAutoLaunch',
    GET_CLOSE_TO_TRAY: 'config:getCloseToTray',
    SET_CLOSE_TO_TRAY: 'config:setCloseToTray',
    // Main → Renderer events
    CHANGED: 'config:changed',
  },

  HISTORY: {
    GET_ALL: 'history:getAll',
    GET_BY_ID: 'history:getById',
    DELETE: 'history:delete',
    DELETE_ALL: 'history:deleteAll',
    SEARCH: 'history:search',
    EXPORT: 'history:export',
    // Main → Renderer events
    ADDED: 'history:added',
  },

  DICTIONARY: {
    GET_ALL: 'dictionary:getAll',
    ADD: 'dictionary:add',
    UPDATE: 'dictionary:update',
    DELETE: 'dictionary:delete',
    IMPORT: 'dictionary:import',
    EXPORT: 'dictionary:export',
    SEARCH: 'dictionary:search',
  },

  WINDOW: {
    MINIMIZE: 'window:minimize',
    MAXIMIZE: 'window:maximize',
    CLOSE: 'window:close',
    IS_MAXIMIZED: 'window:isMaximized',
    SHOW_RECORDING_TIP: 'window:showRecordingTip',
    HIDE_RECORDING_TIP: 'window:hideRecordingTip',
    SHOW_RESULT_POPUP: 'window:showResultPopup',
    HIDE_RESULT_POPUP: 'window:hideResultPopup',
    TIP_MEASURED: 'window:tipMeasured',
    // Main → Renderer events
    TIP_STATE_CHANGED: 'window:tipStateChanged',
    TIP_PREPARE: 'window:tipPrepare',
    TIP_SHOW: 'window:tipShow',
  },

  SYSTEM: {
    GET_PLATFORM: 'system:getPlatform',
    GET_VERSION: 'system:getVersion',
    CHECK_MIC_PERMISSION: 'system:checkMicPermission',
    REQUEST_MIC_PERMISSION: 'system:requestMicPermission',
    SHOW_NOTIFICATION: 'system:showNotification',
    OPEN_EXTERNAL: 'system:openExternal',
    GET_ACTIVE_APP: 'system:getActiveApp',
    INSERT_TEXT: 'system:insertText',
    PLAY_SOUND: 'system:playSound',
    SET_SOUND_ENABLED: 'system:setSoundEnabled',
    IS_SOUND_ENABLED: 'system:isSoundEnabled',
  },

  STATS: {
    GET_SUMMARY: 'stats:getSummary',
    GET_DAILY: 'stats:getDaily',
    GET_WEEKLY: 'stats:getWeekly',
    // Main → Renderer events
    UPDATED: 'stats:updated',
  },
} as const;

// 타입 유틸리티: 채널명 유니온 추출
type NestedValues<T> = T extends Record<string, infer V>
  ? V extends string
    ? V
    : NestedValues<V>
  : never;

export type IPCChannel = NestedValues<typeof IPC_CHANNELS>;

3. shared/types.ts 전체 코드

// src/shared/types.ts
// 모든 IPC 파라미터/반환 타입 정의

// ============================================================
// Common
// ============================================================

export type ThemeMode = 'light' | 'dark' | 'auto';

export type VoiceMode = 'dictation' | 'hands-free';

export type PermissionStatus = 'granted' | 'denied' | 'unknown';

// ============================================================
// Voice (음성 오케스트레이션)
// ============================================================

export enum RecognitionState {
  IDLE = 'idle',
  PREPARING = 'preparing',
  CONNECTING = 'connecting',
  READY = 'ready',
  RECOGNIZING = 'recognizing',
  COMPLETED = 'completed',
  CANCELLED = 'cancelled',
  ERROR = 'error',
  DESTROYED = 'destroyed',
}

export enum AudioState {
  IDLE = 'idle',
  INITIALIZING = 'initializing',
  STREAMING = 'streaming',
  STOPPED = 'stopped',
}

export interface VoiceState {
  recognitionState: RecognitionState;
  audioState: AudioState;
  mode: VoiceMode;
  sessionId: string | null;
  /** 현재 세션 녹음 시작 시각 (ms epoch), null이면 비활성 */
  recordingStartedAt: number | null;
}

export interface StartRecordingParams {
  /** 세션 ID (자동 생성 시 생략 가능) */
  sessionId?: string;
  /** 마이크 디바이스 ID (생략 시 기본 디바이스) */
  deviceId?: string;
}

export interface StartRecordingResult {
  sessionId: string;
}

export interface StopRecordingParams {
  sessionId: string;
}

export interface StopRecordingResult {
  sessionId: string;
  /** 최종 전사 텍스트 (완료 전이면 빈 문자열) */
  text: string;
  /** 녹음 지속 시간 (ms) */
  durationMs: number;
}

export interface CancelRecordingParams {
  sessionId: string;
}

export interface SetVoiceModeParams {
  mode: VoiceMode;
}

// Voice events (Main → Renderer)

export interface VoiceStateChangedEvent {
  previousState: RecognitionState;
  currentState: RecognitionState;
  audioState: AudioState;
  sessionId: string | null;
}

export interface TranscriptionDeltaEvent {
  sessionId: string;
  /** 중간 전사 텍스트 (누적) */
  text: string;
  /** 마지막 델타 부분 */
  delta: string;
  isFinal: boolean;
}

export interface TranscriptionCompleteEvent {
  sessionId: string;
  text: string;
  durationMs: number;
  language: string;
}

export interface VoiceErrorEvent {
  sessionId: string | null;
  errorCode: number;
  message: string;
}

export interface AudioLevelEvent {
  /** 0.0 ~ 1.0 정규화된 오디오 레벨 */
  level: number;
}

// ============================================================
// Audio (디바이스 & 캡처)
// ============================================================

export interface AudioDevice {
  deviceId: string;
  label: string;
  isDefault: boolean;
}

export interface SetDeviceParams {
  deviceId: string;
}

export interface TestDeviceParams {
  deviceId: string;
  /** 테스트 지속 시간 (ms), 기본 2000 */
  durationMs?: number;
}

export interface TestDeviceResult {
  /** 평균 오디오 레벨 (0.0 ~ 1.0) */
  averageLevel: number;
  /** 피크 오디오 레벨 */
  peakLevel: number;
  /** 오디오 데이터를 받았는지 여부 */
  hasAudio: boolean;
}

export interface AudioDeviceChangedEvent {
  devices: AudioDevice[];
  /** 변경 유형 */
  type: 'added' | 'removed' | 'default-changed';
}

// ============================================================
// STT (로컬 Whisper)
// ============================================================

export enum STTEngineState {
  NOT_INSTALLED = 'not-installed',
  DOWNLOADING = 'downloading',
  LOADING = 'loading',
  READY = 'ready',
  PROCESSING = 'processing',
  ERROR = 'error',
}

export interface STTStatus {
  engineState: STTEngineState;
  activeModel: string | null;
  /** faster-whisper 또는 whisper.cpp 버전 */
  engineVersion: string | null;
  /** GPU 가속 사용 여부 */
  gpuAccelerated: boolean;
}

export interface STTModel {
  id: string;
  name: string;
  /** 모델 크기 (bytes) */
  sizeBytes: number;
  /** 다운로드 완료 여부 */
  downloaded: boolean;
  /** 지원 언어 목록 (ISO 639-1) */
  languages: string[];
  /** 상대적 정확도 (1-5, 5가 가장 높음) */
  accuracy: number;
  /** 상대적 속도 (1-5, 5가 가장 빠름) */
  speed: number;
}

export interface SetSTTModelParams {
  modelId: string;
}

export interface DownloadModelParams {
  modelId: string;
}

export interface SetSTTLanguageParams {
  /** ISO 639-1 언어 코드 (예: 'ko', 'en', 'auto') */
  language: string;
}

export interface STTStatusChangedEvent {
  status: STTStatus;
}

export interface DownloadProgressEvent {
  modelId: string;
  /** 0 ~ 100 */
  percent: number;
  /** 다운로드된 바이트 */
  downloadedBytes: number;
  /** 전체 바이트 */
  totalBytes: number;
  /** 초당 바이트 */
  bytesPerSecond: number;
}

// ============================================================
// TTS (로컬 TTS)
// ============================================================

export enum TTSEngineState {
  NOT_INSTALLED = 'not-installed',
  LOADING = 'loading',
  READY = 'ready',
  SPEAKING = 'speaking',
  ERROR = 'error',
}

export interface TTSStatus {
  engineState: TTSEngineState;
  activeVoice: string | null;
  engineVersion: string | null;
}

export interface TTSVoice {
  id: string;
  name: string;
  language: string;
  /** 음성 성별 */
  gender: 'male' | 'female' | 'neutral';
  /** 다운로드 완료 여부 */
  downloaded: boolean;
  sizeBytes: number;
}

export interface TTSSpeakParams {
  text: string;
  /** 음성 ID (생략 시 활성 음성) */
  voiceId?: string;
  /** 재생 속도 (0.5 ~ 2.0, 기본 1.0) */
  speed?: number;
}

export interface TTSSpeakResult {
  /** 생성된 오디오 지속 시간 (ms) */
  durationMs: number;
}

export interface SetTTSVoiceParams {
  voiceId: string;
}

export interface DownloadVoiceParams {
  voiceId: string;
}

export interface TTSStatusChangedEvent {
  status: TTSStatus;
}

export interface SpeakingStateChangedEvent {
  isSpeaking: boolean;
  /** 현재/마지막 재생 텍스트 */
  text: string;
}

// ============================================================
// LLM (Ollama)
// ============================================================

export enum LLMConnectionState {
  DISCONNECTED = 'disconnected',
  CONNECTING = 'connecting',
  CONNECTED = 'connected',
  ERROR = 'error',
}

export interface LLMStatus {
  connectionState: LLMConnectionState;
  serverUrl: string;
  activeModel: string | null;
  /** Ollama 서버 버전 */
  serverVersion: string | null;
}

export interface LLMModel {
  id: string;
  name: string;
  /** 모델 크기 (bytes) */
  sizeBytes: number;
  /** 파라미터 수 문자열 (예: '7B', '13B') */
  parameterSize: string;
  /** 양자화 레벨 (예: 'Q4_K_M') */
  quantization: string;
  /** 수정 시각 (ISO 8601) */
  modifiedAt: string;
}

export type LLMAction =
  | 'refine'       // 텍스트 다듬기
  | 'translate'    // 번역
  | 'summarize'    // 요약
  | 'expand'       // 확장
  | 'grammar'      // 문법 교정
  | 'custom';      // 커스텀 프롬프트

export interface LLMProcessParams {
  text: string;
  action: LLMAction;
  /** translate 시 대상 언어 */
  targetLanguage?: string;
  /** custom 시 프롬프트 */
  customPrompt?: string;
  /** 사용할 모델 ID (생략 시 활성 모델) */
  modelId?: string;
}

export interface LLMProcessResult {
  originalText: string;
  processedText: string;
  action: LLMAction;
  /** 처리 시간 (ms) */
  processingTimeMs: number;
  /** 사용된 토큰 수 */
  tokenCount: number;
}

export interface SetLLMModelParams {
  modelId: string;
}

export interface SetServerUrlParams {
  url: string;
}

export interface PullModelParams {
  modelName: string;
}

export interface LLMStatusChangedEvent {
  status: LLMStatus;
}

export interface LLMProcessProgressEvent {
  /** 누적 생성 텍스트 */
  text: string;
  /** 마지막 토큰 */
  token: string;
  /** 완료 여부 */
  done: boolean;
}

export interface LLMPullProgressEvent {
  modelName: string;
  status: string;
  /** 0 ~ 100 */
  percent: number;
  /** 다운로드된 바이트 */
  downloadedBytes: number;
  totalBytes: number;
}

// ============================================================
// Hotkey (핫키)
// ============================================================

export interface HotkeyBinding {
  /** uiohook 키코드 */
  keyCode: number;
  /** Ctrl 수식자 */
  ctrl: boolean;
  /** Alt 수식자 */
  alt: boolean;
  /** Shift 수식자 */
  shift: boolean;
  /** Meta(Win) 수식자 */
  meta: boolean;
  /** 표시용 문자열 (예: 'Right Alt') */
  displayLabel: string;
}

export interface SetHotkeyParams {
  binding: HotkeyBinding;
}

export interface SetEnabledParams {
  enabled: boolean;
}

export type HotkeyAction = 'dictation' | 'hands-free' | 'command';

export interface HotkeyTriggeredEvent {
  action: HotkeyAction;
  /** 'pressed' | 'released' — hold-to-talk용 */
  type: 'pressed' | 'released';
  /** 더블프레스 여부 (300ms 이내) */
  isDoublePress: boolean;
}

export interface HotkeyRecordingResultEvent {
  binding: HotkeyBinding | null;
  /** 시스템 예약 키 충돌 시 사유 */
  conflictReason: string | null;
}

// ============================================================
// Config (설정)
// ============================================================

export interface AppConfig {
  // UI
  theme: ThemeMode;
  language: string;
  closeToTray: boolean;
  autoLaunch: boolean;
  soundEnabled: boolean;

  // Audio
  selectedDeviceId: string | null;

  // STT
  sttModelId: string;
  sttLanguage: string;

  // TTS
  ttsVoiceId: string | null;
  ttsSpeed: number;

  // LLM
  ollamaServerUrl: string;
  llmModelId: string | null;
  defaultLLMAction: LLMAction;

  // Hotkey
  dictationShortcut: HotkeyBinding;
  handsFreeShortcut: HotkeyBinding;
  commandShortcut: HotkeyBinding;
  hotkeyEnabled: boolean;

  // Text Insert
  insertMethod: 'clipboard' | 'keyboard';
  autoInsert: boolean;

  // History
  maxHistoryEntries: number;
}

export interface ConfigGetParams {
  key: keyof AppConfig;
}

export interface ConfigSetParams {
  key: keyof AppConfig;
  value: AppConfig[keyof AppConfig];
}

export interface ConfigResetParams {
  /** 초기화할 키 (생략 시 전체 초기화) */
  key?: keyof AppConfig;
}

export interface SetThemeParams {
  theme: ThemeMode;
}

export interface SetLanguageParams {
  language: string;
}

export interface SetAutoLaunchParams {
  enabled: boolean;
}

export interface SetCloseToTrayParams {
  enabled: boolean;
}

export interface ConfigChangedEvent {
  key: keyof AppConfig;
  value: AppConfig[keyof AppConfig];
  previousValue: AppConfig[keyof AppConfig];
}

// ============================================================
// History (히스토리)
// ============================================================

export interface HistoryEntry {
  id: string;
  /** 원본 전사 텍스트 */
  originalText: string;
  /** LLM 다듬기/번역 결과 (미처리 시 null) */
  polishedText: string | null;
  /** 포커스 앱 실행 경로 */
  focusedApp: string | null;
  /** 포커스 앱 이름 */
  focusedAppName: string | null;
  /** 포커스 윈도우 타이틀 */
  focusedAppWindowTitle: string | null;
  /** 녹음 모드 */
  mode: 'dictation' | 'translate' | 'command';
  /** 세션 상태 */
  status: 'completed' | 'cancelled' | 'error';
  /** 에러 코드 (실패 시) */
  errorCode: string | null;
  /** 녹음 파일 경로 */
  audioLocalPath: string | null;
  /** 녹음 시간 (초) */
  duration: number;
  /** Whisper 감지 언어 */
  detectedLanguage: string | null;
  /** 마이크 디바이스 ID */
  micDevice: string | null;
  /** 단어 수 */
  wordCount: number;
  /** 사용된 Whisper 모델명 */
  sttModel: string | null;
  /** 사용된 Ollama 모델명 */
  llmModel: string | null;
  /** STT 처리 시간 (ms) */
  sttLatencyMs: number | null;
  /** LLM 처리 시간 (ms) */
  llmLatencyMs: number | null;
  createdAt: number;
  updatedAt: number;
  appVersion: string;
}

export interface HistoryQueryParams {
  /** 페이지 번호 (0부터) */
  page: number;
  /** 페이지 크기 */
  pageSize: number;
  /** 정렬 기준 */
  sortBy?: 'createdAt' | 'durationMs' | 'wordCount';
  /** 정렬 방향 */
  sortOrder?: 'asc' | 'desc';
}

export interface HistoryPage {
  entries: HistoryEntry[];
  total: number;
  page: number;
  pageSize: number;
  totalPages: number;
}

export interface HistoryGetByIdParams {
  id: string;
}

export interface HistoryDeleteParams {
  id: string;
}

export interface HistorySearchParams {
  query: string;
  page: number;
  pageSize: number;
}

export interface HistoryExportParams {
  format: 'json' | 'csv';
  /** 내보내기 시작 날짜 (ISO 8601, 생략 시 전체) */
  from?: string;
  /** 내보내기 종료 날짜 */
  to?: string;
}

// ============================================================
// Dictionary (사전)
// ============================================================

export interface DictionaryEntry {
  id: string;
  /** 단어/구문 */
  word: string;
  /** 발음 힌트 (선택) */
  pronunciation: string | null;
  /** 카테고리 */
  category: 'user' | 'auto' | 'technical';
  /** 사용 횟수 */
  usageCount: number;
  /** 마지막 사용 시각 */
  lastUsedAt: number | null;
  createdAt: number;
  updatedAt: number;
}

export interface DictionaryQueryParams {
  page: number;
  pageSize: number;
  sortBy?: 'word' | 'category' | 'usageCount' | 'createdAt';
  sortOrder?: 'asc' | 'desc';
}

export interface DictionaryPage {
  entries: DictionaryEntry[];
  total: number;
  page: number;
  pageSize: number;
  totalPages: number;
}

export interface DictionaryAddParams {
  word: string;
  pronunciation?: string;
  category?: 'user' | 'auto' | 'technical';
}

export interface DictionaryUpdateParams {
  id: string;
  word?: string;
  pronunciation?: string;
  category?: 'user' | 'auto' | 'technical';
}

export interface DictionaryDeleteParams {
  id: string;
}

export interface DictionaryImportParams {
  /** 파일 경로 */
  filePath: string;
  format: 'json' | 'csv';
}

export interface DictionaryImportResult {
  imported: number;
  skipped: number;
  errors: number;
}

export interface DictionaryExportParams {
  format: 'json' | 'csv';
}

export interface DictionarySearchParams {
  query: string;
  page: number;
  pageSize: number;
}

// ============================================================
// Window (윈도우 제어)
// ============================================================

export type RecordingTipState =
  | 'opening'
  | 'recording'
  | 'thinking'
  | 'result'
  | 'error';

export interface ShowRecordingTipParams {
  state: RecordingTipState;
  /** 결과 텍스트 (state='result' 시) */
  text?: string;
  /** 에러 메시지 (state='error' 시) */
  errorMessage?: string;
}

export interface ShowResultPopupParams {
  text: string;
  /** 자동 숨김 시간 (ms), 0이면 수동 닫기만 */
  autoHideMs?: number;
}

export interface TipMeasuredParams {
  /** 측정된 너비 (px) */
  width: number;
  /** 측정된 높이 (px) */
  height: number;
}

export interface TipStateChangedEvent {
  state: RecordingTipState;
  text?: string;
  errorMessage?: string;
}

export interface TipPrepareEvent {
  state: RecordingTipState;
  text?: string;
}

export interface TipShowEvent {
  state: RecordingTipState;
}

// ============================================================
// System (시스템)
// ============================================================

export interface ActiveAppInfo {
  /** 앱 실행 파일명 */
  name: string;
  /** 윈도우 제목 */
  title: string;
  /** 프로세스 ID */
  pid: number;
}

export interface ShowNotificationParams {
  title: string;
  body: string;
  /** 'info' | 'warning' | 'error' */
  type?: 'info' | 'warning' | 'error';
}

export interface OpenExternalParams {
  url: string;
}

export interface InsertTextParams {
  text: string;
  /** 삽입 방법 (생략 시 설정 기본값) */
  method?: 'clipboard' | 'keyboard';
}

export interface InsertTextResult {
  success: boolean;
  /** 삽입된 문자 수 */
  insertedLength: number;
}

export type SoundEffect =
  | 'recording-start'
  | 'recording-stop'
  | 'transcription-complete'
  | 'error'
  | 'notification';

export interface PlaySoundParams {
  sound: SoundEffect;
}

export interface SetSoundEnabledParams {
  enabled: boolean;
}

// ============================================================
// Stats (통계)
// ============================================================

export interface StatsSummary {
  /** 총 녹음 시간 (ms) */
  totalRecordingTimeMs: number;
  /** 총 단어 수 */
  totalWordCount: number;
  /** 총 세션 수 */
  totalSessionCount: number;
  /** 오늘 녹음 시간 (ms) */
  todayRecordingTimeMs: number;
  /** 오늘 단어 수 */
  todayWordCount: number;
  /** 오늘 세션 수 */
  todaySessionCount: number;
  /** 연속 사용 일수 */
  streakDays: number;
}

export interface StatsQueryParams {
  /** 조회 시작일 (ISO 8601) */
  from: string;
  /** 조회 종료일 */
  to: string;
}

export interface DailyStats {
  date: string;
  recordingTimeMs: number;
  wordCount: number;
  sessionCount: number;
}

export interface WeeklyStats {
  /** 주 시작일 (월요일) */
  weekStart: string;
  recordingTimeMs: number;
  wordCount: number;
  sessionCount: number;
}

4. shared/errors.ts 에러 코드 전체 설계

Speakly의 900+ 에러 코드 체계를 참고하되, 클라우드/인증 관련을 제거하고 로컬 서비스에 맞게 재설계한다.

에러 코드 범위 규칙

범위 카테고리
0 성공
100-199 STT (Whisper)
200-299 TTS
300-399 LLM (Ollama)
400-499 Audio (마이크/캡처)
500-599 Hotkey
600-699 TextInsert (텍스트 삽입)
700-799 History / Dictionary (DB)
800-899 Config (설정)
900-999 System / Window
// src/shared/errors.ts

export enum ErrorCode {
  // === Success ===
  Success = 0,

  // === STT (100-199) ===
  STTEngineNotInstalled = 100,
  STTModelNotFound = 101,
  STTModelNotLoaded = 102,
  STTModelLoadFailed = 103,
  STTModelDownloadFailed = 104,
  STTModelDownloadCancelled = 105,
  STTTranscriptionFailed = 110,
  STTTranscriptionTimeout = 111,
  STTTranscriptionCancelled = 112,
  STTNoAudioData = 113,
  STTAudioTooShort = 114,
  STTLanguageNotSupported = 120,
  STTSidecarSpawnFailed = 130,
  STTSidecarCrashed = 131,
  STTSidecarCommunicationFailed = 132,
  STTGPUNotAvailable = 140,

  // === TTS (200-299) ===
  TTSEngineNotInstalled = 200,
  TTSVoiceNotFound = 201,
  TTSVoiceNotLoaded = 202,
  TTSVoiceLoadFailed = 203,
  TTSVoiceDownloadFailed = 204,
  TTSSynthesisFailed = 210,
  TTSPlaybackFailed = 211,
  TTSPlaybackInterrupted = 212,
  TTSTextTooLong = 220,
  TTSTextEmpty = 221,

  // === LLM / Ollama (300-399) ===
  LLMServerUnreachable = 300,
  LLMServerConnectionFailed = 301,
  LLMServerTimeout = 302,
  LLMModelNotFound = 310,
  LLMModelNotLoaded = 311,
  LLMModelLoadFailed = 312,
  LLMModelPullFailed = 313,
  LLMModelPullCancelled = 314,
  LLMProcessingFailed = 320,
  LLMProcessingTimeout = 321,
  LLMProcessingCancelled = 322,
  LLMResponseParseFailed = 323,
  LLMInvalidAction = 330,
  LLMPromptTooLong = 331,

  // === Audio (400-499) ===
  AudioDeviceNotFound = 400,
  AudioDeviceAccessDenied = 401,
  AudioDeviceBusy = 402,
  AudioCaptureStartFailed = 410,
  AudioCaptureStopFailed = 411,
  AudioCaptureFailed = 412,
  AudioNoPermission = 420,
  AudioStreamError = 430,
  AudioBufferOverflow = 431,

  // === Hotkey (500-599) ===
  HotkeyRegistrationFailed = 500,
  HotkeyConflict = 501,
  HotkeySystemReserved = 502,
  HotkeyHookInitFailed = 510,
  HotkeyHookCrashed = 511,

  // === TextInsert (600-699) ===
  TextInsertFailed = 600,
  TextInsertClipboardSaveFailed = 601,
  TextInsertClipboardRestoreFailed = 602,
  TextInsertKeySimulationFailed = 603,
  TextInsertNoActiveWindow = 610,
  TextInsertTargetAppNotResponding = 611,

  // === History / Dictionary / DB (700-799) ===
  DBOpenFailed = 700,
  DBMigrationFailed = 701,
  DBQueryFailed = 702,
  DBWriteFailed = 703,
  HistoryNotFound = 710,
  HistoryExportFailed = 711,
  DictionaryNotFound = 720,
  DictionaryDuplicate = 721,
  DictionaryImportFailed = 722,
  DictionaryExportFailed = 723,
  DictionaryImportInvalidFormat = 724,

  // === Config (800-899) ===
  ConfigReadFailed = 800,
  ConfigWriteFailed = 801,
  ConfigInvalidValue = 802,
  ConfigKeyNotFound = 803,
  ConfigResetFailed = 804,
  ConfigMigrationFailed = 810,

  // === System / Window (900-999) ===
  WindowCreationFailed = 900,
  WindowNotFound = 901,
  TrayCreationFailed = 910,
  NotificationFailed = 920,
  PermissionDenied = 930,
  ExternalOpenFailed = 940,
  SoundPlayFailed = 950,
  AppAlreadyRunning = 960,
  UnknownError = 999,
}

/**
 * D3RO-VOICE 표준 에러 객체 (Speakly NXError 패턴)
 * 모든 IPC 에러 응답은 이 형태로 전달된다.
 */
export class D3ROError extends Error {
  readonly code: ErrorCode;
  readonly details?: Record<string, unknown>;

  constructor(code: ErrorCode, message: string, details?: Record<string, unknown>) {
    super(message);
    this.name = 'D3ROError';
    this.code = code;
    this.details = details;
  }

  toJSON(): D3ROErrorJSON {
    return {
      code: this.code,
      message: this.message,
      details: this.details,
    };
  }

  static fromJSON(json: D3ROErrorJSON): D3ROError {
    return new D3ROError(json.code, json.message, json.details);
  }
}

export interface D3ROErrorJSON {
  code: ErrorCode;
  message: string;
  details?: Record<string, unknown>;
}

/**
 * IPC 핸들러에서 사용하는 표준 응답 래퍼.
 * 성공 시 { success: true, data }, 실패 시 { success: false, error }
 */
export type IPCResult<T> =
  | { success: true; data: T }
  | { success: false; error: D3ROErrorJSON };

/**
 * IPCResult 헬퍼 함수
 */
export function ipcSuccess<T>(data: T): IPCResult<T> {
  return { success: true, data };
}

export function ipcError<T>(code: ErrorCode, message: string, details?: Record<string, unknown>): IPCResult<T> {
  return { success: false, error: { code, message, details } };
}

5. preload/index.ts 전체 API 설계

contextBridge로 노출할 window.electronAPI 구조. 렌더러는 이 객체를 통해서만 메인 프로세스와 통신한다.

// src/preload/index.ts
import { contextBridge, ipcRenderer } from 'electron';
import { IPC_CHANNELS } from '../shared/ipc-channels';
import type {
  // Voice
  StartRecordingParams, StartRecordingResult,
  StopRecordingParams, StopRecordingResult,
  CancelRecordingParams, VoiceState, SetVoiceModeParams, VoiceMode,
  VoiceStateChangedEvent, TranscriptionDeltaEvent, TranscriptionCompleteEvent,
  VoiceErrorEvent, AudioLevelEvent,
  // Audio
  AudioDevice, SetDeviceParams, TestDeviceParams, TestDeviceResult,
  AudioDeviceChangedEvent,
  // STT
  STTStatus, STTModel, SetSTTModelParams, DownloadModelParams,
  SetSTTLanguageParams, STTStatusChangedEvent, DownloadProgressEvent,
  // TTS
  TTSStatus, TTSVoice, TTSSpeakParams, TTSSpeakResult,
  SetTTSVoiceParams, DownloadVoiceParams,
  TTSStatusChangedEvent, SpeakingStateChangedEvent,
  // LLM
  LLMStatus, LLMModel, SetLLMModelParams, LLMProcessParams, LLMProcessResult,
  SetServerUrlParams, PullModelParams,
  LLMStatusChangedEvent, LLMProcessProgressEvent, LLMPullProgressEvent,
  // Hotkey
  HotkeyBinding, SetHotkeyParams, SetEnabledParams,
  HotkeyTriggeredEvent, HotkeyRecordingResultEvent,
  // Config
  AppConfig, ConfigGetParams, ConfigSetParams, ConfigResetParams,
  SetThemeParams, SetLanguageParams, SetAutoLaunchParams, SetCloseToTrayParams,
  ThemeMode, ConfigChangedEvent,
  // History
  HistoryEntry, HistoryQueryParams, HistoryPage, HistoryGetByIdParams,
  HistoryDeleteParams, HistorySearchParams, HistoryExportParams,
  // Dictionary
  DictionaryEntry, DictionaryQueryParams, DictionaryPage,
  DictionaryAddParams, DictionaryUpdateParams, DictionaryDeleteParams,
  DictionaryImportParams, DictionaryImportResult, DictionaryExportParams,
  DictionarySearchParams,
  // Window
  ShowRecordingTipParams, ShowResultPopupParams, TipMeasuredParams,
  TipStateChangedEvent, TipPrepareEvent, TipShowEvent,
  // System
  ActiveAppInfo, ShowNotificationParams, OpenExternalParams,
  InsertTextParams, InsertTextResult, PlaySoundParams, SetSoundEnabledParams,
  PermissionStatus,
  // Stats
  StatsSummary, StatsQueryParams, DailyStats, WeeklyStats,
} from '../shared/types';
import type { IPCResult } from '../shared/errors';

// 타입 안전한 invoke 헬퍼
function invoke<TResult>(channel: string, ...args: unknown[]): Promise<IPCResult<TResult>> {
  return ipcRenderer.invoke(channel, ...args);
}

// 타입 안전한 send 헬퍼 (fire-and-forget)
function send(channel: string, ...args: unknown[]): void {
  ipcRenderer.send(channel, ...args);
}

// 타입 안전한 이벤트 리스너 헬퍼
type Unsubscribe = () => void;
function on<T>(channel: string, callback: (data: T) => void): Unsubscribe {
  const listener = (_event: Electron.IpcRendererEvent, data: T) => callback(data);
  ipcRenderer.on(channel, listener);
  return () => ipcRenderer.removeListener(channel, listener);
}

const electronAPI = {
  // ── Voice ──────────────────────────────────────────────
  voice: {
    startRecording: (params: StartRecordingParams) =>
      invoke<StartRecordingResult>(IPC_CHANNELS.VOICE.START_RECORDING, params),
    stopRecording: (params: StopRecordingParams) =>
      invoke<StopRecordingResult>(IPC_CHANNELS.VOICE.STOP_RECORDING, params),
    cancelRecording: (params: CancelRecordingParams) =>
      invoke<void>(IPC_CHANNELS.VOICE.CANCEL_RECORDING, params),
    getState: () =>
      invoke<VoiceState>(IPC_CHANNELS.VOICE.GET_STATE),
    setMode: (params: SetVoiceModeParams) =>
      invoke<void>(IPC_CHANNELS.VOICE.SET_MODE, params),
    getMode: () =>
      invoke<VoiceMode>(IPC_CHANNELS.VOICE.GET_MODE),

    onStateChanged: (cb: (e: VoiceStateChangedEvent) => void): Unsubscribe =>
      on(IPC_CHANNELS.VOICE.STATE_CHANGED, cb),
    onTranscriptionDelta: (cb: (e: TranscriptionDeltaEvent) => void): Unsubscribe =>
      on(IPC_CHANNELS.VOICE.TRANSCRIPTION_DELTA, cb),
    onTranscriptionComplete: (cb: (e: TranscriptionCompleteEvent) => void): Unsubscribe =>
      on(IPC_CHANNELS.VOICE.TRANSCRIPTION_COMPLETE, cb),
    onError: (cb: (e: VoiceErrorEvent) => void): Unsubscribe =>
      on(IPC_CHANNELS.VOICE.ERROR, cb),
    onAudioLevel: (cb: (e: AudioLevelEvent) => void): Unsubscribe =>
      on(IPC_CHANNELS.VOICE.AUDIO_LEVEL, cb),
  },

  // ── Audio ──────────────────────────────────────────────
  audio: {
    getDevices: () =>
      invoke<AudioDevice[]>(IPC_CHANNELS.AUDIO.GET_DEVICES),
    getSelectedDevice: () =>
      invoke<string | null>(IPC_CHANNELS.AUDIO.GET_SELECTED_DEVICE),
    setSelectedDevice: (params: SetDeviceParams) =>
      invoke<void>(IPC_CHANNELS.AUDIO.SET_SELECTED_DEVICE, params),
    testDevice: (params: TestDeviceParams) =>
      invoke<TestDeviceResult>(IPC_CHANNELS.AUDIO.TEST_DEVICE, params),

    onDeviceChanged: (cb: (e: AudioDeviceChangedEvent) => void): Unsubscribe =>
      on(IPC_CHANNELS.AUDIO.DEVICE_CHANGED, cb),
  },

  // ── STT ────────────────────────────────────────────────
  stt: {
    getStatus: () =>
      invoke<STTStatus>(IPC_CHANNELS.STT.GET_STATUS),
    getModels: () =>
      invoke<STTModel[]>(IPC_CHANNELS.STT.GET_MODELS),
    getActiveModel: () =>
      invoke<string | null>(IPC_CHANNELS.STT.GET_ACTIVE_MODEL),
    setModel: (params: SetSTTModelParams) =>
      invoke<void>(IPC_CHANNELS.STT.SET_MODEL, params),
    downloadModel: (params: DownloadModelParams) =>
      invoke<void>(IPC_CHANNELS.STT.DOWNLOAD_MODEL, params),
    cancelDownload: () =>
      invoke<void>(IPC_CHANNELS.STT.CANCEL_DOWNLOAD),
    getLanguage: () =>
      invoke<string>(IPC_CHANNELS.STT.GET_LANGUAGE),
    setLanguage: (params: SetSTTLanguageParams) =>
      invoke<void>(IPC_CHANNELS.STT.SET_LANGUAGE, params),

    onStatusChanged: (cb: (e: STTStatusChangedEvent) => void): Unsubscribe =>
      on(IPC_CHANNELS.STT.STATUS_CHANGED, cb),
    onDownloadProgress: (cb: (e: DownloadProgressEvent) => void): Unsubscribe =>
      on(IPC_CHANNELS.STT.DOWNLOAD_PROGRESS, cb),
  },

  // ── TTS ────────────────────────────────────────────────
  tts: {
    speak: (params: TTSSpeakParams) =>
      invoke<TTSSpeakResult>(IPC_CHANNELS.TTS.SPEAK, params),
    stop: () =>
      invoke<void>(IPC_CHANNELS.TTS.STOP),
    getVoices: () =>
      invoke<TTSVoice[]>(IPC_CHANNELS.TTS.GET_VOICES),
    getActiveVoice: () =>
      invoke<string | null>(IPC_CHANNELS.TTS.GET_ACTIVE_VOICE),
    setVoice: (params: SetTTSVoiceParams) =>
      invoke<void>(IPC_CHANNELS.TTS.SET_VOICE, params),
    getStatus: () =>
      invoke<TTSStatus>(IPC_CHANNELS.TTS.GET_STATUS),
    downloadVoice: (params: DownloadVoiceParams) =>
      invoke<void>(IPC_CHANNELS.TTS.DOWNLOAD_VOICE, params),

    onStatusChanged: (cb: (e: TTSStatusChangedEvent) => void): Unsubscribe =>
      on(IPC_CHANNELS.TTS.STATUS_CHANGED, cb),
    onSpeakingStateChanged: (cb: (e: SpeakingStateChangedEvent) => void): Unsubscribe =>
      on(IPC_CHANNELS.TTS.SPEAKING_STATE_CHANGED, cb),
  },

  // ── LLM ────────────────────────────────────────────────
  llm: {
    getStatus: () =>
      invoke<LLMStatus>(IPC_CHANNELS.LLM.GET_STATUS),
    getModels: () =>
      invoke<LLMModel[]>(IPC_CHANNELS.LLM.GET_MODELS),
    getActiveModel: () =>
      invoke<string | null>(IPC_CHANNELS.LLM.GET_ACTIVE_MODEL),
    setModel: (params: SetLLMModelParams) =>
      invoke<void>(IPC_CHANNELS.LLM.SET_MODEL, params),
    process: (params: LLMProcessParams) =>
      invoke<LLMProcessResult>(IPC_CHANNELS.LLM.PROCESS, params),
    cancelProcess: () =>
      invoke<void>(IPC_CHANNELS.LLM.CANCEL_PROCESS),
    getServerUrl: () =>
      invoke<string>(IPC_CHANNELS.LLM.GET_SERVER_URL),
    setServerUrl: (params: SetServerUrlParams) =>
      invoke<void>(IPC_CHANNELS.LLM.SET_SERVER_URL, params),
    pullModel: (params: PullModelParams) =>
      invoke<void>(IPC_CHANNELS.LLM.PULL_MODEL, params),

    onStatusChanged: (cb: (e: LLMStatusChangedEvent) => void): Unsubscribe =>
      on(IPC_CHANNELS.LLM.STATUS_CHANGED, cb),
    onProcessProgress: (cb: (e: LLMProcessProgressEvent) => void): Unsubscribe =>
      on(IPC_CHANNELS.LLM.PROCESS_PROGRESS, cb),
    onPullProgress: (cb: (e: LLMPullProgressEvent) => void): Unsubscribe =>
      on(IPC_CHANNELS.LLM.PULL_PROGRESS, cb),
  },

  // ── Hotkey ─────────────────────────────────────────────
  hotkey: {
    getDictationShortcut: () =>
      invoke<HotkeyBinding>(IPC_CHANNELS.HOTKEY.GET_DICTATION_SHORTCUT),
    setDictationShortcut: (params: SetHotkeyParams) =>
      invoke<void>(IPC_CHANNELS.HOTKEY.SET_DICTATION_SHORTCUT, params),
    getHandsFreeShortcut: () =>
      invoke<HotkeyBinding>(IPC_CHANNELS.HOTKEY.GET_HANDS_FREE_SHORTCUT),
    setHandsFreeShortcut: (params: SetHotkeyParams) =>
      invoke<void>(IPC_CHANNELS.HOTKEY.SET_HANDS_FREE_SHORTCUT, params),
    getCommandShortcut: () =>
      invoke<HotkeyBinding>(IPC_CHANNELS.HOTKEY.GET_COMMAND_SHORTCUT),
    setCommandShortcut: (params: SetHotkeyParams) =>
      invoke<void>(IPC_CHANNELS.HOTKEY.SET_COMMAND_SHORTCUT, params),
    isEnabled: () =>
      invoke<boolean>(IPC_CHANNELS.HOTKEY.IS_ENABLED),
    setEnabled: (params: SetEnabledParams) =>
      invoke<void>(IPC_CHANNELS.HOTKEY.SET_ENABLED, params),
    startRecording: () =>
      invoke<void>(IPC_CHANNELS.HOTKEY.START_RECORDING),
    stopRecording: () =>
      invoke<HotkeyBinding | null>(IPC_CHANNELS.HOTKEY.STOP_RECORDING),

    onTriggered: (cb: (e: HotkeyTriggeredEvent) => void): Unsubscribe =>
      on(IPC_CHANNELS.HOTKEY.TRIGGERED, cb),
    onRecordingResult: (cb: (e: HotkeyRecordingResultEvent) => void): Unsubscribe =>
      on(IPC_CHANNELS.HOTKEY.RECORDING_RESULT, cb),
  },

  // ── Config ─────────────────────────────────────────────
  config: {
    get: (params: ConfigGetParams) =>
      invoke<unknown>(IPC_CHANNELS.CONFIG.GET, params),
    set: (params: ConfigSetParams) =>
      invoke<void>(IPC_CHANNELS.CONFIG.SET, params),
    getAll: () =>
      invoke<AppConfig>(IPC_CHANNELS.CONFIG.GET_ALL),
    reset: (params: ConfigResetParams) =>
      invoke<void>(IPC_CHANNELS.CONFIG.RESET, params),
    getTheme: () =>
      invoke<ThemeMode>(IPC_CHANNELS.CONFIG.GET_THEME),
    setTheme: (params: SetThemeParams) =>
      invoke<void>(IPC_CHANNELS.CONFIG.SET_THEME, params),
    getLanguage: () =>
      invoke<string>(IPC_CHANNELS.CONFIG.GET_LANGUAGE),
    setLanguage: (params: SetLanguageParams) =>
      invoke<void>(IPC_CHANNELS.CONFIG.SET_LANGUAGE, params),
    getAutoLaunch: () =>
      invoke<boolean>(IPC_CHANNELS.CONFIG.GET_AUTO_LAUNCH),
    setAutoLaunch: (params: SetAutoLaunchParams) =>
      invoke<void>(IPC_CHANNELS.CONFIG.SET_AUTO_LAUNCH, params),
    getCloseToTray: () =>
      invoke<boolean>(IPC_CHANNELS.CONFIG.GET_CLOSE_TO_TRAY),
    setCloseToTray: (params: SetCloseToTrayParams) =>
      invoke<void>(IPC_CHANNELS.CONFIG.SET_CLOSE_TO_TRAY, params),

    onChanged: (cb: (e: ConfigChangedEvent) => void): Unsubscribe =>
      on(IPC_CHANNELS.CONFIG.CHANGED, cb),
  },

  // ── History ────────────────────────────────────────────
  history: {
    getAll: (params: HistoryQueryParams) =>
      invoke<HistoryPage>(IPC_CHANNELS.HISTORY.GET_ALL, params),
    getById: (params: HistoryGetByIdParams) =>
      invoke<HistoryEntry | null>(IPC_CHANNELS.HISTORY.GET_BY_ID, params),
    delete: (params: HistoryDeleteParams) =>
      invoke<void>(IPC_CHANNELS.HISTORY.DELETE, params),
    deleteAll: () =>
      invoke<void>(IPC_CHANNELS.HISTORY.DELETE_ALL),
    search: (params: HistorySearchParams) =>
      invoke<HistoryPage>(IPC_CHANNELS.HISTORY.SEARCH, params),
    export: (params: HistoryExportParams) =>
      invoke<string>(IPC_CHANNELS.HISTORY.EXPORT, params),

    onAdded: (cb: (e: HistoryEntry) => void): Unsubscribe =>
      on(IPC_CHANNELS.HISTORY.ADDED, cb),
  },

  // ── Dictionary ─────────────────────────────────────────
  dictionary: {
    getAll: (params: DictionaryQueryParams) =>
      invoke<DictionaryPage>(IPC_CHANNELS.DICTIONARY.GET_ALL, params),
    add: (params: DictionaryAddParams) =>
      invoke<DictionaryEntry>(IPC_CHANNELS.DICTIONARY.ADD, params),
    update: (params: DictionaryUpdateParams) =>
      invoke<DictionaryEntry>(IPC_CHANNELS.DICTIONARY.UPDATE, params),
    delete: (params: DictionaryDeleteParams) =>
      invoke<void>(IPC_CHANNELS.DICTIONARY.DELETE, params),
    import: (params: DictionaryImportParams) =>
      invoke<DictionaryImportResult>(IPC_CHANNELS.DICTIONARY.IMPORT, params),
    export: (params: DictionaryExportParams) =>
      invoke<string>(IPC_CHANNELS.DICTIONARY.EXPORT, params),
    search: (params: DictionarySearchParams) =>
      invoke<DictionaryPage>(IPC_CHANNELS.DICTIONARY.SEARCH, params),
  },

  // ── Window ─────────────────────────────────────────────
  window: {
    minimize: () =>
      send(IPC_CHANNELS.WINDOW.MINIMIZE),
    maximize: () =>
      send(IPC_CHANNELS.WINDOW.MAXIMIZE),
    close: () =>
      send(IPC_CHANNELS.WINDOW.CLOSE),
    isMaximized: () =>
      invoke<boolean>(IPC_CHANNELS.WINDOW.IS_MAXIMIZED),
    showRecordingTip: (params: ShowRecordingTipParams) =>
      send(IPC_CHANNELS.WINDOW.SHOW_RECORDING_TIP, params),
    hideRecordingTip: () =>
      send(IPC_CHANNELS.WINDOW.HIDE_RECORDING_TIP),
    showResultPopup: (params: ShowResultPopupParams) =>
      send(IPC_CHANNELS.WINDOW.SHOW_RESULT_POPUP, params),
    hideResultPopup: () =>
      send(IPC_CHANNELS.WINDOW.HIDE_RESULT_POPUP),
    tipMeasured: (params: TipMeasuredParams) =>
      send(IPC_CHANNELS.WINDOW.TIP_MEASURED, params),

    onTipStateChanged: (cb: (e: TipStateChangedEvent) => void): Unsubscribe =>
      on(IPC_CHANNELS.WINDOW.TIP_STATE_CHANGED, cb),
    onTipPrepare: (cb: (e: TipPrepareEvent) => void): Unsubscribe =>
      on(IPC_CHANNELS.WINDOW.TIP_PREPARE, cb),
    onTipShow: (cb: (e: TipShowEvent) => void): Unsubscribe =>
      on(IPC_CHANNELS.WINDOW.TIP_SHOW, cb),
  },

  // ── System ─────────────────────────────────────────────
  system: {
    getPlatform: () =>
      invoke<NodeJS.Platform>(IPC_CHANNELS.SYSTEM.GET_PLATFORM),
    getVersion: () =>
      invoke<string>(IPC_CHANNELS.SYSTEM.GET_VERSION),
    checkMicPermission: () =>
      invoke<PermissionStatus>(IPC_CHANNELS.SYSTEM.CHECK_MIC_PERMISSION),
    requestMicPermission: () =>
      invoke<PermissionStatus>(IPC_CHANNELS.SYSTEM.REQUEST_MIC_PERMISSION),
    showNotification: (params: ShowNotificationParams) =>
      invoke<void>(IPC_CHANNELS.SYSTEM.SHOW_NOTIFICATION, params),
    openExternal: (params: OpenExternalParams) =>
      invoke<void>(IPC_CHANNELS.SYSTEM.OPEN_EXTERNAL, params),
    getActiveApp: () =>
      invoke<ActiveAppInfo | null>(IPC_CHANNELS.SYSTEM.GET_ACTIVE_APP),
    insertText: (params: InsertTextParams) =>
      invoke<InsertTextResult>(IPC_CHANNELS.SYSTEM.INSERT_TEXT, params),
    playSound: (params: PlaySoundParams) =>
      invoke<void>(IPC_CHANNELS.SYSTEM.PLAY_SOUND, params),
    setSoundEnabled: (params: SetSoundEnabledParams) =>
      invoke<void>(IPC_CHANNELS.SYSTEM.SET_SOUND_ENABLED, params),
    isSoundEnabled: () =>
      invoke<boolean>(IPC_CHANNELS.SYSTEM.IS_SOUND_ENABLED),
  },

  // ── Stats ──────────────────────────────────────────────
  stats: {
    getSummary: () =>
      invoke<StatsSummary>(IPC_CHANNELS.STATS.GET_SUMMARY),
    getDaily: (params: StatsQueryParams) =>
      invoke<DailyStats[]>(IPC_CHANNELS.STATS.GET_DAILY, params),
    getWeekly: (params: StatsQueryParams) =>
      invoke<WeeklyStats[]>(IPC_CHANNELS.STATS.GET_WEEKLY, params),

    onUpdated: (cb: (e: StatsSummary) => void): Unsubscribe =>
      on(IPC_CHANNELS.STATS.UPDATED, cb),
  },
} as const;

// contextBridge로 렌더러에 노출
contextBridge.exposeInMainWorld('electronAPI', electronAPI);

// 렌더러에서 사용할 타입 선언
export type ElectronAPI = typeof electronAPI;

렌더러 타입 선언 (src/renderer/electron.d.ts)

// src/renderer/electron.d.ts
import type { ElectronAPI } from '../preload/index';

declare global {
  interface Window {
    electronAPI: ElectronAPI;
  }
}

6. 채널 통계 요약

카테고리 handle on (R->M) send (M->R) 합계
voice 6 0 5 11
audio 4 0 1 5
stt 8 0 2 10
tts 7 0 2 9
llm 9 0 3 12
hotkey 10 0 2 12
config 12 0 1 13
history 6 0 1 7
dictionary 7 0 0 7
window 1 8 3 12
system 11 0 0 11
stats 3 0 1 4
합계 84 8 21 113

Speakly 대비:

  • 제거: 클라우드 인증, WebSocket STT, 텔레메트리, 피드백, 업데이트, GenSpark 서비스 등 (~90개)
  • 추가: 로컬 STT/TTS/LLM 관리, 모델 다운로드/pull, 통계 (~30개)

7. 설계 결정 사항

7.1 IPCResult 래퍼 패턴

모든 handle 채널은 IPCResult<T> 를 반환한다. 이는 Speakly의 NXError 패턴을 발전시킨 것으로, 렌더러에서 try/catch 없이 success 필드로 분기할 수 있다.

7.2 이벤트 구독 패턴

on* 메서드는 Unsubscribe 함수를 반환한다. React 컴포넌트에서 useEffect cleanup으로 사용:

useEffect(() => {
  const unsub = window.electronAPI.voice.onStateChanged((e) => {
    setState(e.currentState);
  });
  return unsub;
}, []);

7.3 채널명 규칙

  • 형식: ${namespace}:${action} (camelCase)
  • handle 채널: 동사로 시작 (get, set, start, stop, cancel, delete)
  • send 이벤트: 과거분사 또는 명사 (changed, progress, complete, added)

7.4 Vanilla JS 팝업 통신

RecordingTip, ResultPopup 등 Vanilla JS 팝업은 별도 preload 스크립트가 필요하다. 동일한 IPC_CHANNELS 상수를 사용하되, window:tip* 채널만 노출한다.