Phase 1+2 구현: Electron 뼈대 + STT/핫키/오케스트레이터

Phase 1:
- 프로젝트 초기화 (TypeScript strict, electron-vite, ESLint, Prettier)
- shared 타입 (ipc-channels 113채널, types, errors, constants)
- 메인 프로세스 뼈대 (bootstrap, lifecycle, 단일 인스턴스)
- LoggerService, ConfigService (electron-store ESM dynamic import)
- React 19 + MUI 7 Dashboard, 시스템 트레이

Phase 2:
- AudioCaptureService (node-record-lpcm16, PCM16 16kHz mono)
- HotkeyService (uiohook-napi, 더블프레스, holdMode/toggleMode)
- LocalSTTService (faster-whisper Python sidecar, 이중 조건 플러시)
- VoiceModeService 오케스트레이터 (이중 상태머신, Action Queue)
- Python sidecar (FastAPI: health/load/transcribe/shutdown)
- IPC 핸들러 (voice, stt, hotkey) + Preload API 확장
This commit is contained in:
Yun Chan 2026-04-05 02:01:37 +09:00
parent e24bb8378c
commit 1d152d01a1
46 changed files with 10828 additions and 4 deletions

164
src/preload/index.ts Normal file
View file

@ -0,0 +1,164 @@
// src/preload/index.ts
// contextBridge로 렌더러에 노출할 API 정의
import { contextBridge, ipcRenderer } from 'electron'
import { IPC_CHANNELS } from '@shared/ipc-channels'
import type {
AudioDevice,
SetDeviceParams,
TestDeviceParams,
TestDeviceResult,
AudioDeviceChangedEvent,
AppConfig,
ConfigGetParams,
ConfigSetParams,
ConfigResetParams,
SetThemeParams,
SetLanguageParams,
ThemeMode,
ConfigChangedEvent,
VoiceState,
VoiceMode,
AudioLevelEvent,
VoiceStateChangedEvent,
TranscriptionDeltaEvent,
TranscriptionCompleteEvent,
VoiceErrorEvent,
StartRecordingParams,
StartRecordingResult,
StopRecordingParams,
StopRecordingResult,
CancelRecordingParams,
SetVoiceModeParams,
STTStatus,
STTModel,
SetSTTModelParams,
SetSTTLanguageParams,
STTStatusChangedEvent,
HotkeyBinding,
SetHotkeyParams,
SetEnabledParams,
HotkeyTriggeredEvent,
PermissionStatus
} from '@shared/types'
import type { IPCResult } from '@shared/errors'
type Unsubscribe = () => void
function invoke<TResult>(channel: string, ...args: unknown[]): Promise<IPCResult<TResult>> {
return ipcRenderer.invoke(channel, ...args)
}
function send(channel: string, ...args: unknown[]): void {
ipcRenderer.send(channel, ...args)
}
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 = {
// ── 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)
},
// ── 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),
onChanged: (cb: (e: ConfigChangedEvent) => void): Unsubscribe =>
on(IPC_CHANNELS.CONFIG.CHANGED, cb)
},
// ── 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)
},
// ── 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),
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)
},
// ── 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),
isEnabled: () => invoke<boolean>(IPC_CHANNELS.HOTKEY.IS_ENABLED),
setEnabled: (params: SetEnabledParams) =>
invoke<void>(IPC_CHANNELS.HOTKEY.SET_ENABLED, params),
onTriggered: (cb: (e: HotkeyTriggeredEvent) => void): Unsubscribe =>
on(IPC_CHANNELS.HOTKEY.TRIGGERED, cb)
},
// ── 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)
},
// ── 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)
}
} as const
contextBridge.exposeInMainWorld('electronAPI', electronAPI)
export type ElectronAPI = typeof electronAPI