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* 채널만 노출한다.