# 02. IPC 채널 & 타입 명세서 > D3RO-VOICE의 모든 IPC 채널, TypeScript 타입, 에러 코드, preload API를 정의한다. > Speakly의 ~200개 IPC 채널을 참고하되, 클라우드/인증/텔레메트리를 제거하고 로컬 서비스(STT, TTS, LLM)를 추가한다. --- ## 1. IPC 채널 전체 목록 ### 방향 범례 | 기호 | Electron API | 설명 | |------|-------------|------| | `handle` | `ipcMain.handle` / `ipcRenderer.invoke` | 양방향 (요청→응답) | | `on` | `ipcMain.on` | 단방향 Renderer → Main (fire-and-forget) | | `send` | `webContents.send` | 단방향 Main → Renderer (푸시 이벤트) | --- ### 1.1 `voice:*` -- 음성 녹음/인식 오케스트레이션 | 채널명 | 방향 | 파라미터 타입 | 반환 타입 | 담당 서비스 | 설명 | |--------|------|--------------|----------|------------|------| | `voice:startRecording` | handle | `StartRecordingParams` | `StartRecordingResult` | VoiceModeService | 녹음 세션 시작 (마이크 캡처 + STT 준비) | | `voice:stopRecording` | handle | `StopRecordingParams` | `StopRecordingResult` | VoiceModeService | 녹음 중단 및 최종 전사 요청 | | `voice:cancelRecording` | handle | `CancelRecordingParams` | `void` | VoiceModeService | 녹음 취소 (결과 버림) | | `voice:getState` | handle | `void` | `VoiceState` | VoiceModeService | 현재 RecognitionState + AudioState 조회 | | `voice:setMode` | handle | `SetVoiceModeParams` | `void` | VoiceModeService | 녹음 모드 변경 (dictation/hands-free) | | `voice:getMode` | handle | `void` | `VoiceMode` | VoiceModeService | 현재 녹음 모드 조회 | | `voice:stateChanged` | send | -- | `VoiceStateChangedEvent` | VoiceModeService | 상태 전이 알림 | | `voice:transcriptionDelta` | send | -- | `TranscriptionDeltaEvent` | VoiceModeService | 중간 전사 결과 스트리밍 | | `voice:transcriptionComplete` | send | -- | `TranscriptionCompleteEvent` | VoiceModeService | 최종 전사 결과 | | `voice:error` | send | -- | `VoiceErrorEvent` | VoiceModeService | 음성 처리 에러 알림 | | `voice:audioLevel` | send | -- | `AudioLevelEvent` | VoiceModeService | 오디오 레벨 (웨이브 바 애니메이션용, ~100ms 간격) | ### 1.2 `audio:*` -- 오디오 디바이스 & 캡처 | 채널명 | 방향 | 파라미터 타입 | 반환 타입 | 담당 서비스 | 설명 | |--------|------|--------------|----------|------------|------| | `audio:getDevices` | handle | `void` | `AudioDevice[]` | AudioCaptureService | 사용 가능한 마이크 디바이스 목록 | | `audio:getSelectedDevice` | handle | `void` | `string \| null` | AudioCaptureService | 현재 선택된 디바이스 ID | | `audio:setSelectedDevice` | handle | `SetDeviceParams` | `void` | AudioCaptureService | 마이크 디바이스 변경 | | `audio:testDevice` | handle | `TestDeviceParams` | `TestDeviceResult` | AudioCaptureService | 마이크 테스트 (짧은 캡처 후 레벨 반환) | | `audio:deviceChanged` | send | -- | `AudioDeviceChangedEvent` | AudioCaptureService | 디바이스 연결/해제 알림 | ### 1.3 `stt:*` -- 로컬 STT (Whisper) | 채널명 | 방향 | 파라미터 타입 | 반환 타입 | 담당 서비스 | 설명 | |--------|------|--------------|----------|------------|------| | `stt:getStatus` | handle | `void` | `STTStatus` | LocalSTTService | STT 엔진 상태 (모델 로딩 여부, 버전 등) | | `stt:getModels` | handle | `void` | `STTModel[]` | LocalSTTService | 사용 가능한 Whisper 모델 목록 | | `stt:getActiveModel` | handle | `void` | `string \| null` | LocalSTTService | 현재 활성 모델 ID | | `stt:setModel` | handle | `SetSTTModelParams` | `void` | LocalSTTService | 사용할 Whisper 모델 변경 | | `stt:downloadModel` | handle | `DownloadModelParams` | `void` | LocalSTTService | 모델 다운로드 시작 | | `stt:cancelDownload` | handle | `void` | `void` | LocalSTTService | 진행 중인 모델 다운로드 취소 | | `stt:getLanguage` | handle | `void` | `string` | LocalSTTService | STT 인식 언어 조회 | | `stt:setLanguage` | handle | `SetSTTLanguageParams` | `void` | LocalSTTService | STT 인식 언어 변경 | | `stt:statusChanged` | send | -- | `STTStatusChangedEvent` | LocalSTTService | STT 엔진 상태 변경 알림 | | `stt:downloadProgress` | send | -- | `DownloadProgressEvent` | LocalSTTService | 모델 다운로드 진행률 | ### 1.4 `tts:*` -- 로컬 TTS | 채널명 | 방향 | 파라미터 타입 | 반환 타입 | 담당 서비스 | 설명 | |--------|------|--------------|----------|------------|------| | `tts:speak` | handle | `TTSSpeakParams` | `TTSSpeakResult` | LocalTTSService | 텍스트를 음성으로 변환 및 재생 | | `tts:stop` | handle | `void` | `void` | LocalTTSService | 현재 재생 중단 | | `tts:getVoices` | handle | `void` | `TTSVoice[]` | LocalTTSService | 사용 가능한 음성 목록 | | `tts:getActiveVoice` | handle | `void` | `string \| null` | LocalTTSService | 현재 활성 음성 ID | | `tts:setVoice` | handle | `SetTTSVoiceParams` | `void` | LocalTTSService | 사용할 음성 변경 | | `tts:getStatus` | handle | `void` | `TTSStatus` | LocalTTSService | TTS 엔진 상태 | | `tts:downloadVoice` | handle | `DownloadVoiceParams` | `void` | LocalTTSService | 음성 모델 다운로드 | | `tts:statusChanged` | send | -- | `TTSStatusChangedEvent` | LocalTTSService | TTS 엔진 상태 변경 알림 | | `tts:speakingStateChanged` | send | -- | `SpeakingStateChangedEvent` | LocalTTSService | 재생 시작/완료 알림 | ### 1.5 `llm:*` -- 로컬 LLM (Ollama) | 채널명 | 방향 | 파라미터 타입 | 반환 타입 | 담당 서비스 | 설명 | |--------|------|--------------|----------|------------|------| | `llm:getStatus` | handle | `void` | `LLMStatus` | LocalLLMService | Ollama 서버 연결 상태 | | `llm:getModels` | handle | `void` | `LLMModel[]` | LocalLLMService | 사용 가능한 모델 목록 | | `llm:getActiveModel` | handle | `void` | `string \| null` | LocalLLMService | 현재 활성 모델 ID | | `llm:setModel` | handle | `SetLLMModelParams` | `void` | LocalLLMService | 사용할 모델 변경 | | `llm:process` | handle | `LLMProcessParams` | `LLMProcessResult` | LocalLLMService | 텍스트 처리 (다듬기, 번역, 요약 등) | | `llm:cancelProcess` | handle | `void` | `void` | LocalLLMService | 진행 중인 LLM 처리 취소 | | `llm:getServerUrl` | handle | `void` | `string` | LocalLLMService | Ollama 서버 URL 조회 | | `llm:setServerUrl` | handle | `SetServerUrlParams` | `void` | LocalLLMService | Ollama 서버 URL 변경 | | `llm:pullModel` | handle | `PullModelParams` | `void` | LocalLLMService | Ollama 모델 pull 시작 | | `llm:statusChanged` | send | -- | `LLMStatusChangedEvent` | LocalLLMService | Ollama 연결 상태 변경 알림 | | `llm:processProgress` | send | -- | `LLMProcessProgressEvent` | LocalLLMService | LLM 처리 스트리밍 (토큰 단위) | | `llm:pullProgress` | send | -- | `LLMPullProgressEvent` | LocalLLMService | 모델 pull 진행률 | ### 1.6 `hotkey:*` -- 핫키 설정 | 채널명 | 방향 | 파라미터 타입 | 반환 타입 | 담당 서비스 | 설명 | |--------|------|--------------|----------|------------|------| | `hotkey:getDictationShortcut` | handle | `void` | `HotkeyBinding` | HotkeyService | 받아쓰기 핫키 조회 | | `hotkey:setDictationShortcut` | handle | `SetHotkeyParams` | `void` | HotkeyService | 받아쓰기 핫키 변경 | | `hotkey:getHandsFreeShortcut` | handle | `void` | `HotkeyBinding` | HotkeyService | 핸즈프리 모드 핫키 조회 | | `hotkey:setHandsFreeShortcut` | handle | `SetHotkeyParams` | `void` | HotkeyService | 핸즈프리 모드 핫키 변경 | | `hotkey:getCommandShortcut` | handle | `void` | `HotkeyBinding` | HotkeyService | 명령 모드 핫키 조회 | | `hotkey:setCommandShortcut` | handle | `SetHotkeyParams` | `void` | HotkeyService | 명령 모드 핫키 변경 | | `hotkey:isEnabled` | handle | `void` | `boolean` | HotkeyService | 핫키 활성화 상태 조회 | | `hotkey:setEnabled` | handle | `SetEnabledParams` | `void` | HotkeyService | 핫키 전역 활성화/비활성화 | | `hotkey:startRecording` | handle | `void` | `void` | HotkeyService | 핫키 녹화 모드 시작 (설정 UI용) | | `hotkey:stopRecording` | handle | `void` | `HotkeyBinding \| null` | HotkeyService | 핫키 녹화 모드 종료 및 결과 반환 | | `hotkey:triggered` | send | -- | `HotkeyTriggeredEvent` | HotkeyService | 핫키 입력 감지 알림 | | `hotkey:recordingResult` | send | -- | `HotkeyRecordingResultEvent` | HotkeyService | 핫키 녹화 결과 알림 | ### 1.7 `config:*` -- 설정 관리 | 채널명 | 방향 | 파라미터 타입 | 반환 타입 | 담당 서비스 | 설명 | |--------|------|--------------|----------|------------|------| | `config:get` | handle | `ConfigGetParams` | `unknown` | ConfigService | 설정값 조회 (키 기반) | | `config:set` | handle | `ConfigSetParams` | `void` | ConfigService | 설정값 변경 | | `config:getAll` | handle | `void` | `AppConfig` | ConfigService | 전체 설정 조회 | | `config:reset` | handle | `ConfigResetParams` | `void` | ConfigService | 설정값 초기화 (키 지정 또는 전체) | | `config:getTheme` | handle | `void` | `ThemeMode` | ConfigService | 테마 조회 (light/dark/auto) | | `config:setTheme` | handle | `SetThemeParams` | `void` | ConfigService | 테마 변경 | | `config:getLanguage` | handle | `void` | `string` | ConfigService | UI 언어 조회 | | `config:setLanguage` | handle | `SetLanguageParams` | `void` | ConfigService | UI 언어 변경 | | `config:getAutoLaunch` | handle | `void` | `boolean` | ConfigService | 시작 시 자동 실행 여부 | | `config:setAutoLaunch` | handle | `SetAutoLaunchParams` | `void` | ConfigService | 시작 시 자동 실행 설정 | | `config:getCloseToTray` | handle | `void` | `boolean` | ConfigService | 닫기 버튼 시 트레이로 최소화 여부 | | `config:setCloseToTray` | handle | `SetCloseToTrayParams` | `void` | ConfigService | 트레이로 최소화 설정 | | `config:changed` | send | -- | `ConfigChangedEvent` | ConfigService | 설정 변경 알림 | ### 1.8 `history:*` -- 히스토리 | 채널명 | 방향 | 파라미터 타입 | 반환 타입 | 담당 서비스 | 설명 | |--------|------|--------------|----------|------------|------| | `history:getAll` | handle | `HistoryQueryParams` | `HistoryPage` | HistoryService | 히스토리 목록 조회 (페이지네이션) | | `history:getById` | handle | `HistoryGetByIdParams` | `HistoryEntry \| null` | HistoryService | 단건 히스토리 조회 | | `history:delete` | handle | `HistoryDeleteParams` | `void` | HistoryService | 히스토리 삭제 | | `history:deleteAll` | handle | `void` | `void` | HistoryService | 전체 히스토리 삭제 | | `history:search` | handle | `HistorySearchParams` | `HistoryPage` | HistoryService | 히스토리 전문 검색 | | `history:export` | handle | `HistoryExportParams` | `string` | HistoryService | 히스토리 내보내기 (파일 경로 반환) | | `history:added` | send | -- | `HistoryEntry` | HistoryService | 새 히스토리 추가 알림 | ### 1.9 `dictionary:*` -- 사전 | 채널명 | 방향 | 파라미터 타입 | 반환 타입 | 담당 서비스 | 설명 | |--------|------|--------------|----------|------------|------| | `dictionary:getAll` | handle | `DictionaryQueryParams` | `DictionaryPage` | DictionaryService | 사전 항목 목록 조회 | | `dictionary:add` | handle | `DictionaryAddParams` | `DictionaryEntry` | DictionaryService | 사전 항목 추가 | | `dictionary:update` | handle | `DictionaryUpdateParams` | `DictionaryEntry` | DictionaryService | 사전 항목 수정 | | `dictionary:delete` | handle | `DictionaryDeleteParams` | `void` | DictionaryService | 사전 항목 삭제 | | `dictionary:import` | handle | `DictionaryImportParams` | `DictionaryImportResult` | DictionaryService | 사전 가져오기 (CSV/JSON) | | `dictionary:export` | handle | `DictionaryExportParams` | `string` | DictionaryService | 사전 내보내기 (파일 경로 반환) | | `dictionary:search` | handle | `DictionarySearchParams` | `DictionaryPage` | DictionaryService | 사전 검색 | ### 1.10 `window:*` -- 윈도우 제어 | 채널명 | 방향 | 파라미터 타입 | 반환 타입 | 담당 서비스 | 설명 | |--------|------|--------------|----------|------------|------| | `window:minimize` | on | `void` | -- | WindowManager | 메인 윈도우 최소화 | | `window:maximize` | on | `void` | -- | WindowManager | 메인 윈도우 최대화/복원 토글 | | `window:close` | on | `void` | -- | WindowManager | 메인 윈도우 닫기 | | `window:isMaximized` | handle | `void` | `boolean` | WindowManager | 최대화 상태 조회 | | `window:showRecordingTip` | on | `ShowRecordingTipParams` | -- | WindowManager | RecordingTip 팝업 표시 | | `window:hideRecordingTip` | on | `void` | -- | WindowManager | RecordingTip 팝업 숨김 | | `window:showResultPopup` | on | `ShowResultPopupParams` | -- | WindowManager | ResultPopup 팝업 표시 | | `window:hideResultPopup` | on | `void` | -- | WindowManager | ResultPopup 팝업 숨김 | | `window:tipMeasured` | on | `TipMeasuredParams` | -- | WindowManager | RecordingTip 측정 완료 (2-phase 리사이즈) | | `window:tipStateChanged` | send | -- | `TipStateChangedEvent` | WindowManager | RecordingTip 상태 변경 전달 | | `window:tipPrepare` | send | -- | `TipPrepareEvent` | WindowManager | RecordingTip 측정 요청 (2-phase step 1) | | `window:tipShow` | send | -- | `TipShowEvent` | WindowManager | RecordingTip 표시 요청 (2-phase step 2) | ### 1.11 `system:*` -- 시스템 (권한, 알림, 외부 앱) | 채널명 | 방향 | 파라미터 타입 | 반환 타입 | 담당 서비스 | 설명 | |--------|------|--------------|----------|------------|------| | `system:getPlatform` | handle | `void` | `NodeJS.Platform` | SystemService | OS 플랫폼 조회 | | `system:getVersion` | handle | `void` | `string` | SystemService | 앱 버전 조회 | | `system:checkMicPermission` | handle | `void` | `PermissionStatus` | PermissionService | 마이크 권한 상태 확인 | | `system:requestMicPermission` | handle | `void` | `PermissionStatus` | PermissionService | 마이크 권한 요청 | | `system:showNotification` | handle | `ShowNotificationParams` | `void` | SystemService | 시스템 알림 표시 | | `system:openExternal` | handle | `OpenExternalParams` | `void` | SystemService | 외부 URL/파일 열기 | | `system:getActiveApp` | handle | `void` | `ActiveAppInfo \| null` | SystemService | 현재 활성 앱 정보 조회 | | `system:insertText` | handle | `InsertTextParams` | `InsertTextResult` | TextInsertService | 활성 앱에 텍스트 삽입 (클립보드 방식) | | `system:playSound` | handle | `PlaySoundParams` | `void` | SoundEffectService | 효과음 재생 | | `system:setSoundEnabled` | handle | `SetSoundEnabledParams` | `void` | SoundEffectService | 효과음 on/off | | `system:isSoundEnabled` | handle | `void` | `boolean` | SoundEffectService | 효과음 활성화 상태 | ### 1.12 `stats:*` -- 통계 | 채널명 | 방향 | 파라미터 타입 | 반환 타입 | 담당 서비스 | 설명 | |--------|------|--------------|----------|------------|------| | `stats:getSummary` | handle | `void` | `StatsSummary` | StatsService | 전체 통계 요약 (총 시간, 단어 수 등) | | `stats:getDaily` | handle | `StatsQueryParams` | `DailyStats[]` | StatsService | 일별 통계 | | `stats:getWeekly` | handle | `StatsQueryParams` | `WeeklyStats[]` | StatsService | 주별 통계 | | `stats:updated` | send | -- | `StatsSummary` | StatsService | 통계 갱신 알림 | --- ## 2. `shared/ipc-channels.ts` 전체 코드 ```typescript // src/shared/ipc-channels.ts // IPC 채널명 중앙 정의 — 모든 채널명은 이 파일에서만 정의한다. export const IPC_CHANNELS = { VOICE: { START_RECORDING: 'voice:startRecording', STOP_RECORDING: 'voice:stopRecording', CANCEL_RECORDING: 'voice:cancelRecording', GET_STATE: 'voice:getState', SET_MODE: 'voice:setMode', GET_MODE: 'voice:getMode', // Main → Renderer events STATE_CHANGED: 'voice:stateChanged', TRANSCRIPTION_DELTA: 'voice:transcriptionDelta', TRANSCRIPTION_COMPLETE: 'voice:transcriptionComplete', ERROR: 'voice:error', AUDIO_LEVEL: 'voice:audioLevel', }, AUDIO: { GET_DEVICES: 'audio:getDevices', GET_SELECTED_DEVICE: 'audio:getSelectedDevice', SET_SELECTED_DEVICE: 'audio:setSelectedDevice', TEST_DEVICE: 'audio:testDevice', // Main → Renderer events DEVICE_CHANGED: 'audio:deviceChanged', }, STT: { GET_STATUS: 'stt:getStatus', GET_MODELS: 'stt:getModels', GET_ACTIVE_MODEL: 'stt:getActiveModel', SET_MODEL: 'stt:setModel', DOWNLOAD_MODEL: 'stt:downloadModel', CANCEL_DOWNLOAD: 'stt:cancelDownload', GET_LANGUAGE: 'stt:getLanguage', SET_LANGUAGE: 'stt:setLanguage', // Main → Renderer events STATUS_CHANGED: 'stt:statusChanged', DOWNLOAD_PROGRESS: 'stt:downloadProgress', }, TTS: { SPEAK: 'tts:speak', STOP: 'tts:stop', GET_VOICES: 'tts:getVoices', GET_ACTIVE_VOICE: 'tts:getActiveVoice', SET_VOICE: 'tts:setVoice', GET_STATUS: 'tts:getStatus', DOWNLOAD_VOICE: 'tts:downloadVoice', // Main → Renderer events STATUS_CHANGED: 'tts:statusChanged', SPEAKING_STATE_CHANGED: 'tts:speakingStateChanged', }, LLM: { GET_STATUS: 'llm:getStatus', GET_MODELS: 'llm:getModels', GET_ACTIVE_MODEL: 'llm:getActiveModel', SET_MODEL: 'llm:setModel', PROCESS: 'llm:process', CANCEL_PROCESS: 'llm:cancelProcess', GET_SERVER_URL: 'llm:getServerUrl', SET_SERVER_URL: 'llm:setServerUrl', PULL_MODEL: 'llm:pullModel', // Main → Renderer events STATUS_CHANGED: 'llm:statusChanged', PROCESS_PROGRESS: 'llm:processProgress', PULL_PROGRESS: 'llm:pullProgress', }, HOTKEY: { GET_DICTATION_SHORTCUT: 'hotkey:getDictationShortcut', SET_DICTATION_SHORTCUT: 'hotkey:setDictationShortcut', GET_HANDS_FREE_SHORTCUT: 'hotkey:getHandsFreeShortcut', SET_HANDS_FREE_SHORTCUT: 'hotkey:setHandsFreeShortcut', GET_COMMAND_SHORTCUT: 'hotkey:getCommandShortcut', SET_COMMAND_SHORTCUT: 'hotkey:setCommandShortcut', IS_ENABLED: 'hotkey:isEnabled', SET_ENABLED: 'hotkey:setEnabled', START_RECORDING: 'hotkey:startRecording', STOP_RECORDING: 'hotkey:stopRecording', // Main → Renderer events TRIGGERED: 'hotkey:triggered', RECORDING_RESULT: 'hotkey:recordingResult', }, CONFIG: { GET: 'config:get', SET: 'config:set', GET_ALL: 'config:getAll', RESET: 'config:reset', GET_THEME: 'config:getTheme', SET_THEME: 'config:setTheme', GET_LANGUAGE: 'config:getLanguage', SET_LANGUAGE: 'config:setLanguage', GET_AUTO_LAUNCH: 'config:getAutoLaunch', SET_AUTO_LAUNCH: 'config:setAutoLaunch', GET_CLOSE_TO_TRAY: 'config:getCloseToTray', SET_CLOSE_TO_TRAY: 'config:setCloseToTray', // Main → Renderer events CHANGED: 'config:changed', }, HISTORY: { GET_ALL: 'history:getAll', GET_BY_ID: 'history:getById', DELETE: 'history:delete', DELETE_ALL: 'history:deleteAll', SEARCH: 'history:search', EXPORT: 'history:export', // Main → Renderer events ADDED: 'history:added', }, DICTIONARY: { GET_ALL: 'dictionary:getAll', ADD: 'dictionary:add', UPDATE: 'dictionary:update', DELETE: 'dictionary:delete', IMPORT: 'dictionary:import', EXPORT: 'dictionary:export', SEARCH: 'dictionary:search', }, WINDOW: { MINIMIZE: 'window:minimize', MAXIMIZE: 'window:maximize', CLOSE: 'window:close', IS_MAXIMIZED: 'window:isMaximized', SHOW_RECORDING_TIP: 'window:showRecordingTip', HIDE_RECORDING_TIP: 'window:hideRecordingTip', SHOW_RESULT_POPUP: 'window:showResultPopup', HIDE_RESULT_POPUP: 'window:hideResultPopup', TIP_MEASURED: 'window:tipMeasured', // Main → Renderer events TIP_STATE_CHANGED: 'window:tipStateChanged', TIP_PREPARE: 'window:tipPrepare', TIP_SHOW: 'window:tipShow', }, SYSTEM: { GET_PLATFORM: 'system:getPlatform', GET_VERSION: 'system:getVersion', CHECK_MIC_PERMISSION: 'system:checkMicPermission', REQUEST_MIC_PERMISSION: 'system:requestMicPermission', SHOW_NOTIFICATION: 'system:showNotification', OPEN_EXTERNAL: 'system:openExternal', GET_ACTIVE_APP: 'system:getActiveApp', INSERT_TEXT: 'system:insertText', PLAY_SOUND: 'system:playSound', SET_SOUND_ENABLED: 'system:setSoundEnabled', IS_SOUND_ENABLED: 'system:isSoundEnabled', }, STATS: { GET_SUMMARY: 'stats:getSummary', GET_DAILY: 'stats:getDaily', GET_WEEKLY: 'stats:getWeekly', // Main → Renderer events UPDATED: 'stats:updated', }, } as const; // 타입 유틸리티: 채널명 유니온 추출 type NestedValues = T extends Record ? V extends string ? V : NestedValues : never; export type IPCChannel = NestedValues; ``` --- ## 3. `shared/types.ts` 전체 코드 ```typescript // src/shared/types.ts // 모든 IPC 파라미터/반환 타입 정의 // ============================================================ // Common // ============================================================ export type ThemeMode = 'light' | 'dark' | 'auto'; export type VoiceMode = 'dictation' | 'hands-free'; export type PermissionStatus = 'granted' | 'denied' | 'unknown'; // ============================================================ // Voice (음성 오케스트레이션) // ============================================================ export enum RecognitionState { IDLE = 'idle', PREPARING = 'preparing', CONNECTING = 'connecting', READY = 'ready', RECOGNIZING = 'recognizing', COMPLETED = 'completed', CANCELLED = 'cancelled', ERROR = 'error', DESTROYED = 'destroyed', } export enum AudioState { IDLE = 'idle', INITIALIZING = 'initializing', STREAMING = 'streaming', STOPPED = 'stopped', } export interface VoiceState { recognitionState: RecognitionState; audioState: AudioState; mode: VoiceMode; sessionId: string | null; /** 현재 세션 녹음 시작 시각 (ms epoch), null이면 비활성 */ recordingStartedAt: number | null; } export interface StartRecordingParams { /** 세션 ID (자동 생성 시 생략 가능) */ sessionId?: string; /** 마이크 디바이스 ID (생략 시 기본 디바이스) */ deviceId?: string; } export interface StartRecordingResult { sessionId: string; } export interface StopRecordingParams { sessionId: string; } export interface StopRecordingResult { sessionId: string; /** 최종 전사 텍스트 (완료 전이면 빈 문자열) */ text: string; /** 녹음 지속 시간 (ms) */ durationMs: number; } export interface CancelRecordingParams { sessionId: string; } export interface SetVoiceModeParams { mode: VoiceMode; } // Voice events (Main → Renderer) export interface VoiceStateChangedEvent { previousState: RecognitionState; currentState: RecognitionState; audioState: AudioState; sessionId: string | null; } export interface TranscriptionDeltaEvent { sessionId: string; /** 중간 전사 텍스트 (누적) */ text: string; /** 마지막 델타 부분 */ delta: string; isFinal: boolean; } export interface TranscriptionCompleteEvent { sessionId: string; text: string; durationMs: number; language: string; } export interface VoiceErrorEvent { sessionId: string | null; errorCode: number; message: string; } export interface AudioLevelEvent { /** 0.0 ~ 1.0 정규화된 오디오 레벨 */ level: number; } // ============================================================ // Audio (디바이스 & 캡처) // ============================================================ export interface AudioDevice { deviceId: string; label: string; isDefault: boolean; } export interface SetDeviceParams { deviceId: string; } export interface TestDeviceParams { deviceId: string; /** 테스트 지속 시간 (ms), 기본 2000 */ durationMs?: number; } export interface TestDeviceResult { /** 평균 오디오 레벨 (0.0 ~ 1.0) */ averageLevel: number; /** 피크 오디오 레벨 */ peakLevel: number; /** 오디오 데이터를 받았는지 여부 */ hasAudio: boolean; } export interface AudioDeviceChangedEvent { devices: AudioDevice[]; /** 변경 유형 */ type: 'added' | 'removed' | 'default-changed'; } // ============================================================ // STT (로컬 Whisper) // ============================================================ export enum STTEngineState { NOT_INSTALLED = 'not-installed', DOWNLOADING = 'downloading', LOADING = 'loading', READY = 'ready', PROCESSING = 'processing', ERROR = 'error', } export interface STTStatus { engineState: STTEngineState; activeModel: string | null; /** faster-whisper 또는 whisper.cpp 버전 */ engineVersion: string | null; /** GPU 가속 사용 여부 */ gpuAccelerated: boolean; } export interface STTModel { id: string; name: string; /** 모델 크기 (bytes) */ sizeBytes: number; /** 다운로드 완료 여부 */ downloaded: boolean; /** 지원 언어 목록 (ISO 639-1) */ languages: string[]; /** 상대적 정확도 (1-5, 5가 가장 높음) */ accuracy: number; /** 상대적 속도 (1-5, 5가 가장 빠름) */ speed: number; } export interface SetSTTModelParams { modelId: string; } export interface DownloadModelParams { modelId: string; } export interface SetSTTLanguageParams { /** ISO 639-1 언어 코드 (예: 'ko', 'en', 'auto') */ language: string; } export interface STTStatusChangedEvent { status: STTStatus; } export interface DownloadProgressEvent { modelId: string; /** 0 ~ 100 */ percent: number; /** 다운로드된 바이트 */ downloadedBytes: number; /** 전체 바이트 */ totalBytes: number; /** 초당 바이트 */ bytesPerSecond: number; } // ============================================================ // TTS (로컬 TTS) // ============================================================ export enum TTSEngineState { NOT_INSTALLED = 'not-installed', LOADING = 'loading', READY = 'ready', SPEAKING = 'speaking', ERROR = 'error', } export interface TTSStatus { engineState: TTSEngineState; activeVoice: string | null; engineVersion: string | null; } export interface TTSVoice { id: string; name: string; language: string; /** 음성 성별 */ gender: 'male' | 'female' | 'neutral'; /** 다운로드 완료 여부 */ downloaded: boolean; sizeBytes: number; } export interface TTSSpeakParams { text: string; /** 음성 ID (생략 시 활성 음성) */ voiceId?: string; /** 재생 속도 (0.5 ~ 2.0, 기본 1.0) */ speed?: number; } export interface TTSSpeakResult { /** 생성된 오디오 지속 시간 (ms) */ durationMs: number; } export interface SetTTSVoiceParams { voiceId: string; } export interface DownloadVoiceParams { voiceId: string; } export interface TTSStatusChangedEvent { status: TTSStatus; } export interface SpeakingStateChangedEvent { isSpeaking: boolean; /** 현재/마지막 재생 텍스트 */ text: string; } // ============================================================ // LLM (Ollama) // ============================================================ export enum LLMConnectionState { DISCONNECTED = 'disconnected', CONNECTING = 'connecting', CONNECTED = 'connected', ERROR = 'error', } export interface LLMStatus { connectionState: LLMConnectionState; serverUrl: string; activeModel: string | null; /** Ollama 서버 버전 */ serverVersion: string | null; } export interface LLMModel { id: string; name: string; /** 모델 크기 (bytes) */ sizeBytes: number; /** 파라미터 수 문자열 (예: '7B', '13B') */ parameterSize: string; /** 양자화 레벨 (예: 'Q4_K_M') */ quantization: string; /** 수정 시각 (ISO 8601) */ modifiedAt: string; } export type LLMAction = | 'refine' // 텍스트 다듬기 | 'translate' // 번역 | 'summarize' // 요약 | 'expand' // 확장 | 'grammar' // 문법 교정 | 'custom'; // 커스텀 프롬프트 export interface LLMProcessParams { text: string; action: LLMAction; /** translate 시 대상 언어 */ targetLanguage?: string; /** custom 시 프롬프트 */ customPrompt?: string; /** 사용할 모델 ID (생략 시 활성 모델) */ modelId?: string; } export interface LLMProcessResult { originalText: string; processedText: string; action: LLMAction; /** 처리 시간 (ms) */ processingTimeMs: number; /** 사용된 토큰 수 */ tokenCount: number; } export interface SetLLMModelParams { modelId: string; } export interface SetServerUrlParams { url: string; } export interface PullModelParams { modelName: string; } export interface LLMStatusChangedEvent { status: LLMStatus; } export interface LLMProcessProgressEvent { /** 누적 생성 텍스트 */ text: string; /** 마지막 토큰 */ token: string; /** 완료 여부 */ done: boolean; } export interface LLMPullProgressEvent { modelName: string; status: string; /** 0 ~ 100 */ percent: number; /** 다운로드된 바이트 */ downloadedBytes: number; totalBytes: number; } // ============================================================ // Hotkey (핫키) // ============================================================ export interface HotkeyBinding { /** uiohook 키코드 */ keyCode: number; /** Ctrl 수식자 */ ctrl: boolean; /** Alt 수식자 */ alt: boolean; /** Shift 수식자 */ shift: boolean; /** Meta(Win) 수식자 */ meta: boolean; /** 표시용 문자열 (예: 'Right Alt') */ displayLabel: string; } export interface SetHotkeyParams { binding: HotkeyBinding; } export interface SetEnabledParams { enabled: boolean; } export type HotkeyAction = 'dictation' | 'hands-free' | 'command'; export interface HotkeyTriggeredEvent { action: HotkeyAction; /** 'pressed' | 'released' — hold-to-talk용 */ type: 'pressed' | 'released'; /** 더블프레스 여부 (300ms 이내) */ isDoublePress: boolean; } export interface HotkeyRecordingResultEvent { binding: HotkeyBinding | null; /** 시스템 예약 키 충돌 시 사유 */ conflictReason: string | null; } // ============================================================ // Config (설정) // ============================================================ export interface AppConfig { // UI theme: ThemeMode; language: string; closeToTray: boolean; autoLaunch: boolean; soundEnabled: boolean; // Audio selectedDeviceId: string | null; // STT sttModelId: string; sttLanguage: string; // TTS ttsVoiceId: string | null; ttsSpeed: number; // LLM ollamaServerUrl: string; llmModelId: string | null; defaultLLMAction: LLMAction; // Hotkey dictationShortcut: HotkeyBinding; handsFreeShortcut: HotkeyBinding; commandShortcut: HotkeyBinding; hotkeyEnabled: boolean; // Text Insert insertMethod: 'clipboard' | 'keyboard'; autoInsert: boolean; // History maxHistoryEntries: number; } export interface ConfigGetParams { key: keyof AppConfig; } export interface ConfigSetParams { key: keyof AppConfig; value: AppConfig[keyof AppConfig]; } export interface ConfigResetParams { /** 초기화할 키 (생략 시 전체 초기화) */ key?: keyof AppConfig; } export interface SetThemeParams { theme: ThemeMode; } export interface SetLanguageParams { language: string; } export interface SetAutoLaunchParams { enabled: boolean; } export interface SetCloseToTrayParams { enabled: boolean; } export interface ConfigChangedEvent { key: keyof AppConfig; value: AppConfig[keyof AppConfig]; previousValue: AppConfig[keyof AppConfig]; } // ============================================================ // History (히스토리) // ============================================================ export interface HistoryEntry { id: string; /** 원본 전사 텍스트 */ originalText: string; /** LLM 다듬기/번역 결과 (미처리 시 null) */ polishedText: string | null; /** 포커스 앱 실행 경로 */ focusedApp: string | null; /** 포커스 앱 이름 */ focusedAppName: string | null; /** 포커스 윈도우 타이틀 */ focusedAppWindowTitle: string | null; /** 녹음 모드 */ mode: 'dictation' | 'translate' | 'command'; /** 세션 상태 */ status: 'completed' | 'cancelled' | 'error'; /** 에러 코드 (실패 시) */ errorCode: string | null; /** 녹음 파일 경로 */ audioLocalPath: string | null; /** 녹음 시간 (초) */ duration: number; /** Whisper 감지 언어 */ detectedLanguage: string | null; /** 마이크 디바이스 ID */ micDevice: string | null; /** 단어 수 */ wordCount: number; /** 사용된 Whisper 모델명 */ sttModel: string | null; /** 사용된 Ollama 모델명 */ llmModel: string | null; /** STT 처리 시간 (ms) */ sttLatencyMs: number | null; /** LLM 처리 시간 (ms) */ llmLatencyMs: number | null; createdAt: number; updatedAt: number; appVersion: string; } export interface HistoryQueryParams { /** 페이지 번호 (0부터) */ page: number; /** 페이지 크기 */ pageSize: number; /** 정렬 기준 */ sortBy?: 'createdAt' | 'durationMs' | 'wordCount'; /** 정렬 방향 */ sortOrder?: 'asc' | 'desc'; } export interface HistoryPage { entries: HistoryEntry[]; total: number; page: number; pageSize: number; totalPages: number; } export interface HistoryGetByIdParams { id: string; } export interface HistoryDeleteParams { id: string; } export interface HistorySearchParams { query: string; page: number; pageSize: number; } export interface HistoryExportParams { format: 'json' | 'csv'; /** 내보내기 시작 날짜 (ISO 8601, 생략 시 전체) */ from?: string; /** 내보내기 종료 날짜 */ to?: string; } // ============================================================ // Dictionary (사전) // ============================================================ export interface DictionaryEntry { id: string; /** 단어/구문 */ word: string; /** 발음 힌트 (선택) */ pronunciation: string | null; /** 카테고리 */ category: 'user' | 'auto' | 'technical'; /** 사용 횟수 */ usageCount: number; /** 마지막 사용 시각 */ lastUsedAt: number | null; createdAt: number; updatedAt: number; } export interface DictionaryQueryParams { page: number; pageSize: number; sortBy?: 'word' | 'category' | 'usageCount' | 'createdAt'; sortOrder?: 'asc' | 'desc'; } export interface DictionaryPage { entries: DictionaryEntry[]; total: number; page: number; pageSize: number; totalPages: number; } export interface DictionaryAddParams { word: string; pronunciation?: string; category?: 'user' | 'auto' | 'technical'; } export interface DictionaryUpdateParams { id: string; word?: string; pronunciation?: string; category?: 'user' | 'auto' | 'technical'; } export interface DictionaryDeleteParams { id: string; } export interface DictionaryImportParams { /** 파일 경로 */ filePath: string; format: 'json' | 'csv'; } export interface DictionaryImportResult { imported: number; skipped: number; errors: number; } export interface DictionaryExportParams { format: 'json' | 'csv'; } export interface DictionarySearchParams { query: string; page: number; pageSize: number; } // ============================================================ // Window (윈도우 제어) // ============================================================ export type RecordingTipState = | 'opening' | 'recording' | 'thinking' | 'result' | 'error'; export interface ShowRecordingTipParams { state: RecordingTipState; /** 결과 텍스트 (state='result' 시) */ text?: string; /** 에러 메시지 (state='error' 시) */ errorMessage?: string; } export interface ShowResultPopupParams { text: string; /** 자동 숨김 시간 (ms), 0이면 수동 닫기만 */ autoHideMs?: number; } export interface TipMeasuredParams { /** 측정된 너비 (px) */ width: number; /** 측정된 높이 (px) */ height: number; } export interface TipStateChangedEvent { state: RecordingTipState; text?: string; errorMessage?: string; } export interface TipPrepareEvent { state: RecordingTipState; text?: string; } export interface TipShowEvent { state: RecordingTipState; } // ============================================================ // System (시스템) // ============================================================ export interface ActiveAppInfo { /** 앱 실행 파일명 */ name: string; /** 윈도우 제목 */ title: string; /** 프로세스 ID */ pid: number; } export interface ShowNotificationParams { title: string; body: string; /** 'info' | 'warning' | 'error' */ type?: 'info' | 'warning' | 'error'; } export interface OpenExternalParams { url: string; } export interface InsertTextParams { text: string; /** 삽입 방법 (생략 시 설정 기본값) */ method?: 'clipboard' | 'keyboard'; } export interface InsertTextResult { success: boolean; /** 삽입된 문자 수 */ insertedLength: number; } export type SoundEffect = | 'recording-start' | 'recording-stop' | 'transcription-complete' | 'error' | 'notification'; export interface PlaySoundParams { sound: SoundEffect; } export interface SetSoundEnabledParams { enabled: boolean; } // ============================================================ // Stats (통계) // ============================================================ export interface StatsSummary { /** 총 녹음 시간 (ms) */ totalRecordingTimeMs: number; /** 총 단어 수 */ totalWordCount: number; /** 총 세션 수 */ totalSessionCount: number; /** 오늘 녹음 시간 (ms) */ todayRecordingTimeMs: number; /** 오늘 단어 수 */ todayWordCount: number; /** 오늘 세션 수 */ todaySessionCount: number; /** 연속 사용 일수 */ streakDays: number; } export interface StatsQueryParams { /** 조회 시작일 (ISO 8601) */ from: string; /** 조회 종료일 */ to: string; } export interface DailyStats { date: string; recordingTimeMs: number; wordCount: number; sessionCount: number; } export interface WeeklyStats { /** 주 시작일 (월요일) */ weekStart: string; recordingTimeMs: number; wordCount: number; sessionCount: number; } ``` --- ## 4. `shared/errors.ts` 에러 코드 전체 설계 Speakly의 900+ 에러 코드 체계를 참고하되, 클라우드/인증 관련을 제거하고 로컬 서비스에 맞게 재설계한다. ### 에러 코드 범위 규칙 | 범위 | 카테고리 | |------|---------| | 0 | 성공 | | 100-199 | STT (Whisper) | | 200-299 | TTS | | 300-399 | LLM (Ollama) | | 400-499 | Audio (마이크/캡처) | | 500-599 | Hotkey | | 600-699 | TextInsert (텍스트 삽입) | | 700-799 | History / Dictionary (DB) | | 800-899 | Config (설정) | | 900-999 | System / Window | ```typescript // src/shared/errors.ts export enum ErrorCode { // === Success === Success = 0, // === STT (100-199) === STTEngineNotInstalled = 100, STTModelNotFound = 101, STTModelNotLoaded = 102, STTModelLoadFailed = 103, STTModelDownloadFailed = 104, STTModelDownloadCancelled = 105, STTTranscriptionFailed = 110, STTTranscriptionTimeout = 111, STTTranscriptionCancelled = 112, STTNoAudioData = 113, STTAudioTooShort = 114, STTLanguageNotSupported = 120, STTSidecarSpawnFailed = 130, STTSidecarCrashed = 131, STTSidecarCommunicationFailed = 132, STTGPUNotAvailable = 140, // === TTS (200-299) === TTSEngineNotInstalled = 200, TTSVoiceNotFound = 201, TTSVoiceNotLoaded = 202, TTSVoiceLoadFailed = 203, TTSVoiceDownloadFailed = 204, TTSSynthesisFailed = 210, TTSPlaybackFailed = 211, TTSPlaybackInterrupted = 212, TTSTextTooLong = 220, TTSTextEmpty = 221, // === LLM / Ollama (300-399) === LLMServerUnreachable = 300, LLMServerConnectionFailed = 301, LLMServerTimeout = 302, LLMModelNotFound = 310, LLMModelNotLoaded = 311, LLMModelLoadFailed = 312, LLMModelPullFailed = 313, LLMModelPullCancelled = 314, LLMProcessingFailed = 320, LLMProcessingTimeout = 321, LLMProcessingCancelled = 322, LLMResponseParseFailed = 323, LLMInvalidAction = 330, LLMPromptTooLong = 331, // === Audio (400-499) === AudioDeviceNotFound = 400, AudioDeviceAccessDenied = 401, AudioDeviceBusy = 402, AudioCaptureStartFailed = 410, AudioCaptureStopFailed = 411, AudioCaptureFailed = 412, AudioNoPermission = 420, AudioStreamError = 430, AudioBufferOverflow = 431, // === Hotkey (500-599) === HotkeyRegistrationFailed = 500, HotkeyConflict = 501, HotkeySystemReserved = 502, HotkeyHookInitFailed = 510, HotkeyHookCrashed = 511, // === TextInsert (600-699) === TextInsertFailed = 600, TextInsertClipboardSaveFailed = 601, TextInsertClipboardRestoreFailed = 602, TextInsertKeySimulationFailed = 603, TextInsertNoActiveWindow = 610, TextInsertTargetAppNotResponding = 611, // === History / Dictionary / DB (700-799) === DBOpenFailed = 700, DBMigrationFailed = 701, DBQueryFailed = 702, DBWriteFailed = 703, HistoryNotFound = 710, HistoryExportFailed = 711, DictionaryNotFound = 720, DictionaryDuplicate = 721, DictionaryImportFailed = 722, DictionaryExportFailed = 723, DictionaryImportInvalidFormat = 724, // === Config (800-899) === ConfigReadFailed = 800, ConfigWriteFailed = 801, ConfigInvalidValue = 802, ConfigKeyNotFound = 803, ConfigResetFailed = 804, ConfigMigrationFailed = 810, // === System / Window (900-999) === WindowCreationFailed = 900, WindowNotFound = 901, TrayCreationFailed = 910, NotificationFailed = 920, PermissionDenied = 930, ExternalOpenFailed = 940, SoundPlayFailed = 950, AppAlreadyRunning = 960, UnknownError = 999, } /** * D3RO-VOICE 표준 에러 객체 (Speakly NXError 패턴) * 모든 IPC 에러 응답은 이 형태로 전달된다. */ export class D3ROError extends Error { readonly code: ErrorCode; readonly details?: Record; constructor(code: ErrorCode, message: string, details?: Record) { super(message); this.name = 'D3ROError'; this.code = code; this.details = details; } toJSON(): D3ROErrorJSON { return { code: this.code, message: this.message, details: this.details, }; } static fromJSON(json: D3ROErrorJSON): D3ROError { return new D3ROError(json.code, json.message, json.details); } } export interface D3ROErrorJSON { code: ErrorCode; message: string; details?: Record; } /** * IPC 핸들러에서 사용하는 표준 응답 래퍼. * 성공 시 { success: true, data }, 실패 시 { success: false, error } */ export type IPCResult = | { success: true; data: T } | { success: false; error: D3ROErrorJSON }; /** * IPCResult 헬퍼 함수 */ export function ipcSuccess(data: T): IPCResult { return { success: true, data }; } export function ipcError(code: ErrorCode, message: string, details?: Record): IPCResult { return { success: false, error: { code, message, details } }; } ``` --- ## 5. `preload/index.ts` 전체 API 설계 contextBridge로 노출할 `window.electronAPI` 구조. 렌더러는 이 객체를 통해서만 메인 프로세스와 통신한다. ```typescript // src/preload/index.ts import { contextBridge, ipcRenderer } from 'electron'; import { IPC_CHANNELS } from '../shared/ipc-channels'; import type { // Voice StartRecordingParams, StartRecordingResult, StopRecordingParams, StopRecordingResult, CancelRecordingParams, VoiceState, SetVoiceModeParams, VoiceMode, VoiceStateChangedEvent, TranscriptionDeltaEvent, TranscriptionCompleteEvent, VoiceErrorEvent, AudioLevelEvent, // Audio AudioDevice, SetDeviceParams, TestDeviceParams, TestDeviceResult, AudioDeviceChangedEvent, // STT STTStatus, STTModel, SetSTTModelParams, DownloadModelParams, SetSTTLanguageParams, STTStatusChangedEvent, DownloadProgressEvent, // TTS TTSStatus, TTSVoice, TTSSpeakParams, TTSSpeakResult, SetTTSVoiceParams, DownloadVoiceParams, TTSStatusChangedEvent, SpeakingStateChangedEvent, // LLM LLMStatus, LLMModel, SetLLMModelParams, LLMProcessParams, LLMProcessResult, SetServerUrlParams, PullModelParams, LLMStatusChangedEvent, LLMProcessProgressEvent, LLMPullProgressEvent, // Hotkey HotkeyBinding, SetHotkeyParams, SetEnabledParams, HotkeyTriggeredEvent, HotkeyRecordingResultEvent, // Config AppConfig, ConfigGetParams, ConfigSetParams, ConfigResetParams, SetThemeParams, SetLanguageParams, SetAutoLaunchParams, SetCloseToTrayParams, ThemeMode, ConfigChangedEvent, // History HistoryEntry, HistoryQueryParams, HistoryPage, HistoryGetByIdParams, HistoryDeleteParams, HistorySearchParams, HistoryExportParams, // Dictionary DictionaryEntry, DictionaryQueryParams, DictionaryPage, DictionaryAddParams, DictionaryUpdateParams, DictionaryDeleteParams, DictionaryImportParams, DictionaryImportResult, DictionaryExportParams, DictionarySearchParams, // Window ShowRecordingTipParams, ShowResultPopupParams, TipMeasuredParams, TipStateChangedEvent, TipPrepareEvent, TipShowEvent, // System ActiveAppInfo, ShowNotificationParams, OpenExternalParams, InsertTextParams, InsertTextResult, PlaySoundParams, SetSoundEnabledParams, PermissionStatus, // Stats StatsSummary, StatsQueryParams, DailyStats, WeeklyStats, } from '../shared/types'; import type { IPCResult } from '../shared/errors'; // 타입 안전한 invoke 헬퍼 function invoke(channel: string, ...args: unknown[]): Promise> { return ipcRenderer.invoke(channel, ...args); } // 타입 안전한 send 헬퍼 (fire-and-forget) function send(channel: string, ...args: unknown[]): void { ipcRenderer.send(channel, ...args); } // 타입 안전한 이벤트 리스너 헬퍼 type Unsubscribe = () => void; function on(channel: string, callback: (data: T) => void): Unsubscribe { const listener = (_event: Electron.IpcRendererEvent, data: T) => callback(data); ipcRenderer.on(channel, listener); return () => ipcRenderer.removeListener(channel, listener); } const electronAPI = { // ── Voice ────────────────────────────────────────────── voice: { startRecording: (params: StartRecordingParams) => invoke(IPC_CHANNELS.VOICE.START_RECORDING, params), stopRecording: (params: StopRecordingParams) => invoke(IPC_CHANNELS.VOICE.STOP_RECORDING, params), cancelRecording: (params: CancelRecordingParams) => invoke(IPC_CHANNELS.VOICE.CANCEL_RECORDING, params), getState: () => invoke(IPC_CHANNELS.VOICE.GET_STATE), setMode: (params: SetVoiceModeParams) => invoke(IPC_CHANNELS.VOICE.SET_MODE, params), getMode: () => invoke(IPC_CHANNELS.VOICE.GET_MODE), onStateChanged: (cb: (e: VoiceStateChangedEvent) => void): Unsubscribe => on(IPC_CHANNELS.VOICE.STATE_CHANGED, cb), onTranscriptionDelta: (cb: (e: TranscriptionDeltaEvent) => void): Unsubscribe => on(IPC_CHANNELS.VOICE.TRANSCRIPTION_DELTA, cb), onTranscriptionComplete: (cb: (e: TranscriptionCompleteEvent) => void): Unsubscribe => on(IPC_CHANNELS.VOICE.TRANSCRIPTION_COMPLETE, cb), onError: (cb: (e: VoiceErrorEvent) => void): Unsubscribe => on(IPC_CHANNELS.VOICE.ERROR, cb), onAudioLevel: (cb: (e: AudioLevelEvent) => void): Unsubscribe => on(IPC_CHANNELS.VOICE.AUDIO_LEVEL, cb), }, // ── Audio ────────────────────────────────────────────── audio: { getDevices: () => invoke(IPC_CHANNELS.AUDIO.GET_DEVICES), getSelectedDevice: () => invoke(IPC_CHANNELS.AUDIO.GET_SELECTED_DEVICE), setSelectedDevice: (params: SetDeviceParams) => invoke(IPC_CHANNELS.AUDIO.SET_SELECTED_DEVICE, params), testDevice: (params: TestDeviceParams) => invoke(IPC_CHANNELS.AUDIO.TEST_DEVICE, params), onDeviceChanged: (cb: (e: AudioDeviceChangedEvent) => void): Unsubscribe => on(IPC_CHANNELS.AUDIO.DEVICE_CHANGED, cb), }, // ── STT ──────────────────────────────────────────────── stt: { getStatus: () => invoke(IPC_CHANNELS.STT.GET_STATUS), getModels: () => invoke(IPC_CHANNELS.STT.GET_MODELS), getActiveModel: () => invoke(IPC_CHANNELS.STT.GET_ACTIVE_MODEL), setModel: (params: SetSTTModelParams) => invoke(IPC_CHANNELS.STT.SET_MODEL, params), downloadModel: (params: DownloadModelParams) => invoke(IPC_CHANNELS.STT.DOWNLOAD_MODEL, params), cancelDownload: () => invoke(IPC_CHANNELS.STT.CANCEL_DOWNLOAD), getLanguage: () => invoke(IPC_CHANNELS.STT.GET_LANGUAGE), setLanguage: (params: SetSTTLanguageParams) => invoke(IPC_CHANNELS.STT.SET_LANGUAGE, params), onStatusChanged: (cb: (e: STTStatusChangedEvent) => void): Unsubscribe => on(IPC_CHANNELS.STT.STATUS_CHANGED, cb), onDownloadProgress: (cb: (e: DownloadProgressEvent) => void): Unsubscribe => on(IPC_CHANNELS.STT.DOWNLOAD_PROGRESS, cb), }, // ── TTS ──────────────────────────────────────────────── tts: { speak: (params: TTSSpeakParams) => invoke(IPC_CHANNELS.TTS.SPEAK, params), stop: () => invoke(IPC_CHANNELS.TTS.STOP), getVoices: () => invoke(IPC_CHANNELS.TTS.GET_VOICES), getActiveVoice: () => invoke(IPC_CHANNELS.TTS.GET_ACTIVE_VOICE), setVoice: (params: SetTTSVoiceParams) => invoke(IPC_CHANNELS.TTS.SET_VOICE, params), getStatus: () => invoke(IPC_CHANNELS.TTS.GET_STATUS), downloadVoice: (params: DownloadVoiceParams) => invoke(IPC_CHANNELS.TTS.DOWNLOAD_VOICE, params), onStatusChanged: (cb: (e: TTSStatusChangedEvent) => void): Unsubscribe => on(IPC_CHANNELS.TTS.STATUS_CHANGED, cb), onSpeakingStateChanged: (cb: (e: SpeakingStateChangedEvent) => void): Unsubscribe => on(IPC_CHANNELS.TTS.SPEAKING_STATE_CHANGED, cb), }, // ── LLM ──────────────────────────────────────────────── llm: { getStatus: () => invoke(IPC_CHANNELS.LLM.GET_STATUS), getModels: () => invoke(IPC_CHANNELS.LLM.GET_MODELS), getActiveModel: () => invoke(IPC_CHANNELS.LLM.GET_ACTIVE_MODEL), setModel: (params: SetLLMModelParams) => invoke(IPC_CHANNELS.LLM.SET_MODEL, params), process: (params: LLMProcessParams) => invoke(IPC_CHANNELS.LLM.PROCESS, params), cancelProcess: () => invoke(IPC_CHANNELS.LLM.CANCEL_PROCESS), getServerUrl: () => invoke(IPC_CHANNELS.LLM.GET_SERVER_URL), setServerUrl: (params: SetServerUrlParams) => invoke(IPC_CHANNELS.LLM.SET_SERVER_URL, params), pullModel: (params: PullModelParams) => invoke(IPC_CHANNELS.LLM.PULL_MODEL, params), onStatusChanged: (cb: (e: LLMStatusChangedEvent) => void): Unsubscribe => on(IPC_CHANNELS.LLM.STATUS_CHANGED, cb), onProcessProgress: (cb: (e: LLMProcessProgressEvent) => void): Unsubscribe => on(IPC_CHANNELS.LLM.PROCESS_PROGRESS, cb), onPullProgress: (cb: (e: LLMPullProgressEvent) => void): Unsubscribe => on(IPC_CHANNELS.LLM.PULL_PROGRESS, cb), }, // ── Hotkey ───────────────────────────────────────────── hotkey: { getDictationShortcut: () => invoke(IPC_CHANNELS.HOTKEY.GET_DICTATION_SHORTCUT), setDictationShortcut: (params: SetHotkeyParams) => invoke(IPC_CHANNELS.HOTKEY.SET_DICTATION_SHORTCUT, params), getHandsFreeShortcut: () => invoke(IPC_CHANNELS.HOTKEY.GET_HANDS_FREE_SHORTCUT), setHandsFreeShortcut: (params: SetHotkeyParams) => invoke(IPC_CHANNELS.HOTKEY.SET_HANDS_FREE_SHORTCUT, params), getCommandShortcut: () => invoke(IPC_CHANNELS.HOTKEY.GET_COMMAND_SHORTCUT), setCommandShortcut: (params: SetHotkeyParams) => invoke(IPC_CHANNELS.HOTKEY.SET_COMMAND_SHORTCUT, params), isEnabled: () => invoke(IPC_CHANNELS.HOTKEY.IS_ENABLED), setEnabled: (params: SetEnabledParams) => invoke(IPC_CHANNELS.HOTKEY.SET_ENABLED, params), startRecording: () => invoke(IPC_CHANNELS.HOTKEY.START_RECORDING), stopRecording: () => invoke(IPC_CHANNELS.HOTKEY.STOP_RECORDING), onTriggered: (cb: (e: HotkeyTriggeredEvent) => void): Unsubscribe => on(IPC_CHANNELS.HOTKEY.TRIGGERED, cb), onRecordingResult: (cb: (e: HotkeyRecordingResultEvent) => void): Unsubscribe => on(IPC_CHANNELS.HOTKEY.RECORDING_RESULT, cb), }, // ── Config ───────────────────────────────────────────── config: { get: (params: ConfigGetParams) => invoke(IPC_CHANNELS.CONFIG.GET, params), set: (params: ConfigSetParams) => invoke(IPC_CHANNELS.CONFIG.SET, params), getAll: () => invoke(IPC_CHANNELS.CONFIG.GET_ALL), reset: (params: ConfigResetParams) => invoke(IPC_CHANNELS.CONFIG.RESET, params), getTheme: () => invoke(IPC_CHANNELS.CONFIG.GET_THEME), setTheme: (params: SetThemeParams) => invoke(IPC_CHANNELS.CONFIG.SET_THEME, params), getLanguage: () => invoke(IPC_CHANNELS.CONFIG.GET_LANGUAGE), setLanguage: (params: SetLanguageParams) => invoke(IPC_CHANNELS.CONFIG.SET_LANGUAGE, params), getAutoLaunch: () => invoke(IPC_CHANNELS.CONFIG.GET_AUTO_LAUNCH), setAutoLaunch: (params: SetAutoLaunchParams) => invoke(IPC_CHANNELS.CONFIG.SET_AUTO_LAUNCH, params), getCloseToTray: () => invoke(IPC_CHANNELS.CONFIG.GET_CLOSE_TO_TRAY), setCloseToTray: (params: SetCloseToTrayParams) => invoke(IPC_CHANNELS.CONFIG.SET_CLOSE_TO_TRAY, params), onChanged: (cb: (e: ConfigChangedEvent) => void): Unsubscribe => on(IPC_CHANNELS.CONFIG.CHANGED, cb), }, // ── History ──────────────────────────────────────────── history: { getAll: (params: HistoryQueryParams) => invoke(IPC_CHANNELS.HISTORY.GET_ALL, params), getById: (params: HistoryGetByIdParams) => invoke(IPC_CHANNELS.HISTORY.GET_BY_ID, params), delete: (params: HistoryDeleteParams) => invoke(IPC_CHANNELS.HISTORY.DELETE, params), deleteAll: () => invoke(IPC_CHANNELS.HISTORY.DELETE_ALL), search: (params: HistorySearchParams) => invoke(IPC_CHANNELS.HISTORY.SEARCH, params), export: (params: HistoryExportParams) => invoke(IPC_CHANNELS.HISTORY.EXPORT, params), onAdded: (cb: (e: HistoryEntry) => void): Unsubscribe => on(IPC_CHANNELS.HISTORY.ADDED, cb), }, // ── Dictionary ───────────────────────────────────────── dictionary: { getAll: (params: DictionaryQueryParams) => invoke(IPC_CHANNELS.DICTIONARY.GET_ALL, params), add: (params: DictionaryAddParams) => invoke(IPC_CHANNELS.DICTIONARY.ADD, params), update: (params: DictionaryUpdateParams) => invoke(IPC_CHANNELS.DICTIONARY.UPDATE, params), delete: (params: DictionaryDeleteParams) => invoke(IPC_CHANNELS.DICTIONARY.DELETE, params), import: (params: DictionaryImportParams) => invoke(IPC_CHANNELS.DICTIONARY.IMPORT, params), export: (params: DictionaryExportParams) => invoke(IPC_CHANNELS.DICTIONARY.EXPORT, params), search: (params: DictionarySearchParams) => invoke(IPC_CHANNELS.DICTIONARY.SEARCH, params), }, // ── Window ───────────────────────────────────────────── window: { minimize: () => send(IPC_CHANNELS.WINDOW.MINIMIZE), maximize: () => send(IPC_CHANNELS.WINDOW.MAXIMIZE), close: () => send(IPC_CHANNELS.WINDOW.CLOSE), isMaximized: () => invoke(IPC_CHANNELS.WINDOW.IS_MAXIMIZED), showRecordingTip: (params: ShowRecordingTipParams) => send(IPC_CHANNELS.WINDOW.SHOW_RECORDING_TIP, params), hideRecordingTip: () => send(IPC_CHANNELS.WINDOW.HIDE_RECORDING_TIP), showResultPopup: (params: ShowResultPopupParams) => send(IPC_CHANNELS.WINDOW.SHOW_RESULT_POPUP, params), hideResultPopup: () => send(IPC_CHANNELS.WINDOW.HIDE_RESULT_POPUP), tipMeasured: (params: TipMeasuredParams) => send(IPC_CHANNELS.WINDOW.TIP_MEASURED, params), onTipStateChanged: (cb: (e: TipStateChangedEvent) => void): Unsubscribe => on(IPC_CHANNELS.WINDOW.TIP_STATE_CHANGED, cb), onTipPrepare: (cb: (e: TipPrepareEvent) => void): Unsubscribe => on(IPC_CHANNELS.WINDOW.TIP_PREPARE, cb), onTipShow: (cb: (e: TipShowEvent) => void): Unsubscribe => on(IPC_CHANNELS.WINDOW.TIP_SHOW, cb), }, // ── System ───────────────────────────────────────────── system: { getPlatform: () => invoke(IPC_CHANNELS.SYSTEM.GET_PLATFORM), getVersion: () => invoke(IPC_CHANNELS.SYSTEM.GET_VERSION), checkMicPermission: () => invoke(IPC_CHANNELS.SYSTEM.CHECK_MIC_PERMISSION), requestMicPermission: () => invoke(IPC_CHANNELS.SYSTEM.REQUEST_MIC_PERMISSION), showNotification: (params: ShowNotificationParams) => invoke(IPC_CHANNELS.SYSTEM.SHOW_NOTIFICATION, params), openExternal: (params: OpenExternalParams) => invoke(IPC_CHANNELS.SYSTEM.OPEN_EXTERNAL, params), getActiveApp: () => invoke(IPC_CHANNELS.SYSTEM.GET_ACTIVE_APP), insertText: (params: InsertTextParams) => invoke(IPC_CHANNELS.SYSTEM.INSERT_TEXT, params), playSound: (params: PlaySoundParams) => invoke(IPC_CHANNELS.SYSTEM.PLAY_SOUND, params), setSoundEnabled: (params: SetSoundEnabledParams) => invoke(IPC_CHANNELS.SYSTEM.SET_SOUND_ENABLED, params), isSoundEnabled: () => invoke(IPC_CHANNELS.SYSTEM.IS_SOUND_ENABLED), }, // ── Stats ────────────────────────────────────────────── stats: { getSummary: () => invoke(IPC_CHANNELS.STATS.GET_SUMMARY), getDaily: (params: StatsQueryParams) => invoke(IPC_CHANNELS.STATS.GET_DAILY, params), getWeekly: (params: StatsQueryParams) => invoke(IPC_CHANNELS.STATS.GET_WEEKLY, params), onUpdated: (cb: (e: StatsSummary) => void): Unsubscribe => on(IPC_CHANNELS.STATS.UPDATED, cb), }, } as const; // contextBridge로 렌더러에 노출 contextBridge.exposeInMainWorld('electronAPI', electronAPI); // 렌더러에서 사용할 타입 선언 export type ElectronAPI = typeof electronAPI; ``` ### 렌더러 타입 선언 (`src/renderer/electron.d.ts`) ```typescript // src/renderer/electron.d.ts import type { ElectronAPI } from '../preload/index'; declare global { interface Window { electronAPI: ElectronAPI; } } ``` --- ## 6. 채널 통계 요약 | 카테고리 | handle | on (R->M) | send (M->R) | 합계 | |---------|--------|-----------|-------------|------| | voice | 6 | 0 | 5 | 11 | | audio | 4 | 0 | 1 | 5 | | stt | 8 | 0 | 2 | 10 | | tts | 7 | 0 | 2 | 9 | | llm | 9 | 0 | 3 | 12 | | hotkey | 10 | 0 | 2 | 12 | | config | 12 | 0 | 1 | 13 | | history | 6 | 0 | 1 | 7 | | dictionary | 7 | 0 | 0 | 7 | | window | 1 | 8 | 3 | 12 | | system | 11 | 0 | 0 | 11 | | stats | 3 | 0 | 1 | 4 | | **합계** | **84** | **8** | **21** | **113** | Speakly 대비: - 제거: 클라우드 인증, WebSocket STT, 텔레메트리, 피드백, 업데이트, GenSpark 서비스 등 (~90개) - 추가: 로컬 STT/TTS/LLM 관리, 모델 다운로드/pull, 통계 (~30개) --- ## 7. 설계 결정 사항 ### 7.1 IPCResult 래퍼 패턴 모든 `handle` 채널은 `IPCResult` 를 반환한다. 이는 Speakly의 NXError 패턴을 발전시킨 것으로, 렌더러에서 try/catch 없이 `success` 필드로 분기할 수 있다. ### 7.2 이벤트 구독 패턴 `on*` 메서드는 `Unsubscribe` 함수를 반환한다. React 컴포넌트에서 `useEffect` cleanup으로 사용: ```typescript useEffect(() => { const unsub = window.electronAPI.voice.onStateChanged((e) => { setState(e.currentState); }); return unsub; }, []); ``` ### 7.3 채널명 규칙 - 형식: `${namespace}:${action}` (camelCase) - handle 채널: 동사로 시작 (`get`, `set`, `start`, `stop`, `cancel`, `delete`) - send 이벤트: 과거분사 또는 명사 (`changed`, `progress`, `complete`, `added`) ### 7.4 Vanilla JS 팝업 통신 RecordingTip, ResultPopup 등 Vanilla JS 팝업은 별도 preload 스크립트가 필요하다. 동일한 `IPC_CHANNELS` 상수를 사용하되, `window:tip*` 채널만 노출한다.