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

78
src/main/bootstrap.ts Normal file
View file

@ -0,0 +1,78 @@
// src/main/bootstrap.ts — 초기화 시퀀스
import { app, dialog } from 'electron'
import { initLoggerService, getLogger } from './services/LoggerService'
import { initConfigService } from './services/ConfigService'
import { getHotkeyService } from './services/HotkeyService'
import { getVoiceModeService } from './services/VoiceModeService'
import { createMainWindow } from './windows/WindowManager'
import { createTray } from './windows/TrayManager'
import { registerAllIpcHandlers } from './ipc'
const logger = getLogger('bootstrap')
interface BootstrapStep {
name: string
critical: boolean
fn: () => Promise<void>
}
export async function bootstrap(): Promise<void> {
const steps: BootstrapStep[] = [
{ name: 'logger', critical: false, fn: initLogger },
{ name: 'config', critical: false, fn: initConfig },
{ name: 'create-windows', critical: true, fn: createWindows },
{ name: 'tray', critical: false, fn: initTray },
{ name: 'ipc-handlers', critical: true, fn: initIpcHandlers },
{ name: 'hotkey', critical: false, fn: initHotkey },
{ name: 'voice-mode', critical: false, fn: initVoiceMode }
]
for (const step of steps) {
try {
await step.fn()
logger.info(`[bootstrap] ${step.name} initialized`)
} catch (error) {
logger.error(`[bootstrap] ${step.name} failed:`, error)
if (step.critical) {
dialog.showErrorBox(
'D3RO-VOICE 초기화 실패',
`${step.name}: ${error instanceof Error ? error.message : String(error)}`
)
app.quit()
return
}
}
}
}
async function initLogger(): Promise<void> {
initLoggerService()
}
async function initConfig(): Promise<void> {
initConfigService()
}
async function createWindows(): Promise<void> {
createMainWindow()
}
async function initTray(): Promise<void> {
createTray()
}
async function initIpcHandlers(): Promise<void> {
registerAllIpcHandlers()
}
async function initHotkey(): Promise<void> {
const hotkey = getHotkeyService()
hotkey.loadFromConfig()
hotkey.start()
}
async function initVoiceMode(): Promise<void> {
const voiceMode = getVoiceModeService()
voiceMode.connectHotkey()
}

34
src/main/index.ts Normal file
View file

@ -0,0 +1,34 @@
// src/main/index.ts — 앱 진입점
import { app } from 'electron'
import { bootstrap } from './bootstrap'
import { setupLifecycle } from './lifecycle'
import { getMainWindow } from './windows/WindowManager'
// 단일 인스턴스 잠금
const gotTheLock = app.requestSingleInstanceLock()
if (!gotTheLock) {
app.quit()
} else {
app.on('second-instance', () => {
// 기존 인스턴스의 메인 윈도우를 활성화
const mainWindow = getMainWindow()
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore()
mainWindow.focus()
}
})
app.whenReady().then(async () => {
await bootstrap()
setupLifecycle()
})
app.on('window-all-closed', () => {
// Windows에서는 모든 윈도우 닫아도 앱 유지 (트레이)
if (process.platform !== 'darwin') {
// closeToTray 설정 확인은 lifecycle에서 처리
}
})
}

View file

@ -0,0 +1,37 @@
// src/main/ipc/audio-handlers.ts
import { ipcMain } from 'electron'
import { IPC_CHANNELS } from '@shared/ipc-channels'
import { ipcSuccess, ipcError, ErrorCode } from '@shared/errors'
import { getAudioCaptureService } from '../services/AudioCaptureService'
import { configGet, configSet } from '../services/ConfigService'
import type { SetDeviceParams } from '@shared/types'
export function registerAudioHandlers(): void {
ipcMain.handle(IPC_CHANNELS.AUDIO.GET_DEVICES, async () => {
try {
const devices = await getAudioCaptureService().getDevices()
return ipcSuccess(devices)
} catch {
return ipcError(ErrorCode.AudioDeviceNotFound, 'Failed to enumerate audio devices')
}
})
ipcMain.handle(IPC_CHANNELS.AUDIO.GET_SELECTED_DEVICE, async () => {
return ipcSuccess(configGet('selectedDeviceId'))
})
ipcMain.handle(IPC_CHANNELS.AUDIO.SET_SELECTED_DEVICE, async (_event, params: SetDeviceParams) => {
try {
configSet('selectedDeviceId', params.deviceId)
return ipcSuccess(undefined)
} catch {
return ipcError(ErrorCode.AudioDeviceNotFound, 'Failed to set device')
}
})
ipcMain.handle(IPC_CHANNELS.AUDIO.TEST_DEVICE, async () => {
// Phase 1: 스텁
return ipcSuccess({ averageLevel: 0, peakLevel: 0, hasAudio: false })
})
}

View file

@ -0,0 +1,72 @@
// src/main/ipc/config-handlers.ts
import { ipcMain } from 'electron'
import { IPC_CHANNELS } from '@shared/ipc-channels'
import { ipcSuccess, ipcError, ErrorCode } from '@shared/errors'
import { configGet, configSet, configGetAll, configReset } from '../services/ConfigService'
import type {
ConfigGetParams,
ConfigSetParams,
ConfigResetParams,
SetThemeParams,
SetLanguageParams,
AppConfig
} from '@shared/types'
export function registerConfigHandlers(): void {
ipcMain.handle(IPC_CHANNELS.CONFIG.GET, async (_event, params: ConfigGetParams) => {
try {
return ipcSuccess(configGet(params.key))
} catch {
return ipcError(ErrorCode.ConfigReadFailed, `Failed to read config key: ${params.key}`)
}
})
ipcMain.handle(IPC_CHANNELS.CONFIG.SET, async (_event, params: ConfigSetParams) => {
try {
configSet(params.key, params.value as AppConfig[typeof params.key])
return ipcSuccess(undefined)
} catch {
return ipcError(ErrorCode.ConfigWriteFailed, `Failed to write config key: ${params.key}`)
}
})
ipcMain.handle(IPC_CHANNELS.CONFIG.GET_ALL, async () => {
return ipcSuccess(configGetAll())
})
ipcMain.handle(IPC_CHANNELS.CONFIG.RESET, async (_event, params: ConfigResetParams) => {
try {
configReset(params.key)
return ipcSuccess(undefined)
} catch {
return ipcError(ErrorCode.ConfigResetFailed, 'Failed to reset config')
}
})
ipcMain.handle(IPC_CHANNELS.CONFIG.GET_THEME, async () => {
return ipcSuccess(configGet('theme'))
})
ipcMain.handle(IPC_CHANNELS.CONFIG.SET_THEME, async (_event, params: SetThemeParams) => {
configSet('theme', params.theme)
return ipcSuccess(undefined)
})
ipcMain.handle(IPC_CHANNELS.CONFIG.GET_LANGUAGE, async () => {
return ipcSuccess(configGet('language'))
})
ipcMain.handle(IPC_CHANNELS.CONFIG.SET_LANGUAGE, async (_event, params: SetLanguageParams) => {
configSet('language', params.language)
return ipcSuccess(undefined)
})
ipcMain.handle(IPC_CHANNELS.CONFIG.GET_AUTO_LAUNCH, async () => {
return ipcSuccess(configGet('autoLaunch'))
})
ipcMain.handle(IPC_CHANNELS.CONFIG.GET_CLOSE_TO_TRAY, async () => {
return ipcSuccess(configGet('closeToTray'))
})
}

View file

@ -0,0 +1,67 @@
// src/main/ipc/hotkey-handlers.ts
import { ipcMain } from 'electron'
import { IPC_CHANNELS } from '@shared/ipc-channels'
import { ipcSuccess, ipcError, ErrorCode } from '@shared/errors'
import { getHotkeyService } from '../services/HotkeyService'
import { configGet, configSet } from '../services/ConfigService'
import type { SetHotkeyParams, SetEnabledParams } from '@shared/types'
export function registerHotkeyHandlers(): void {
ipcMain.handle(IPC_CHANNELS.HOTKEY.GET_DICTATION_SHORTCUT, async () => {
return ipcSuccess(configGet('dictationShortcut'))
})
ipcMain.handle(IPC_CHANNELS.HOTKEY.SET_DICTATION_SHORTCUT, async (_event, params: SetHotkeyParams) => {
try {
configSet('dictationShortcut', params.binding)
getHotkeyService().loadFromConfig()
return ipcSuccess(undefined)
} catch {
return ipcError(ErrorCode.HotkeyRegistrationFailed, 'Failed to set dictation shortcut')
}
})
ipcMain.handle(IPC_CHANNELS.HOTKEY.GET_HANDS_FREE_SHORTCUT, async () => {
return ipcSuccess(configGet('handsFreeShortcut'))
})
ipcMain.handle(IPC_CHANNELS.HOTKEY.SET_HANDS_FREE_SHORTCUT, async (_event, params: SetHotkeyParams) => {
try {
configSet('handsFreeShortcut', params.binding)
getHotkeyService().loadFromConfig()
return ipcSuccess(undefined)
} catch {
return ipcError(ErrorCode.HotkeyRegistrationFailed, 'Failed to set hands-free shortcut')
}
})
ipcMain.handle(IPC_CHANNELS.HOTKEY.GET_COMMAND_SHORTCUT, async () => {
return ipcSuccess(configGet('commandShortcut'))
})
ipcMain.handle(IPC_CHANNELS.HOTKEY.SET_COMMAND_SHORTCUT, async (_event, params: SetHotkeyParams) => {
try {
configSet('commandShortcut', params.binding)
getHotkeyService().loadFromConfig()
return ipcSuccess(undefined)
} catch {
return ipcError(ErrorCode.HotkeyRegistrationFailed, 'Failed to set command shortcut')
}
})
ipcMain.handle(IPC_CHANNELS.HOTKEY.IS_ENABLED, async () => {
return ipcSuccess(configGet('hotkeyEnabled'))
})
ipcMain.handle(IPC_CHANNELS.HOTKEY.SET_ENABLED, async (_event, params: SetEnabledParams) => {
configSet('hotkeyEnabled', params.enabled)
const hotkey = getHotkeyService()
if (params.enabled) {
hotkey.start()
} else {
hotkey.stop()
}
return ipcSuccess(undefined)
})
}

23
src/main/ipc/index.ts Normal file
View file

@ -0,0 +1,23 @@
// src/main/ipc/index.ts — IPC 핸들러 일괄 등록
import { registerAudioHandlers } from './audio-handlers'
import { registerConfigHandlers } from './config-handlers'
import { registerWindowHandlers } from './window-handlers'
import { registerSystemHandlers } from './system-handlers'
import { registerVoiceHandlers } from './voice-handlers'
import { registerSTTHandlers } from './stt-handlers'
import { registerHotkeyHandlers } from './hotkey-handlers'
import { getLogger } from '../services/LoggerService'
const logger = getLogger('ipc')
export function registerAllIpcHandlers(): void {
registerAudioHandlers()
registerConfigHandlers()
registerWindowHandlers()
registerSystemHandlers()
registerVoiceHandlers()
registerSTTHandlers()
registerHotkeyHandlers()
logger.info('All IPC handlers registered')
}

View file

@ -0,0 +1,52 @@
// src/main/ipc/stt-handlers.ts
import { ipcMain } from 'electron'
import { IPC_CHANNELS } from '@shared/ipc-channels'
import { ipcSuccess, ipcError, ErrorCode } from '@shared/errors'
import { getLocalSTTService } from '../services/LocalSTTService'
import { configGet, configSet } from '../services/ConfigService'
import type { SetSTTModelParams, SetSTTLanguageParams } from '@shared/types'
export function registerSTTHandlers(): void {
ipcMain.handle(IPC_CHANNELS.STT.GET_STATUS, async () => {
try {
const stt = getLocalSTTService()
return ipcSuccess(stt.getStatus())
} catch {
return ipcError(ErrorCode.STTSidecarCommunicationFailed, 'Failed to get STT status')
}
})
ipcMain.handle(IPC_CHANNELS.STT.GET_MODELS, async () => {
try {
const stt = getLocalSTTService()
return ipcSuccess(await stt.getModels())
} catch {
return ipcError(ErrorCode.STTModelNotFound, 'Failed to get models')
}
})
ipcMain.handle(IPC_CHANNELS.STT.GET_ACTIVE_MODEL, async () => {
return ipcSuccess(configGet('sttModelId'))
})
ipcMain.handle(IPC_CHANNELS.STT.SET_MODEL, async (_event, params: SetSTTModelParams) => {
try {
configSet('sttModelId', params.modelId)
const stt = getLocalSTTService()
await stt.initialize(params.modelId)
return ipcSuccess(undefined)
} catch {
return ipcError(ErrorCode.STTModelLoadFailed, 'Failed to set STT model')
}
})
ipcMain.handle(IPC_CHANNELS.STT.GET_LANGUAGE, async () => {
return ipcSuccess(configGet('sttLanguage'))
})
ipcMain.handle(IPC_CHANNELS.STT.SET_LANGUAGE, async (_event, params: SetSTTLanguageParams) => {
configSet('sttLanguage', params.language)
return ipcSuccess(undefined)
})
}

View file

@ -0,0 +1,28 @@
// src/main/ipc/system-handlers.ts
import { ipcMain, app, systemPreferences } from 'electron'
import { IPC_CHANNELS } from '@shared/ipc-channels'
import { ipcSuccess } from '@shared/errors'
import type { PermissionStatus } from '@shared/types'
export function registerSystemHandlers(): void {
ipcMain.handle(IPC_CHANNELS.SYSTEM.GET_PLATFORM, async () => {
return ipcSuccess(process.platform)
})
ipcMain.handle(IPC_CHANNELS.SYSTEM.GET_VERSION, async () => {
return ipcSuccess(app.getVersion())
})
ipcMain.handle(IPC_CHANNELS.SYSTEM.CHECK_MIC_PERMISSION, async () => {
let status: PermissionStatus = 'unknown'
if (process.platform === 'darwin') {
const macStatus = systemPreferences.getMediaAccessStatus('microphone')
status = macStatus === 'granted' ? 'granted' : macStatus === 'denied' ? 'denied' : 'unknown'
} else {
// Windows: 마이크 권한은 별도 체크 불필요 (시스템 설정에서 관리)
status = 'granted'
}
return ipcSuccess(status)
})
}

View file

@ -0,0 +1,53 @@
// src/main/ipc/voice-handlers.ts
import { ipcMain } from 'electron'
import { IPC_CHANNELS } from '@shared/ipc-channels'
import { ipcSuccess, ipcError, ErrorCode } from '@shared/errors'
import { getVoiceModeService } from '../services/VoiceModeService'
import type { StartRecordingParams, StopRecordingParams, CancelRecordingParams, SetVoiceModeParams } from '@shared/types'
export function registerVoiceHandlers(): void {
ipcMain.handle(IPC_CHANNELS.VOICE.START_RECORDING, async (_event, params: StartRecordingParams) => {
try {
const voice = getVoiceModeService()
await voice.startSession('dictation')
const session = voice.currentSession
return ipcSuccess({ sessionId: session?.id ?? params.sessionId ?? '' })
} catch {
return ipcError(ErrorCode.AudioCaptureStartFailed, 'Failed to start recording')
}
})
ipcMain.handle(IPC_CHANNELS.VOICE.STOP_RECORDING, async (_event, params: StopRecordingParams) => {
try {
const voice = getVoiceModeService()
await voice.stopSession()
return ipcSuccess({
sessionId: params.sessionId,
text: voice.currentSession?.transcription ?? '',
durationMs: voice.currentSession ? Date.now() - voice.currentSession.startedAt : 0
})
} catch {
return ipcError(ErrorCode.STTTranscriptionFailed, 'Failed to stop recording')
}
})
ipcMain.handle(IPC_CHANNELS.VOICE.CANCEL_RECORDING, async (_event, _params: CancelRecordingParams) => {
getVoiceModeService().cancelSession()
return ipcSuccess(undefined)
})
ipcMain.handle(IPC_CHANNELS.VOICE.GET_STATE, async () => {
return ipcSuccess(getVoiceModeService().getState())
})
ipcMain.handle(IPC_CHANNELS.VOICE.SET_MODE, async (_event, params: SetVoiceModeParams) => {
// Phase 2: 모드만 설정에 저장 (실제 모드 전환은 핫키에서 처리)
return ipcSuccess(undefined)
})
ipcMain.handle(IPC_CHANNELS.VOICE.GET_MODE, async () => {
const voice = getVoiceModeService()
return ipcSuccess(voice.currentSession?.mode ?? 'dictation')
})
}

View file

@ -0,0 +1,31 @@
// src/main/ipc/window-handlers.ts
import { ipcMain } from 'electron'
import { IPC_CHANNELS } from '@shared/ipc-channels'
import { ipcSuccess } from '@shared/errors'
import { getMainWindow } from '../windows/WindowManager'
export function registerWindowHandlers(): void {
ipcMain.on(IPC_CHANNELS.WINDOW.MINIMIZE, () => {
getMainWindow()?.minimize()
})
ipcMain.on(IPC_CHANNELS.WINDOW.MAXIMIZE, () => {
const win = getMainWindow()
if (win) {
if (win.isMaximized()) {
win.unmaximize()
} else {
win.maximize()
}
}
})
ipcMain.on(IPC_CHANNELS.WINDOW.CLOSE, () => {
getMainWindow()?.close()
})
ipcMain.handle(IPC_CHANNELS.WINDOW.IS_MAXIMIZED, async () => {
return ipcSuccess(getMainWindow()?.isMaximized() ?? false)
})
}

37
src/main/lifecycle.ts Normal file
View file

@ -0,0 +1,37 @@
// src/main/lifecycle.ts — 종료 시퀀스
import { app } from 'electron'
import { getLogger } from './services/LoggerService'
import { getConfigService } from './services/ConfigService'
const logger = getLogger('lifecycle')
let isQuitting = false
export function getIsQuitting(): boolean {
return isQuitting
}
export function setIsQuitting(value: boolean): void {
isQuitting = value
}
export function setupLifecycle(): void {
app.on('before-quit', () => {
logger.info('[lifecycle] before-quit')
isQuitting = true
})
app.on('will-quit', () => {
logger.info('[lifecycle] will-quit — flushing config')
try {
const configService = getConfigService()
if (configService) {
// electron-store는 동기적으로 디스크에 쓰므로 별도 flush 불필요
logger.info('[lifecycle] config flush complete')
}
} catch (error) {
logger.warn('[lifecycle] config flush failed:', error)
}
})
}

View file

@ -0,0 +1,365 @@
// src/main/services/AudioCaptureService.ts
// 마이크 PCM 캡처 서비스. 설계서 01의 IAudioCaptureService 구현.
// node-record-lpcm16 + SoX로 PCM16 16kHz mono 캡처.
import { EventEmitter } from 'events'
import { record } from 'node-record-lpcm16'
import type { Recording } from 'node-record-lpcm16'
import type { Readable } from 'stream'
import { getLogger } from './LoggerService'
import { configGet } from './ConfigService'
import type { AudioDevice } from '@shared/types'
import { AUDIO_FORMAT, TIMING } from '@shared/constants'
import { D3ROError, ErrorCode } from '@shared/errors'
const logger = getLogger('AudioCaptureService')
/** 60ms 프레임 크기 (바이트): 16000 * 2 * 0.06 = 1920 */
const FRAME_SIZE_BYTES = (AUDIO_FORMAT.SAMPLE_RATE * AUDIO_FORMAT.BYTES_PER_SAMPLE * 60) / 1000
export type CaptureState = 'idle' | 'starting' | 'capturing' | 'stopping' | 'error'
interface AudioCaptureEvents {
'audio-data': (payload: { buffer: Buffer; timestamp: number }) => void
'audio-level': (payload: { level: number; timestamp: number }) => void
'device-changed': (payload: {
previous: AudioDevice | null
current: AudioDevice
}) => void
started: (payload: { deviceId: string }) => void
stopped: (payload: { reason: 'manual' | 'device-lost' | 'error' }) => void
error: (payload: { error: D3ROError }) => void
}
/**
* PCM16 RMS(Root Mean Square) .
* 0.0 ~ 1.0 .
*/
function calculateRMS(buffer: Buffer): number {
const samples = buffer.length / AUDIO_FORMAT.BYTES_PER_SAMPLE
if (samples === 0) return 0
let sumSquares = 0
for (let i = 0; i < buffer.length; i += AUDIO_FORMAT.BYTES_PER_SAMPLE) {
const sample = buffer.readInt16LE(i)
sumSquares += sample * sample
}
const rms = Math.sqrt(sumSquares / samples)
// PCM16 최대값 32768로 나눠서 0.0~1.0 범위로 정규화
return Math.min(1.0, rms / 32768)
}
class AudioCaptureService extends EventEmitter {
private _state: CaptureState = 'idle'
private _currentDevice: AudioDevice | null = null
private _refCount = 0
private _recording: Recording | null = null
private _stream: Readable | null = null
private _levelInterval: ReturnType<typeof setInterval> | null = null
/** 프레임 조립용 잔여 바이트 버퍼 */
private _residualBuffer: Buffer = Buffer.alloc(0)
/** RMS 계산용 누적 버퍼 (100ms 간격 emit) */
private _levelAccumulator: Buffer[] = []
get state(): CaptureState {
return this._state
}
get currentDevice(): AudioDevice | null {
return this._currentDevice
}
async start(deviceId?: string): Promise<void> {
if (this._state === 'capturing') {
this._refCount++
logger.debug(`Reference count increased to ${this._refCount}`)
return
}
if (this._state !== 'idle' && this._state !== 'error') {
logger.warn(`Cannot start capture in state: ${this._state}`)
return
}
this._state = 'starting'
const selectedDeviceId = deviceId ?? configGet('selectedDeviceId')
try {
logger.info(
`Starting audio capture (device: ${selectedDeviceId ?? 'default'}, ` +
`format: ${AUDIO_FORMAT.SAMPLE_RATE}Hz ${AUDIO_FORMAT.CHANNELS}ch ${AUDIO_FORMAT.BIT_DEPTH}bit)`
)
// node-record-lpcm16 으로 SoX rec 프로세스 spawn
const recordingOptions: Record<string, unknown> = {
sampleRate: AUDIO_FORMAT.SAMPLE_RATE,
channels: AUDIO_FORMAT.CHANNELS,
recorder: 'sox',
audioType: 'raw', // 헤더 없는 PCM raw 출력
endOnSilence: false
}
// 특정 디바이스가 지정된 경우 AUDIODEV 환경변수로 전달
if (selectedDeviceId && selectedDeviceId !== 'default') {
recordingOptions.device = selectedDeviceId
}
this._recording = record(recordingOptions)
this._stream = this._recording.stream()
this._residualBuffer = Buffer.alloc(0)
this._levelAccumulator = []
// 스트림 데이터 수신: 60ms 프레임 단위로 잘라 emit
this._stream.on('data', (chunk: Buffer) => {
this._onAudioChunk(chunk)
})
// 스트림 에러 처리
this._stream.on('error', (errorMessage: string | Error) => {
const msg = typeof errorMessage === 'string' ? errorMessage : errorMessage.message
logger.error(`Audio stream error: ${msg}`)
// SoX 미설치 감지
const isSoxMissing =
msg.includes('ENOENT') ||
msg.includes('not found') ||
msg.includes('is not recognized')
const errorCode = isSoxMissing
? ErrorCode.AudioCaptureStartFailed
: ErrorCode.AudioStreamError
const d3roError = new D3ROError(
errorCode,
isSoxMissing
? 'SoX가 설치되어 있지 않거나 PATH에 없습니다. SoX를 설치해주세요: https://sox.sourceforge.net'
: `Audio stream error: ${msg}`
)
this._handleError(d3roError, 'error')
})
// SoX 프로세스 종료 감지
this._stream.on('end', () => {
if (this._state === 'capturing') {
logger.warn('Audio stream ended unexpectedly')
this._handleError(
new D3ROError(ErrorCode.AudioCaptureFailed, 'Audio capture process terminated unexpectedly'),
'device-lost'
)
}
})
// SoX child process 에러 이벤트 (spawn 실패 등)
if (this._recording.process) {
this._recording.process.on('error', (err: Error) => {
logger.error(`SoX process spawn error: ${err.message}`)
const d3roError = new D3ROError(
ErrorCode.AudioCaptureStartFailed,
`SoX 프로세스 시작 실패: ${err.message}. SoX가 설치되어 있는지 확인해주세요.`
)
this._handleError(d3roError, 'error')
})
}
this._currentDevice = {
deviceId: selectedDeviceId ?? 'default',
label: 'Default Microphone',
isDefault: !selectedDeviceId || selectedDeviceId === 'default'
}
this._state = 'capturing'
this._refCount = 1
// 100ms 간격으로 audio-level 이벤트 emit
this._levelInterval = setInterval(() => {
this._emitAudioLevel()
}, TIMING.AUDIO_LEVEL_INTERVAL)
this.emit('started', { deviceId: this._currentDevice.deviceId })
logger.info('Audio capture started')
} catch (error) {
this._state = 'error'
const d3roError = new D3ROError(
ErrorCode.AudioCaptureStartFailed,
`Failed to start audio capture: ${error instanceof Error ? error.message : String(error)}`
)
this.emit('error', { error: d3roError })
throw d3roError
}
}
async stop(): Promise<void> {
if (this._state !== 'capturing') {
return
}
this._refCount--
if (this._refCount > 0) {
logger.debug(`Reference count decreased to ${this._refCount}`)
return
}
this._state = 'stopping'
this._cleanup()
this._state = 'idle'
this._currentDevice = null
this.emit('stopped', { reason: 'manual' })
logger.info('Audio capture stopped')
}
async getDevices(): Promise<AudioDevice[]> {
// 현재 Phase에서는 기본 디바이스만 반환. 실제 디바이스 열거는 추후.
logger.debug('Getting audio devices (default only)')
return [
{
deviceId: 'default',
label: 'Default Microphone',
isDefault: true
}
]
}
getCurrentDevice(): AudioDevice | null {
return this._currentDevice
}
dispose(): void {
this._cleanup()
this._state = 'idle'
this._currentDevice = null
this._refCount = 0
this.removeAllListeners()
logger.info('AudioCaptureService disposed')
}
/**
* 60ms (1920 bytes) emit한다.
* .
*/
private _onAudioChunk(chunk: Buffer): void {
// 잔여 버퍼와 새 청크를 결합
const combined = this._residualBuffer.length > 0
? Buffer.concat([this._residualBuffer, chunk])
: chunk
let offset = 0
// 60ms 프레임 단위로 분할하여 emit
while (offset + FRAME_SIZE_BYTES <= combined.length) {
const frame = combined.subarray(offset, offset + FRAME_SIZE_BYTES)
offset += FRAME_SIZE_BYTES
this.emit('audio-data', {
buffer: Buffer.from(frame), // 방어적 복사
timestamp: Date.now()
})
// RMS 계산용 누적
this._levelAccumulator.push(frame)
}
// 남은 바이트는 잔여 버퍼에 보관
if (offset < combined.length) {
this._residualBuffer = Buffer.from(combined.subarray(offset))
} else {
this._residualBuffer = Buffer.alloc(0)
}
}
/**
* 100ms RMS emit한다.
*/
private _emitAudioLevel(): void {
if (this._levelAccumulator.length === 0) {
this.emit('audio-level', { level: 0, timestamp: Date.now() })
return
}
// 누적된 프레임들을 하나로 합쳐서 RMS 계산
const combined = Buffer.concat(this._levelAccumulator)
this._levelAccumulator = []
const level = calculateRMS(combined)
this.emit('audio-level', { level, timestamp: Date.now() })
}
/**
* + emit.
*/
private _handleError(error: D3ROError, stopReason: 'device-lost' | 'error'): void {
if (this._state === 'idle' || this._state === 'stopping') {
return
}
this._cleanup()
this._state = 'error'
this._currentDevice = null
this.emit('error', { error })
this.emit('stopped', { reason: stopReason })
}
/**
* .
*/
private _cleanup(): void {
if (this._levelInterval) {
clearInterval(this._levelInterval)
this._levelInterval = null
}
if (this._stream) {
this._stream.removeAllListeners()
this._stream = null
}
if (this._recording) {
try {
this._recording.stop()
} catch {
// 이미 종료된 프로세스 kill 시 에러 무시
}
this._recording = null
}
this._residualBuffer = Buffer.alloc(0)
this._levelAccumulator = []
}
// EventEmitter 타입 오버라이드
override on<K extends keyof AudioCaptureEvents>(
event: K,
listener: AudioCaptureEvents[K]
): this {
return super.on(event, listener)
}
override off<K extends keyof AudioCaptureEvents>(
event: K,
listener: AudioCaptureEvents[K]
): this {
return super.off(event, listener)
}
override emit<K extends keyof AudioCaptureEvents>(
event: K,
...args: Parameters<AudioCaptureEvents[K]>
): boolean {
return super.emit(event, ...args)
}
}
// 싱글톤
let instance: AudioCaptureService | null = null
export function getAudioCaptureService(): AudioCaptureService {
if (!instance) {
instance = new AudioCaptureService()
}
return instance
}

View file

@ -0,0 +1,119 @@
// src/main/services/ConfigService.ts
// electron-store 기반 설정 관리. 설계서 02의 AppConfig 타입 사용.
import { EventEmitter } from 'events'
import type { AppConfig, ConfigChangedEvent } from '@shared/types'
import { getLogger } from './LoggerService'
const logger = getLogger('ConfigService')
// electron-store v10은 ESM 전용이므로 동적 import 필요
interface ElectronStore<T extends Record<string, unknown>> {
get<K extends keyof T>(key: K): T[K]
set<K extends keyof T>(key: K, value: T[K]): void
store: T
}
const CONFIG_DEFAULTS: AppConfig = {
theme: 'auto',
language: 'ko',
closeToTray: true,
autoLaunch: false,
soundEnabled: true,
selectedDeviceId: null,
sttModelId: 'base',
sttLanguage: 'auto',
ttsVoiceId: null,
ttsSpeed: 1.0,
ollamaServerUrl: 'http://localhost:11434',
llmModelId: null,
defaultLLMAction: 'refine',
dictationShortcut: {
keyCode: 0xa5, // Right Alt
ctrl: false,
alt: false,
shift: false,
meta: false,
displayLabel: 'Right Alt'
},
handsFreeShortcut: {
keyCode: 0xa5,
ctrl: false,
alt: false,
shift: false,
meta: false,
displayLabel: 'Right Alt (double)'
},
commandShortcut: {
keyCode: 0xa5,
ctrl: true,
alt: false,
shift: false,
meta: false,
displayLabel: 'Ctrl + Right Alt'
},
hotkeyEnabled: true,
insertMethod: 'clipboard',
autoInsert: true,
maxHistoryEntries: 1000
}
let store: ElectronStore<AppConfig> | null = null
const emitter = new EventEmitter()
export async function initConfigService(): Promise<void> {
const { default: Store } = await import('electron-store')
store = new Store<AppConfig>({
name: 'd3ro-voice-config',
defaults: CONFIG_DEFAULTS
})
logger.info('ConfigService initialized')
}
export function getConfigService(): ElectronStore<AppConfig> | null {
return store
}
export function configGet<K extends keyof AppConfig>(key: K): AppConfig[K] {
if (!store) {
logger.warn(`ConfigService not initialized, returning default for "${key}"`)
return CONFIG_DEFAULTS[key]
}
return store.get(key)
}
export function configSet<K extends keyof AppConfig>(key: K, value: AppConfig[K]): void {
if (!store) {
logger.warn(`ConfigService not initialized, cannot set "${key}"`)
return
}
const previousValue = store.get(key)
store.set(key, value)
const event: ConfigChangedEvent = {
key,
value,
previousValue
}
emitter.emit('config-changed', event)
logger.debug(`Config changed: ${key}`)
}
export function configGetAll(): AppConfig {
if (!store) return { ...CONFIG_DEFAULTS }
return store.store
}
export function configReset(key?: keyof AppConfig): void {
if (!store) return
if (key) {
store.set(key, CONFIG_DEFAULTS[key])
} else {
store.store = { ...CONFIG_DEFAULTS }
}
}
export function onConfigChanged(callback: (event: ConfigChangedEvent) => void): () => void {
emitter.on('config-changed', callback)
return () => emitter.off('config-changed', callback)
}

View file

@ -0,0 +1,536 @@
// src/main/services/HotkeyService.ts
// uiohook-napi 기반 글로벌 키보드 후킹 서비스.
// 설계서 01의 IHotkeyService 구현. Speakly HotkeyService + HotkeyConfig 패턴 참조.
import { EventEmitter } from 'events'
import { uIOhook, UiohookKey } from 'uiohook-napi'
import type { UiohookKeyboardEvent } from 'uiohook-napi'
import { getLogger } from './LoggerService'
import { configGet } from './ConfigService'
import { D3ROError, ErrorCode } from '@shared/errors'
import { TIMING } from '@shared/constants'
import type { HotkeyBinding } from '@shared/types'
const logger = getLogger('HotkeyService')
// ============================================================
// 내부 타입
// ============================================================
export interface HotkeyConfig {
/** 핫키 식별자 (예: 'voice-dictation', 'voice-handsfree') */
id: string
/** uiohook 키코드 */
keyCode: number
/** 수정자 키 목록 */
modifiers: HotkeyModifier[]
/** true=hold-to-talk (누르고 있는 동안 활성), false=toggle */
holdMode: boolean
/** 더블프레스 감지 활성화 여부 */
doublePressEnabled: boolean
/** 활성화 여부 */
enabled: boolean
}
export type HotkeyModifier = 'ctrl' | 'alt' | 'shift' | 'meta'
interface HotkeyServiceEvents {
'hotkey-pressed': (payload: { config: HotkeyConfig; timestamp: number }) => void
'hotkey-released': (payload: {
config: HotkeyConfig
durationMs: number
timestamp: number
}) => void
'double-press': (payload: {
config: HotkeyConfig
intervalMs: number
timestamp: number
}) => void
error: (payload: { error: D3ROError }) => void
}
// ============================================================
// Windows VK 코드 → uiohook 키코드 매핑
// ============================================================
/**
* Windows Virtual-Key uiohook-napi .
* ConfigService에 HotkeyBinding.keyCode는 Windows VK
* uiohook .
*/
const VK_TO_UIOHOOK: ReadonlyMap<number, number> = new Map([
// 수정자 키
[0xa0, UiohookKey.Shift], // VK_LSHIFT
[0xa1, UiohookKey.ShiftRight], // VK_RSHIFT
[0xa2, UiohookKey.Ctrl], // VK_LCONTROL
[0xa3, UiohookKey.CtrlRight], // VK_RCONTROL
[0xa4, UiohookKey.Alt], // VK_LMENU
[0xa5, UiohookKey.AltRight], // VK_RMENU (Right Alt)
[0x5b, UiohookKey.Meta], // VK_LWIN
[0x5c, UiohookKey.MetaRight], // VK_RWIN
// 기능 키
[0x70, UiohookKey.F1],
[0x71, UiohookKey.F2],
[0x72, UiohookKey.F3],
[0x73, UiohookKey.F4],
[0x74, UiohookKey.F5],
[0x75, UiohookKey.F6],
[0x76, UiohookKey.F7],
[0x77, UiohookKey.F8],
[0x78, UiohookKey.F9],
[0x79, UiohookKey.F10],
[0x7a, UiohookKey.F11],
[0x7b, UiohookKey.F12],
// 일반 키
[0x20, UiohookKey.Space],
[0x0d, UiohookKey.Enter],
[0x1b, UiohookKey.Escape],
[0x08, UiohookKey.Backspace],
[0x09, UiohookKey.Tab],
[0x2d, UiohookKey.Insert],
[0x2e, UiohookKey.Delete],
[0x24, UiohookKey.Home],
[0x23, UiohookKey.End],
[0x21, UiohookKey.PageUp],
[0x22, UiohookKey.PageDown],
[0x25, UiohookKey.ArrowLeft],
[0x26, UiohookKey.ArrowUp],
[0x27, UiohookKey.ArrowRight],
[0x28, UiohookKey.ArrowDown],
// 알파벳 (VK_A=0x41 ~ VK_Z=0x5A)
[0x41, UiohookKey.A],
[0x42, UiohookKey.B],
[0x43, UiohookKey.C],
[0x44, UiohookKey.D],
[0x45, UiohookKey.E],
[0x46, UiohookKey.F],
[0x47, UiohookKey.G],
[0x48, UiohookKey.H],
[0x49, UiohookKey.I],
[0x4a, UiohookKey.J],
[0x4b, UiohookKey.K],
[0x4c, UiohookKey.L],
[0x4d, UiohookKey.M],
[0x4e, UiohookKey.N],
[0x4f, UiohookKey.O],
[0x50, UiohookKey.P],
[0x51, UiohookKey.Q],
[0x52, UiohookKey.R],
[0x53, UiohookKey.S],
[0x54, UiohookKey.T],
[0x55, UiohookKey.U],
[0x56, UiohookKey.V],
[0x57, UiohookKey.W],
[0x58, UiohookKey.X],
[0x59, UiohookKey.Y],
[0x5a, UiohookKey.Z],
// 숫자 (VK_0=0x30 ~ VK_9=0x39)
[0x30, UiohookKey['0']],
[0x31, UiohookKey['1']],
[0x32, UiohookKey['2']],
[0x33, UiohookKey['3']],
[0x34, UiohookKey['4']],
[0x35, UiohookKey['5']],
[0x36, UiohookKey['6']],
[0x37, UiohookKey['7']],
[0x38, UiohookKey['8']],
[0x39, UiohookKey['9']]
])
/**
* HotkeyBinding(Windows VK ) HotkeyConfig(uiohook ) .
*/
function bindingToConfig(
id: string,
binding: HotkeyBinding,
holdMode: boolean,
doublePressEnabled: boolean
): HotkeyConfig {
const uiohookKeyCode = VK_TO_UIOHOOK.get(binding.keyCode)
if (uiohookKeyCode === undefined) {
logger.warn(
`Unknown VK code 0x${binding.keyCode.toString(16)} for hotkey "${id}", ` +
`using raw value ${binding.keyCode}`
)
}
const modifiers: HotkeyModifier[] = []
if (binding.ctrl) modifiers.push('ctrl')
if (binding.alt) modifiers.push('alt')
if (binding.shift) modifiers.push('shift')
if (binding.meta) modifiers.push('meta')
return {
id,
keyCode: uiohookKeyCode ?? binding.keyCode,
modifiers,
holdMode,
doublePressEnabled,
enabled: true
}
}
// ============================================================
// HotkeyService 클래스
// ============================================================
class HotkeyService extends EventEmitter {
private _isRunning = false
private _registeredHotkeys: Map<string, HotkeyConfig> = new Map()
/** 더블프레스 감지용: 마지막 press 시각 */
private _lastPressTime: Map<string, number> = new Map()
/** hold duration 계산용: press 시작 시각 */
private _pressStartTime: Map<string, number> = new Map()
/** 키 반복(auto-repeat) 방지: 현재 눌려있는 키 */
private _isKeyDown: Map<string, boolean> = new Map()
/** uiohook 이벤트 핸들러 (바인딩 해제용) */
private _onKeyDown: ((e: UiohookKeyboardEvent) => void) | null = null
private _onKeyUp: ((e: UiohookKeyboardEvent) => void) | null = null
get isRunning(): boolean {
return this._isRunning
}
get registeredHotkeys(): ReadonlyMap<string, HotkeyConfig> {
return this._registeredHotkeys
}
/**
* uiohook .
* .
*/
start(): void {
if (this._isRunning) {
logger.warn('HotkeyService already running')
return
}
try {
this._onKeyDown = (e: UiohookKeyboardEvent) => this._handleKeyDown(e)
this._onKeyUp = (e: UiohookKeyboardEvent) => this._handleKeyUp(e)
uIOhook.on('keydown', this._onKeyDown)
uIOhook.on('keyup', this._onKeyUp)
uIOhook.start()
this._isRunning = true
logger.info('uiohook started, global keyboard hook active')
} catch (error) {
const d3roError = new D3ROError(
ErrorCode.HotkeyHookInitFailed,
`Failed to start uiohook: ${error instanceof Error ? error.message : String(error)}`
)
this.emit('error', { error: d3roError })
logger.error(d3roError.message)
}
}
/**
* uiohook .
*/
stop(): void {
if (!this._isRunning) {
return
}
try {
uIOhook.stop()
if (this._onKeyDown) {
uIOhook.removeListener('keydown', this._onKeyDown)
this._onKeyDown = null
}
if (this._onKeyUp) {
uIOhook.removeListener('keyup', this._onKeyUp)
this._onKeyUp = null
}
this._isRunning = false
this._isKeyDown.clear()
this._pressStartTime.clear()
logger.info('uiohook stopped')
} catch (error) {
logger.error(
`Failed to stop uiohook: ${error instanceof Error ? error.message : String(error)}`
)
}
}
/**
* . ID가 .
*/
registerHotkey(config: HotkeyConfig): void {
if (!config.enabled) {
logger.debug(`Hotkey "${config.id}" is disabled, skipping registration`)
return
}
this._registeredHotkeys.set(config.id, config)
logger.info(
`Hotkey registered: "${config.id}" ` +
`(keyCode=${config.keyCode}, modifiers=[${config.modifiers.join(',')}], ` +
`holdMode=${config.holdMode}, doublePress=${config.doublePressEnabled})`
)
}
/**
* .
*/
unregisterHotkey(id: string): void {
if (this._registeredHotkeys.delete(id)) {
this._lastPressTime.delete(id)
this._pressStartTime.delete(id)
this._isKeyDown.delete(id)
logger.info(`Hotkey unregistered: "${id}"`)
}
}
/**
* ConfigService에서 .
*/
loadFromConfig(): void {
const hotkeyEnabled = configGet('hotkeyEnabled')
if (!hotkeyEnabled) {
logger.info('Hotkeys disabled in config')
return
}
const dictationBinding = configGet('dictationShortcut')
const handsFreeBinding = configGet('handsFreeShortcut')
const commandBinding = configGet('commandShortcut')
// 기존 핫키 초기화
this._registeredHotkeys.clear()
// Dictation: hold-to-talk, 더블프레스 비활성
this.registerHotkey(
bindingToConfig('voice-dictation', dictationBinding, true, false)
)
// Hands-free: toggle, 더블프레스 활성
this.registerHotkey(
bindingToConfig('voice-handsfree', handsFreeBinding, false, true)
)
// Command: toggle, 더블프레스 비활성
this.registerHotkey(
bindingToConfig('voice-command', commandBinding, false, false)
)
logger.info(
`Loaded ${this._registeredHotkeys.size} hotkeys from config`
)
}
/**
* .
*/
dispose(): void {
this.stop()
this._registeredHotkeys.clear()
this._lastPressTime.clear()
this._pressStartTime.clear()
this._isKeyDown.clear()
this.removeAllListeners()
logger.info('HotkeyService disposed')
}
// ============================================================
// 내부: 키 이벤트 처리
// ============================================================
/**
* .
* .
*/
private _handleKeyDown(e: UiohookKeyboardEvent): void {
const matched = this._findMatchingHotkey(e)
if (!matched) return
const { id } = matched
// 키 반복(auto-repeat) 무시: 이미 눌려있으면 건너뜀
if (this._isKeyDown.get(id)) {
return
}
this._isKeyDown.set(id, true)
const now = Date.now()
// press 시작 시각 기록 (hold duration 계산용)
this._pressStartTime.set(id, now)
// 더블프레스 감지
if (matched.doublePressEnabled) {
const lastPress = this._lastPressTime.get(id)
if (lastPress !== undefined && now - lastPress < TIMING.DOUBLE_PRESS_DURATION) {
// 300ms 이내 연속 두 번 press = 더블프레스
this._lastPressTime.delete(id)
logger.debug(`Double-press detected: "${id}" (interval=${now - lastPress}ms)`)
this.emit('double-press', {
config: matched,
intervalMs: now - lastPress,
timestamp: now
})
return
}
this._lastPressTime.set(id, now)
}
logger.debug(`Hotkey pressed: "${id}"`)
this.emit('hotkey-pressed', {
config: matched,
timestamp: now
})
}
/**
* .
*/
private _handleKeyUp(e: UiohookKeyboardEvent): void {
const matched = this._findMatchingHotkey(e)
if (!matched) return
const { id } = matched
// 눌려있지 않은 키의 release는 무시
if (!this._isKeyDown.get(id)) {
return
}
this._isKeyDown.set(id, false)
const now = Date.now()
const pressStart = this._pressStartTime.get(id)
const durationMs = pressStart !== undefined ? now - pressStart : 0
this._pressStartTime.delete(id)
logger.debug(`Hotkey released: "${id}" (duration=${durationMs}ms)`)
this.emit('hotkey-released', {
config: matched,
durationMs,
timestamp: now
})
}
/**
* uiohook .
* .
*/
private _findMatchingHotkey(e: UiohookKeyboardEvent): HotkeyConfig | null {
for (const config of this._registeredHotkeys.values()) {
if (!config.enabled) continue
// 키코드 일치 확인
if (e.keycode !== config.keyCode) continue
// 수정자 키 일치 확인
// 핫키에 지정된 수정자가 모두 눌려 있어야 하고,
// 지정되지 않은 수정자는 눌려 있으면 안 된다.
//
// 단, 핫키의 주 키 자체가 수정자 키인 경우(예: Right Alt)에는
// 해당 수정자의 altKey 등이 true로 올 수 있으므로, 주 키가 수정자인 경우
// 해당 수정자 검사를 건너뛴다.
const isKeyModifier = this._isModifierKeyCode(config.keyCode)
const wantsCtrl = config.modifiers.includes('ctrl')
const wantsAlt = config.modifiers.includes('alt')
const wantsShift = config.modifiers.includes('shift')
const wantsMeta = config.modifiers.includes('meta')
const ctrlMatch = isKeyModifier && this._isCtrlKeyCode(config.keyCode)
? true
: e.ctrlKey === wantsCtrl
const altMatch = isKeyModifier && this._isAltKeyCode(config.keyCode)
? true
: e.altKey === wantsAlt
const shiftMatch = isKeyModifier && this._isShiftKeyCode(config.keyCode)
? true
: e.shiftKey === wantsShift
const metaMatch = isKeyModifier && this._isMetaKeyCode(config.keyCode)
? true
: e.metaKey === wantsMeta
if (ctrlMatch && altMatch && shiftMatch && metaMatch) {
return config
}
}
return null
}
/** 주어진 uiohook 키코드가 수정자 키(Ctrl/Alt/Shift/Meta)인지 확인 */
private _isModifierKeyCode(keyCode: number): boolean {
return (
this._isCtrlKeyCode(keyCode) ||
this._isAltKeyCode(keyCode) ||
this._isShiftKeyCode(keyCode) ||
this._isMetaKeyCode(keyCode)
)
}
private _isCtrlKeyCode(keyCode: number): boolean {
return keyCode === UiohookKey.Ctrl || keyCode === UiohookKey.CtrlRight
}
private _isAltKeyCode(keyCode: number): boolean {
return keyCode === UiohookKey.Alt || keyCode === UiohookKey.AltRight
}
private _isShiftKeyCode(keyCode: number): boolean {
return keyCode === UiohookKey.Shift || keyCode === UiohookKey.ShiftRight
}
private _isMetaKeyCode(keyCode: number): boolean {
return keyCode === UiohookKey.Meta || keyCode === UiohookKey.MetaRight
}
// ============================================================
// EventEmitter 타입 오버라이드
// ============================================================
override on<K extends keyof HotkeyServiceEvents>(
event: K,
listener: HotkeyServiceEvents[K]
): this {
return super.on(event, listener)
}
override off<K extends keyof HotkeyServiceEvents>(
event: K,
listener: HotkeyServiceEvents[K]
): this {
return super.off(event, listener)
}
override emit<K extends keyof HotkeyServiceEvents>(
event: K,
...args: Parameters<HotkeyServiceEvents[K]>
): boolean {
return super.emit(event, ...args)
}
}
// ============================================================
// 싱글톤
// ============================================================
let instance: HotkeyService | null = null
export function getHotkeyService(): HotkeyService {
if (!instance) {
instance = new HotkeyService()
}
return instance
}

View file

@ -0,0 +1,729 @@
// src/main/services/LocalSTTService.ts
// faster-whisper sidecar를 관리하고 오디오 버퍼를 텍스트로 변환한다.
// 싱글톤 + EventEmitter 패턴. Speakly VoiceRecognitionService의
// 상태 머신 및 이중 조건 플러시 패턴 적용.
import { EventEmitter } from 'events'
import { type ChildProcess, spawn } from 'child_process'
import path from 'path'
import { app } from 'electron'
import { getLogger } from './LoggerService'
import { configGet } from './ConfigService'
import { D3ROError, ErrorCode } from '@shared/errors'
import type { STTModel, STTStatus, STTEngineState } from '@shared/types'
// ── 내부 타입 정의 ────────────────────────────────────────
/** STT 엔진 상태 머신 */
const enum STTState {
Uninitialized = 'uninitialized',
Loading = 'loading',
Ready = 'ready',
Transcribing = 'transcribing',
Error = 'error',
}
/** 전사 결과 세그먼트 */
export interface TranscriptionSegment {
readonly text: string
readonly start: number
readonly end: number
readonly confidence: number
}
/** 전사 결과 */
export interface TranscriptionResult {
readonly text: string
readonly segments: TranscriptionSegment[]
readonly language: string
readonly duration: number
readonly processingTime: number
}
/** 전사 옵션 */
export interface TranscribeOptions {
language?: string
initialPrompt?: string
vadFilter?: boolean
}
/** sidecar /health 응답 */
interface HealthResponse {
status: string
model: string | null
gpu: boolean
}
/** sidecar /load 응답 */
interface LoadResponse {
status: string
model_id: string
load_time_ms: number
}
/** sidecar /transcribe 응답 */
interface TranscribeResponse {
text: string
segments: Array<{
text: string
start: number
end: number
avg_logprob: number
}>
language: string
duration: number
processing_time: number
}
/** 이벤트 페이로드 */
export interface LocalSTTEvents {
'transcription-delta': { text: string; isFinal: boolean }
'transcription-complete': { result: TranscriptionResult }
'model-loaded': { model: STTModel; loadTimeMs: number }
'error': { error: D3ROError }
}
// ── 상수 ──────────────────────────────────────────────────
const SIDECAR_PORT = 18765
const HEALTH_CHECK_INTERVAL_MS = 1000
const HEALTH_CHECK_TIMEOUT_MS = 30000
const MAX_RESTART_COUNT = 3
const SIDECAR_REQUEST_TIMEOUT_MS = 120000
/** 알려진 Whisper 모델 카탈로그 */
const MODEL_CATALOG: STTModel[] = [
{
id: 'tiny',
name: 'Tiny',
sizeBytes: 75_000_000,
downloaded: false,
languages: ['auto', 'ko', 'en', 'ja', 'zh'],
accuracy: 1,
speed: 5,
},
{
id: 'base',
name: 'Base',
sizeBytes: 141_000_000,
downloaded: false,
languages: ['auto', 'ko', 'en', 'ja', 'zh'],
accuracy: 2,
speed: 4,
},
{
id: 'small',
name: 'Small',
sizeBytes: 466_000_000,
downloaded: false,
languages: ['auto', 'ko', 'en', 'ja', 'zh'],
accuracy: 3,
speed: 3,
},
{
id: 'medium',
name: 'Medium',
sizeBytes: 1_500_000_000,
downloaded: false,
languages: ['auto', 'ko', 'en', 'ja', 'zh'],
accuracy: 4,
speed: 2,
},
{
id: 'large-v3',
name: 'Large V3',
sizeBytes: 3_100_000_000,
downloaded: false,
languages: ['auto', 'ko', 'en', 'ja', 'zh'],
accuracy: 5,
speed: 1,
},
]
// ── 서비스 구현 ───────────────────────────────────────────
const logger = getLogger('LocalSTTService')
class LocalSTTService extends EventEmitter {
private _state: STTState = STTState.Uninitialized
private _sidecarProcess: ChildProcess | null = null
private _port: number = SIDECAR_PORT
private _currentModelId: string | null = null
private _restartCount: number = 0
private _disposed: boolean = false
private _gpuAccelerated: boolean = false
// ── 이중 조건 플러시 (Speakly 패턴) ──
private _modelReady: boolean = false
private _audioBuffer: Buffer[] = []
private _pendingResolve: ((result: TranscriptionResult) => void) | null = null
private _pendingReject: ((error: D3ROError) => void) | null = null
// errorEmitted 플래그로 이벤트 중복 방지
private _errorEmitted: boolean = false
// ── 상태 접근자 ──
get state(): STTState {
return this._state
}
get currentModelId(): string | null {
return this._currentModelId
}
// ── 공개 메서드 ──
/**
* Whisper sidecar + .
* .
*/
async initialize(modelId?: string): Promise<void> {
const targetModel = modelId ?? configGet('sttModelId')
if (this._disposed) {
throw new D3ROError(
ErrorCode.STTSidecarSpawnFailed,
'LocalSTTService가 이미 dispose되었습니다',
)
}
// 이미 같은 모델이 로딩된 상태면 무시
if (
this._state === STTState.Ready &&
this._currentModelId === targetModel
) {
logger.debug(`모델 ${targetModel}이 이미 로딩되어 있습니다`)
return
}
this._setState(STTState.Loading)
this._errorEmitted = false
try {
// sidecar가 아직 실행 중이 아니면 시작
if (!this._sidecarProcess || this._sidecarProcess.exitCode !== null) {
await this._spawnSidecar()
await this._waitForHealth()
}
// 모델 로딩
await this._loadModel(targetModel)
this._currentModelId = targetModel
this._modelReady = true
this._setState(STTState.Ready)
// 이중 조건 플러시 시도
this._tryFlushAll()
logger.info(`STT 초기화 완료: 모델=${targetModel}`)
} catch (err) {
this._setState(STTState.Error)
const d3roErr =
err instanceof D3ROError
? err
: new D3ROError(
ErrorCode.STTModelLoadFailed,
`STT 초기화 실패: ${err instanceof Error ? err.message : String(err)}`,
)
this._emitError(d3roErr)
throw d3roErr
}
}
/**
* .
* PCM16 16kHz mono .
* 플러시: 모델 .
*/
async transcribe(
audioBuffer: Buffer,
options?: TranscribeOptions,
): Promise<TranscriptionResult> {
if (this._disposed) {
throw new D3ROError(
ErrorCode.STTTranscriptionFailed,
'LocalSTTService가 이미 dispose되었습니다',
)
}
if (audioBuffer.length === 0) {
throw new D3ROError(ErrorCode.STTNoAudioData, '오디오 데이터가 비어있습니다')
}
// 모델이 아직 준비되지 않았으면 버퍼에 적재하고 대기
if (!this._modelReady) {
logger.debug('모델 로딩 중, 오디오 버퍼에 적재')
this._audioBuffer.push(audioBuffer)
return new Promise<TranscriptionResult>((resolve, reject) => {
this._pendingResolve = resolve
this._pendingReject = reject
// 이중 조건 플러시 시도 (모델이 이미 준비되었을 수 있음)
this._tryFlushAll()
})
}
// 모델 준비 완료 상태: 직접 전사
return this._sendToSidecar(audioBuffer, options)
}
/**
* .
* sidecar에 (faster-whisper가 ).
*/
getModels(): STTModel[] {
return MODEL_CATALOG.map((m) => ({
...m,
// 현재 로딩된 모델은 downloaded=true로 표시
downloaded: m.id === this._currentModelId ? true : m.downloaded,
}))
}
/** 현재 상태 조회 */
getStatus(): STTStatus {
const stateMap: Record<STTState, STTEngineState> = {
[STTState.Uninitialized]: 'not-installed' as STTEngineState,
[STTState.Loading]: 'loading' as STTEngineState,
[STTState.Ready]: 'ready' as STTEngineState,
[STTState.Transcribing]: 'processing' as STTEngineState,
[STTState.Error]: 'error' as STTEngineState,
}
return {
engineState: stateMap[this._state],
activeModel: this._currentModelId,
engineVersion: null,
gpuAccelerated: this._gpuAccelerated,
}
}
/** sidecar 프로세스 종료 및 리소스 정리 */
async dispose(): Promise<void> {
if (this._disposed) return
this._disposed = true
logger.info('LocalSTTService dispose 시작')
// pending promise를 reject
if (this._pendingReject) {
this._pendingReject(
new D3ROError(ErrorCode.STTTranscriptionCancelled, '서비스 종료로 전사 취소'),
)
this._pendingResolve = null
this._pendingReject = null
}
this._audioBuffer = []
this._modelReady = false
await this._shutdownSidecar()
this._setState(STTState.Uninitialized)
logger.info('LocalSTTService dispose 완료')
}
// ── 이중 조건 플러시 (Speakly 핵심 패턴) ──
/**
* .
* () , .
*/
private _tryFlushAll(): void {
if (!this._modelReady) return
if (this._audioBuffer.length === 0) return
if (!this._pendingResolve) return
const merged = Buffer.concat(this._audioBuffer)
this._audioBuffer = []
const resolve = this._pendingResolve
const reject = this._pendingReject
this._pendingResolve = null
this._pendingReject = null
this._sendToSidecar(merged)
.then(resolve)
.catch((err: unknown) => {
if (reject) {
reject(
err instanceof D3ROError
? err
: new D3ROError(
ErrorCode.STTTranscriptionFailed,
`전사 실패: ${err instanceof Error ? err.message : String(err)}`,
),
)
}
})
}
// ── Sidecar 관리 ──
private _getSidecarPath(): string {
const basePath = app.isPackaged ? process.resourcesPath : app.getAppPath()
return path.join(basePath, 'sidecar', 'main.py')
}
private async _spawnSidecar(): Promise<void> {
const sidecarPath = this._getSidecarPath()
logger.info(`Sidecar 시작: python ${sidecarPath} --port ${this._port}`)
return new Promise<void>((resolve, reject) => {
const pythonCmd = process.platform === 'win32' ? 'python' : 'python3'
try {
this._sidecarProcess = spawn(
pythonCmd,
[sidecarPath, '--port', String(this._port)],
{
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env },
},
)
} catch (err) {
const d3roErr = new D3ROError(
ErrorCode.STTSidecarSpawnFailed,
`Sidecar 프로세스 생성 실패: ${err instanceof Error ? err.message : String(err)}`,
)
reject(d3roErr)
return
}
const sidecarLogger = getLogger('sidecar')
this._sidecarProcess.stdout?.on('data', (data: Buffer) => {
const text = data.toString().trim()
if (text) {
sidecarLogger.info(text)
}
})
this._sidecarProcess.stderr?.on('data', (data: Buffer) => {
const text = data.toString().trim()
if (text) {
sidecarLogger.warn(text)
}
})
this._sidecarProcess.on('error', (err: Error) => {
logger.error(`Sidecar 프로세스 에러: ${err.message}`)
reject(
new D3ROError(
ErrorCode.STTSidecarSpawnFailed,
`Sidecar 프로세스 에러: ${err.message}`,
),
)
})
this._sidecarProcess.on('exit', (code: number | null, signal: string | null) => {
logger.warn(`Sidecar 프로세스 종료: code=${code}, signal=${signal}`)
this._sidecarProcess = null
this._modelReady = false
if (!this._disposed) {
this._handleSidecarCrash()
}
})
// spawn 자체는 비동기적이므로 즉시 resolve
// 실제 준비는 _waitForHealth에서 확인
resolve()
})
}
private async _waitForHealth(): Promise<void> {
const startTime = Date.now()
while (Date.now() - startTime < HEALTH_CHECK_TIMEOUT_MS) {
try {
const response = await fetch(`http://localhost:${this._port}/health`, {
signal: AbortSignal.timeout(2000),
})
if (response.ok) {
const data = (await response.json()) as HealthResponse
this._gpuAccelerated = data.gpu
logger.info(
`Sidecar 헬스체크 성공: status=${data.status}, gpu=${data.gpu}`,
)
return
}
} catch {
// 아직 준비 안 됨, 재시도
}
await this._sleep(HEALTH_CHECK_INTERVAL_MS)
}
throw new D3ROError(
ErrorCode.STTSidecarCommunicationFailed,
`Sidecar 헬스체크 타임아웃 (${HEALTH_CHECK_TIMEOUT_MS}ms)`,
)
}
private async _loadModel(modelId: string): Promise<void> {
logger.info(`모델 로딩 시작: ${modelId}`)
const startTime = Date.now()
const response = await fetch(`http://localhost:${this._port}/load`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model_id: modelId }),
signal: AbortSignal.timeout(SIDECAR_REQUEST_TIMEOUT_MS),
})
if (!response.ok) {
const errorText = await response.text()
throw new D3ROError(
ErrorCode.STTModelLoadFailed,
`모델 로딩 실패 (HTTP ${response.status}): ${errorText}`,
)
}
const data = (await response.json()) as LoadResponse
const loadTimeMs = Date.now() - startTime
const model = MODEL_CATALOG.find((m) => m.id === modelId)
if (model) {
this.emit('model-loaded', {
model: { ...model, downloaded: true },
loadTimeMs,
})
}
logger.info(
`모델 로딩 완료: ${data.model_id}, ${loadTimeMs}ms`,
)
}
// ── HTTP 전사 요청 ──
private async _sendToSidecar(
audioBuffer: Buffer,
options?: TranscribeOptions,
): Promise<TranscriptionResult> {
if (!this._sidecarProcess || this._sidecarProcess.exitCode !== null) {
throw new D3ROError(
ErrorCode.STTSidecarCommunicationFailed,
'Sidecar 프로세스가 실행 중이 아닙니다',
)
}
this._setState(STTState.Transcribing)
const startTime = Date.now()
try {
const language = options?.language ?? configGet('sttLanguage')
const vadFilter = options?.vadFilter ?? true
const initialPrompt = options?.initialPrompt ?? ''
// Node 18+ 내장 fetch + FormData + Blob으로 multipart 전송
const formData = new FormData()
// Buffer → ArrayBuffer 복사 후 Blob 생성 (Node/Electron 타입 호환)
const arrayBuf = audioBuffer.buffer.slice(
audioBuffer.byteOffset,
audioBuffer.byteOffset + audioBuffer.byteLength,
) as ArrayBuffer
formData.append(
'audio',
new Blob([arrayBuf], { type: 'application/octet-stream' }),
'audio.pcm',
)
formData.append('language', language)
formData.append('vad_filter', String(vadFilter))
if (initialPrompt) {
formData.append('initial_prompt', initialPrompt)
}
const response = await fetch(
`http://localhost:${this._port}/transcribe`,
{
method: 'POST',
body: formData,
signal: AbortSignal.timeout(SIDECAR_REQUEST_TIMEOUT_MS),
},
)
if (!response.ok) {
const errorText = await response.text()
throw new D3ROError(
ErrorCode.STTTranscriptionFailed,
`전사 실패 (HTTP ${response.status}): ${errorText}`,
)
}
const data = (await response.json()) as TranscribeResponse
const processingTime = Date.now() - startTime
const result: TranscriptionResult = {
text: data.text,
segments: data.segments.map((seg) => ({
text: seg.text,
start: seg.start,
end: seg.end,
confidence: Math.exp(seg.avg_logprob),
})),
language: data.language,
duration: data.duration,
processingTime,
}
// 중간 결과 이벤트 (isFinal=true)
this.emit('transcription-delta', { text: result.text, isFinal: true })
this.emit('transcription-complete', { result })
this._setState(STTState.Ready)
logger.info(
`전사 완료: "${result.text.substring(0, 50)}..." (${processingTime}ms, lang=${result.language})`,
)
return result
} catch (err) {
this._setState(STTState.Ready) // 에러 후에도 Ready 복귀 (sidecar가 살아있으면)
if (err instanceof D3ROError) {
throw err
}
const message = err instanceof Error ? err.message : String(err)
// 타임아웃 구분
if (message.includes('abort') || message.includes('timeout')) {
throw new D3ROError(
ErrorCode.STTTranscriptionTimeout,
`전사 타임아웃: ${message}`,
)
}
throw new D3ROError(
ErrorCode.STTTranscriptionFailed,
`전사 실패: ${message}`,
)
}
}
// ── Sidecar Crash 처리 ──
private _handleSidecarCrash(): void {
if (this._disposed) return
this._restartCount++
logger.warn(`Sidecar crash 감지, 재시작 시도 ${this._restartCount}/${MAX_RESTART_COUNT}`)
if (this._restartCount > MAX_RESTART_COUNT) {
const err = new D3ROError(
ErrorCode.STTSidecarCrashed,
`Sidecar가 ${MAX_RESTART_COUNT}회 crash 후 재시작 포기`,
)
this._setState(STTState.Error)
this._emitError(err)
// pending promise reject
if (this._pendingReject) {
this._pendingReject(err)
this._pendingResolve = null
this._pendingReject = null
}
return
}
// 비동기 재시작
const modelToReload = this._currentModelId
this._currentModelId = null
this._modelReady = false
// setTimeout으로 이벤트 루프에 양보
setTimeout(() => {
if (this._disposed) return
this.initialize(modelToReload ?? undefined).catch((err: unknown) => {
logger.error(
`Sidecar 재시작 실패: ${err instanceof Error ? err.message : String(err)}`,
)
})
}, 1000 * this._restartCount) // 점진적 백오프
}
private async _shutdownSidecar(): Promise<void> {
if (!this._sidecarProcess || this._sidecarProcess.exitCode !== null) {
this._sidecarProcess = null
return
}
logger.info('Sidecar 종료 요청')
try {
// POST /shutdown 요청
await fetch(`http://localhost:${this._port}/shutdown`, {
method: 'POST',
signal: AbortSignal.timeout(3000),
})
} catch {
// 이미 종료되었거나 통신 불가 — 무시
}
// 프로세스가 아직 살아있으면 강제 종료
if (this._sidecarProcess && this._sidecarProcess.exitCode === null) {
logger.warn('Sidecar graceful shutdown 실패, SIGKILL 전송')
this._sidecarProcess.kill('SIGKILL')
}
this._sidecarProcess = null
}
// ── 내부 유틸 ──
private _setState(newState: STTState): void {
if (this._state === newState) return
const prev = this._state
this._state = newState
logger.debug(`STTState: ${prev} -> ${newState}`)
}
private _emitError(error: D3ROError): void {
if (this._errorEmitted) return
this._errorEmitted = true
this.emit('error', { error })
logger.error(`STT 에러: [${error.code}] ${error.message}`)
}
private _sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
// ── 타입 안전한 이벤트 메서드 오버라이드 ──
override emit<K extends keyof LocalSTTEvents>(
event: K,
payload: LocalSTTEvents[K],
): boolean {
return super.emit(event, payload)
}
override on<K extends keyof LocalSTTEvents>(
event: K,
listener: (payload: LocalSTTEvents[K]) => void,
): this {
return super.on(event, listener)
}
override off<K extends keyof LocalSTTEvents>(
event: K,
listener: (payload: LocalSTTEvents[K]) => void,
): this {
return super.off(event, listener)
}
}
// ── 싱글톤 ──
let instance: LocalSTTService | null = null
export function getLocalSTTService(): LocalSTTService {
if (!instance) {
instance = new LocalSTTService()
}
return instance
}
export { LocalSTTService, STTState }

View file

@ -0,0 +1,32 @@
// src/main/services/LoggerService.ts
// electron-log 래퍼. 카테고리별 로깅을 지원한다.
import log from 'electron-log'
export interface CategoryLogger {
info(message: string, ...args: unknown[]): void
warn(message: string, ...args: unknown[]): void
error(message: string, ...args: unknown[]): void
debug(message: string, ...args: unknown[]): void
}
let initialized = false
export function initLoggerService(): void {
if (initialized) return
initialized = true
log.transports.file.maxSize = 5 * 1024 * 1024 // 5MB
log.transports.file.format = '[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] {text}'
log.transports.console.format = '[{h}:{i}:{s}.{ms}] [{level}] {text}'
}
export function getLogger(category: string): CategoryLogger {
const prefix = `[${category}]`
return {
info: (message: string, ...args: unknown[]) => log.info(`${prefix} ${message}`, ...args),
warn: (message: string, ...args: unknown[]) => log.warn(`${prefix} ${message}`, ...args),
error: (message: string, ...args: unknown[]) => log.error(`${prefix} ${message}`, ...args),
debug: (message: string, ...args: unknown[]) => log.debug(`${prefix} ${message}`, ...args)
}
}

View file

@ -0,0 +1,625 @@
// src/main/services/VoiceModeService.ts
// 전체 음성 파이프라인 오케스트레이터.
// 설계서 01의 IVoiceModeService 구현. Speakly VoiceModeService의
// 상태 머신, 이중 조건 플러시, Action Queue 패턴 적용.
import { EventEmitter } from 'events'
import { randomUUID } from 'crypto'
import { getLogger } from './LoggerService'
import { getAudioCaptureService } from './AudioCaptureService'
import { getLocalSTTService } from './LocalSTTService'
import type { TranscriptionResult } from './LocalSTTService'
import { getHotkeyService } from './HotkeyService'
import type { HotkeyConfig } from './HotkeyService'
import { configGet } from './ConfigService'
import { D3ROError, ErrorCode } from '@shared/errors'
import { TIMING } from '@shared/constants'
import { RecognitionState, AudioState } from '@shared/types'
import type { VoiceMode, VoiceState } from '@shared/types'
const logger = getLogger('VoiceModeService')
// ============================================================
// 내부 타입
// ============================================================
interface VoiceSession {
id: string
mode: VoiceMode
startedAt: number
recognitionState: RecognitionState
audioState: AudioState
audioBufferDurationMs: number
transcription: string
processedText: string | null
accidentalPress: boolean
}
interface VoiceAction {
type: 'press' | 'release' | 'escape'
timestamp: number
mode: VoiceMode
hotkeyId: string
}
interface VoiceModeEvents {
'session-started': (payload: { session: VoiceSession }) => void
'recognition-state-changed': (payload: {
previous: RecognitionState
current: RecognitionState
}) => void
'audio-state-changed': (payload: {
previous: AudioState
current: AudioState
}) => void
'transcription-update': (payload: { text: string; isFinal: boolean }) => void
'session-completed': (payload: { session: VoiceSession; finalText: string }) => void
'session-cancelled': (payload: {
session: VoiceSession
reason: 'user' | 'timeout' | 'too-short'
}) => void
'audio-level': (payload: { level: number }) => void
error: (payload: { error: D3ROError; session: VoiceSession | null }) => void
}
// ============================================================
// 터미널 상태 헬퍼
// ============================================================
const TERMINAL_STATES = new Set<RecognitionState>([
RecognitionState.COMPLETED,
RecognitionState.CANCELLED,
RecognitionState.ERROR,
RecognitionState.DESTROYED
])
// ============================================================
// VoiceModeService
// ============================================================
class VoiceModeService extends EventEmitter {
private _session: VoiceSession | null = null
private _recognitionState = RecognitionState.IDLE
private _audioState = AudioState.IDLE
// 이중 조건 플러시
private _sttReady = false
private _audioStarted = false
private _audioBuffer: Buffer[] = []
private _audioBufferBytes = 0
// 에러 가드
private _errorEmitted = false
// Action Queue (이벤트 직렬화)
private _actionQueue: VoiceAction[] = []
private _isProcessingQueue = false
// 리스너 해제용 참조
private _audioDataHandler: ((payload: { buffer: Buffer }) => void) | null = null
private _audioLevelHandler: ((payload: { level: number }) => void) | null = null
private _hotkeyPressHandler: ((payload: { config: HotkeyConfig; timestamp: number }) => void) | null = null
private _hotkeyReleaseHandler: ((payload: { config: HotkeyConfig; durationMs: number; timestamp: number }) => void) | null = null
private _doublePressHandler: ((payload: { config: HotkeyConfig }) => void) | null = null
private _disposed = false
get currentSession(): VoiceSession | null {
return this._session
}
get isActive(): boolean {
return this._session !== null && !this._isInTerminalState()
}
getState(): VoiceState {
return {
recognitionState: this._recognitionState,
audioState: this._audioState,
mode: configGet('defaultLLMAction') === 'translate' ? 'hands-free' : 'dictation',
sessionId: this._session?.id ?? null,
recordingStartedAt: this._session?.startedAt ?? null
}
}
// ── 초기화 ──────────────────────────────────────────────
/**
* HotkeyService .
* bootstrap에서 .
*/
connectHotkey(): void {
const hotkey = getHotkeyService()
this._hotkeyPressHandler = (payload) => {
const mode = this._resolveMode(payload.config)
this._enqueueAction({ type: 'press', timestamp: payload.timestamp, mode, hotkeyId: payload.config.id })
}
this._hotkeyReleaseHandler = (payload) => {
const mode = this._resolveMode(payload.config)
this._enqueueAction({ type: 'release', timestamp: payload.timestamp, mode, hotkeyId: payload.config.id })
}
this._doublePressHandler = (payload) => {
// 더블프레스 → hands-free 모드 토글
this._enqueueAction({ type: 'press', timestamp: Date.now(), mode: 'hands-free', hotkeyId: payload.config.id })
}
hotkey.on('hotkey-pressed', this._hotkeyPressHandler)
hotkey.on('hotkey-released', this._hotkeyReleaseHandler)
hotkey.on('double-press', this._doublePressHandler)
logger.info('Hotkey events connected')
}
// ── 세션 제어 ──────────────────────────────────────────
async startSession(mode: VoiceMode): Promise<void> {
if (this._disposed) return
if (this._session && !this._isInTerminalState()) {
logger.warn('Session already active, ignoring startSession')
return
}
// 세션 생성
this._session = {
id: randomUUID(),
mode,
startedAt: Date.now(),
recognitionState: RecognitionState.PREPARING,
audioState: AudioState.IDLE,
audioBufferDurationMs: 0,
transcription: '',
processedText: null,
accidentalPress: false
}
this._errorEmitted = false
this._sttReady = false
this._audioStarted = false
this._audioBuffer = []
this._audioBufferBytes = 0
this._setRecognitionState(RecognitionState.PREPARING)
this.emit('session-started', { session: this._session })
logger.info(`Session started: ${this._session.id} (mode: ${mode})`)
// 이중 조건 플러시: STT 초기화 + 오디오 캡처를 병렬 시작
const sttPromise = this._initSTT()
const audioPromise = this._startAudio()
// 둘 다 에러여도 개별 처리하므로 allSettled
await Promise.allSettled([sttPromise, audioPromise])
}
async stopSession(): Promise<void> {
if (!this._session || this._isInTerminalState()) return
const session = this._session
const duration = Date.now() - session.startedAt
// accidentalPress 체크 (700ms 미만)
if (duration < TIMING.MIN_AUDIO_DURATION) {
session.accidentalPress = true
logger.info(`Accidental press detected (${duration}ms < ${TIMING.MIN_AUDIO_DURATION}ms)`)
this._cancelSession('too-short')
return
}
// 오디오 캡처 중지
await this._stopAudio()
// 버퍼가 있으면 STT에 전달
if (this._audioBuffer.length > 0 && this._sttReady) {
await this._transcribe()
} else if (this._audioBuffer.length > 0 && !this._sttReady) {
// STT 아직 준비 안 됨 → tryFlushAll이 처리
logger.info('Waiting for STT to be ready before transcribing')
// 타임아웃 설정
setTimeout(() => {
if (this._session?.id === session.id && !this._isInTerminalState()) {
logger.warn('STT readiness timeout, cancelling session')
this._cancelSession('timeout')
}
}, TIMING.POST_RECORDING_WAIT_BUFFERED)
} else {
// 오디오 없음
logger.warn('No audio buffer, cancelling session')
this._cancelSession('too-short')
}
}
cancelSession(): void {
this._cancelSession('user')
}
// ── 상태 머신 ──────────────────────────────────────────
private _isInTerminalState(): boolean {
return TERMINAL_STATES.has(this._recognitionState)
}
private _setRecognitionState(state: RecognitionState): void {
if (this._recognitionState === state) return
if (this._isInTerminalState() && state !== RecognitionState.IDLE) return
const previous = this._recognitionState
this._recognitionState = state
if (this._session) {
this._session.recognitionState = state
}
this.emit('recognition-state-changed', { previous, current: state })
logger.debug(`RecognitionState: ${previous}${state}`)
}
private _setAudioState(state: AudioState): void {
if (this._audioState === state) return
const previous = this._audioState
this._audioState = state
if (this._session) {
this._session.audioState = state
}
this.emit('audio-state-changed', { previous, current: state })
logger.debug(`AudioState: ${previous}${state}`)
}
// ── STT 초기화 ─────────────────────────────────────────
private async _initSTT(): Promise<void> {
try {
this._setRecognitionState(RecognitionState.CONNECTING)
const stt = getLocalSTTService()
const modelId = configGet('sttModelId')
await stt.initialize(modelId)
if (this._isInTerminalState()) return
this._sttReady = true
this._setRecognitionState(RecognitionState.READY)
logger.info('STT ready')
this._tryFlushAll()
} catch (error) {
if (this._isInTerminalState()) return
this._handleError(
new D3ROError(
ErrorCode.STTModelLoadFailed,
`STT initialization failed: ${error instanceof Error ? error.message : String(error)}`
)
)
}
}
// ── 오디오 캡처 ────────────────────────────────────────
private async _startAudio(): Promise<void> {
try {
this._setAudioState(AudioState.INITIALIZING)
const audio = getAudioCaptureService()
// 오디오 데이터 수신
this._audioDataHandler = (payload) => {
if (this._isInTerminalState()) return
this._audioBuffer.push(payload.buffer)
this._audioBufferBytes += payload.buffer.length
// 버퍼 duration 업데이트 (16kHz, 16bit, mono)
const durationMs = (this._audioBufferBytes / 2 / 16000) * 1000
if (this._session) {
this._session.audioBufferDurationMs = durationMs
}
this._tryFlushAll()
}
this._audioLevelHandler = (payload) => {
this.emit('audio-level', { level: payload.level })
}
audio.on('audio-data', this._audioDataHandler)
audio.on('audio-level', this._audioLevelHandler)
await audio.start()
if (this._isInTerminalState()) return
this._audioStarted = true
this._setAudioState(AudioState.STREAMING)
logger.info('Audio capture started')
this._tryFlushAll()
} catch (error) {
if (this._isInTerminalState()) return
this._setAudioState(AudioState.STOPPED)
this._handleError(
new D3ROError(
ErrorCode.AudioCaptureStartFailed,
`Audio capture failed: ${error instanceof Error ? error.message : String(error)}`
)
)
}
}
private async _stopAudio(): Promise<void> {
this._setAudioState(AudioState.STOPPED)
const audio = getAudioCaptureService()
if (this._audioDataHandler) {
audio.off('audio-data', this._audioDataHandler)
this._audioDataHandler = null
}
if (this._audioLevelHandler) {
audio.off('audio-level', this._audioLevelHandler)
this._audioLevelHandler = null
}
try {
await audio.stop()
} catch (error) {
logger.warn(`Audio stop error: ${error instanceof Error ? error.message : String(error)}`)
}
}
// ── 이중 조건 플러시 ───────────────────────────────────
private _tryFlushAll(): void {
if (!this._sttReady || !this._audioStarted) return
if (this._audioBuffer.length === 0) return
if (this._isInTerminalState()) return
// 아직 녹음 중이면 flush 하지 않음 (stopSession에서 처리)
if (this._audioState === AudioState.STREAMING) return
this._transcribe()
}
// ── 전사 ───────────────────────────────────────────────
private async _transcribe(): Promise<void> {
if (this._audioBuffer.length === 0) return
if (this._isInTerminalState()) return
this._setRecognitionState(RecognitionState.RECOGNIZING)
const merged = Buffer.concat(this._audioBuffer)
this._audioBuffer = []
this._audioBufferBytes = 0
try {
const stt = getLocalSTTService()
const language = configGet('sttLanguage')
const result: TranscriptionResult = await stt.transcribe(merged, {
language: language === 'auto' ? undefined : language
})
if (this._isInTerminalState()) return
if (this._session) {
this._session.transcription = result.text
}
this.emit('transcription-update', { text: result.text, isFinal: true })
// Phase 2: LLM 후처리 없이 바로 완료
this._completeSession(result.text)
} catch (error) {
if (this._isInTerminalState()) return
this._handleError(
new D3ROError(
ErrorCode.STTTranscriptionFailed,
`Transcription failed: ${error instanceof Error ? error.message : String(error)}`
)
)
}
}
// ── 세션 완료/취소 ─────────────────────────────────────
private _completeSession(finalText: string): void {
if (!this._session) return
this._setRecognitionState(RecognitionState.COMPLETED)
this._setAudioState(AudioState.STOPPED)
const session = { ...this._session }
logger.info(`Session completed: "${finalText.substring(0, 50)}${finalText.length > 50 ? '...' : ''}"`)
this.emit('session-completed', { session, finalText })
// IDLE로 복귀
this._resetToIdle()
}
private _cancelSession(reason: 'user' | 'timeout' | 'too-short'): void {
if (!this._session) return
this._setRecognitionState(RecognitionState.CANCELLED)
this._setAudioState(AudioState.STOPPED)
const session = { ...this._session }
logger.info(`Session cancelled: ${reason}`)
// 오디오 정리
this._stopAudio()
this.emit('session-cancelled', { session, reason })
this._resetToIdle()
}
private _resetToIdle(): void {
this._session = null
this._audioBuffer = []
this._audioBufferBytes = 0
this._sttReady = false
this._audioStarted = false
this._errorEmitted = false
// 약간의 딜레이 후 IDLE로 전이 (UI 애니메이션용)
setTimeout(() => {
if (!this._session) {
this._setRecognitionState(RecognitionState.IDLE)
this._setAudioState(AudioState.IDLE)
}
}, 200)
}
// ── 에러 처리 ──────────────────────────────────────────
private _handleError(error: D3ROError): void {
if (this._errorEmitted) return
this._errorEmitted = true
logger.error(`VoiceMode error [${error.code}]: ${error.message}`)
this._setRecognitionState(RecognitionState.ERROR)
this._stopAudio()
this.emit('error', { error, session: this._session ? { ...this._session } : null })
this._resetToIdle()
}
// ── Action Queue (이벤트 직렬화) ───────────────────────
private _enqueueAction(action: VoiceAction): void {
this._actionQueue.push(action)
this._processQueue()
}
private async _processQueue(): Promise<void> {
if (this._isProcessingQueue) return
this._isProcessingQueue = true
try {
while (this._actionQueue.length > 0) {
const action = this._actionQueue.shift()!
await this._processAction(action)
}
} finally {
this._isProcessingQueue = false
}
}
private async _processAction(action: VoiceAction): Promise<void> {
try {
switch (action.type) {
case 'press':
await this._handlePress(action)
break
case 'release':
await this._handleRelease(action)
break
case 'escape':
this.cancelSession()
break
}
} catch (error) {
logger.error(`Action processing error: ${error instanceof Error ? error.message : String(error)}`)
}
}
private async _handlePress(action: VoiceAction): Promise<void> {
if (action.mode === 'dictation') {
// Dictation: hold-to-talk — press로 시작
if (!this.isActive) {
await this.startSession('dictation')
}
} else if (action.mode === 'hands-free') {
// HandsFree: toggle
if (this.isActive) {
await this.stopSession()
} else {
await this.startSession('hands-free')
}
}
}
private async _handleRelease(action: VoiceAction): Promise<void> {
if (action.mode === 'dictation' && this.isActive) {
// Dictation: hold-to-talk — release로 종료 (200ms 딜레이)
await new Promise<void>((resolve) => setTimeout(resolve, 200))
await this.stopSession()
}
// HandsFree: release 무시
}
// ── 유틸리티 ───────────────────────────────────────────
private _resolveMode(config: HotkeyConfig): VoiceMode {
if (config.id === 'voice-handsfree' || config.doublePressEnabled) {
return 'hands-free'
}
return 'dictation'
}
// ── 종료 ───────────────────────────────────────────────
dispose(): void {
this._disposed = true
this._actionQueue = []
// 진행 중 세션 취소
if (this._session && !this._isInTerminalState()) {
this._cancelSession('user')
}
// 핫키 리스너 해제
const hotkey = getHotkeyService()
if (this._hotkeyPressHandler) {
hotkey.off('hotkey-pressed', this._hotkeyPressHandler)
}
if (this._hotkeyReleaseHandler) {
hotkey.off('hotkey-released', this._hotkeyReleaseHandler)
}
if (this._doublePressHandler) {
hotkey.off('double-press', this._doublePressHandler)
}
// 오디오 리스너 해제
this._stopAudio()
this._setRecognitionState(RecognitionState.DESTROYED)
this.removeAllListeners()
logger.info('VoiceModeService disposed')
}
// ── EventEmitter 타입 오버라이드 ───────────────────────
override on<K extends keyof VoiceModeEvents>(
event: K,
listener: VoiceModeEvents[K]
): this {
return super.on(event, listener)
}
override off<K extends keyof VoiceModeEvents>(
event: K,
listener: VoiceModeEvents[K]
): this {
return super.off(event, listener)
}
override emit<K extends keyof VoiceModeEvents>(
event: K,
...args: Parameters<VoiceModeEvents[K]>
): boolean {
return super.emit(event, ...args)
}
}
// ── 싱글톤 ─────────────────────────────────────────────
let instance: VoiceModeService | null = null
export function getVoiceModeService(): VoiceModeService {
if (!instance) {
instance = new VoiceModeService()
}
return instance
}

View file

@ -0,0 +1,26 @@
// src/main/services/index.ts — 서비스 레지스트리
export { initLoggerService, getLogger } from './LoggerService'
export {
initConfigService,
getConfigService,
configGet,
configSet,
configGetAll,
configReset,
onConfigChanged
} from './ConfigService'
export {
getLocalSTTService,
LocalSTTService,
STTState
} from './LocalSTTService'
export type {
TranscriptionResult,
TranscriptionSegment,
TranscribeOptions,
LocalSTTEvents
} from './LocalSTTService'
export { getHotkeyService } from './HotkeyService'
export { getVoiceModeService } from './VoiceModeService'
export { getAudioCaptureService } from './AudioCaptureService'

33
src/main/types/node-record-lpcm16.d.ts vendored Normal file
View file

@ -0,0 +1,33 @@
// Type declarations for node-record-lpcm16 (no @types package available)
declare module 'node-record-lpcm16' {
import { ChildProcess } from 'child_process'
import { Readable } from 'stream'
interface RecordingOptions {
sampleRate?: number
channels?: number
compress?: boolean
threshold?: number
thresholdStart?: number | null
thresholdEnd?: number | null
silence?: string
recorder?: 'sox' | 'rec' | 'arecord'
endOnSilence?: boolean
audioType?: string
device?: string
}
interface Recording {
process: ChildProcess
stop(): void
pause(): void
resume(): void
isPaused(): boolean
stream(): Readable
}
function record(options?: RecordingOptions): Recording
export { record, Recording, RecordingOptions }
}

View file

@ -0,0 +1,55 @@
// src/main/windows/TrayManager.ts
import { Tray, Menu, app, nativeImage } from 'electron'
import { join } from 'path'
import { getMainWindow } from './WindowManager'
import { getLogger } from '../services/LoggerService'
import { setIsQuitting } from '../lifecycle'
const logger = getLogger('TrayManager')
let tray: Tray | null = null
export function createTray(): void {
// 16x16 빈 아이콘 생성 (리소스 아이콘이 없을 때 폴백)
const icon = nativeImage.createEmpty()
tray = new Tray(icon)
const contextMenu = Menu.buildFromTemplate([
{
label: '표시',
click: () => {
const mainWindow = getMainWindow()
if (mainWindow) {
mainWindow.show()
mainWindow.focus()
}
}
},
{ type: 'separator' },
{
label: '종료',
click: () => {
setIsQuitting(true)
app.quit()
}
}
])
tray.setToolTip('D3RO Voice')
tray.setContextMenu(contextMenu)
tray.on('double-click', () => {
const mainWindow = getMainWindow()
if (mainWindow) {
mainWindow.show()
mainWindow.focus()
}
})
logger.info('Tray created')
}
export function getTray(): Tray | null {
return tray
}

View file

@ -0,0 +1,66 @@
// src/main/windows/WindowManager.ts
import { BrowserWindow, shell } from 'electron'
import { join } from 'path'
import { is } from '@electron-toolkit/utils'
import { WINDOW_SIZE } from '@shared/constants'
import { getLogger } from '../services/LoggerService'
import { getIsQuitting, setIsQuitting } from '../lifecycle'
import { configGet } from '../services/ConfigService'
const logger = getLogger('WindowManager')
let mainWindow: BrowserWindow | null = null
export function getMainWindow(): BrowserWindow | null {
return mainWindow
}
export function createMainWindow(): BrowserWindow {
mainWindow = new BrowserWindow({
width: WINDOW_SIZE.MAIN.width,
height: WINDOW_SIZE.MAIN.height,
minWidth: 800,
minHeight: 600,
show: false,
autoHideMenuBar: true,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
contextIsolation: true,
nodeIntegration: false
}
})
mainWindow.on('ready-to-show', () => {
mainWindow?.show()
logger.info('Main window shown')
})
mainWindow.on('close', (event) => {
if (!getIsQuitting() && configGet('closeToTray')) {
event.preventDefault()
mainWindow?.hide()
logger.info('Main window hidden to tray')
}
})
mainWindow.on('closed', () => {
mainWindow = null
})
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
// 개발/프로덕션 URL 로드
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
logger.info('Main window created')
return mainWindow
}

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

26
src/renderer/App.tsx Normal file
View file

@ -0,0 +1,26 @@
// src/renderer/App.tsx — 루트 컴포넌트
import { useState, useMemo } from 'react'
import { ThemeProvider, CssBaseline, useMediaQuery } from '@mui/material'
import { lightTheme, darkTheme } from './theme'
import { AppLayout } from './components/AppLayout'
import type { ThemeMode } from '@shared/types'
export function App(): React.ReactElement {
const [themeMode] = useState<ThemeMode>('auto')
const prefersDark = useMediaQuery('(prefers-color-scheme: dark)')
const theme = useMemo(() => {
if (themeMode === 'auto') {
return prefersDark ? darkTheme : lightTheme
}
return themeMode === 'dark' ? darkTheme : lightTheme
}, [themeMode, prefersDark])
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<AppLayout />
</ThemeProvider>
)
}

View file

@ -0,0 +1,110 @@
// src/renderer/components/AppLayout.tsx
import { useState } from 'react'
import {
Box,
Drawer,
List,
ListItemButton,
ListItemIcon,
ListItemText,
Divider,
Typography,
Chip
} from '@mui/material'
import DashboardIcon from '@mui/icons-material/Dashboard'
import HistoryIcon from '@mui/icons-material/History'
import MenuBookIcon from '@mui/icons-material/MenuBook'
import SettingsIcon from '@mui/icons-material/Settings'
import { DashboardPage } from '../pages/DashboardPage'
type Route = 'dashboard' | 'history' | 'dictionary'
const DRAWER_WIDTH = 240
const NAV_ITEMS: Array<{ route: Route; label: string; icon: React.ReactElement }> = [
{ route: 'dashboard', label: 'Dashboard', icon: <DashboardIcon /> },
{ route: 'history', label: 'History', icon: <HistoryIcon /> },
{ route: 'dictionary', label: 'Dictionary', icon: <MenuBookIcon /> }
]
export function AppLayout(): React.ReactElement {
const [currentRoute, setCurrentRoute] = useState<Route>('dashboard')
return (
<Box sx={{ display: 'flex', height: '100vh' }}>
{/* Sidebar Drawer */}
<Drawer
variant="permanent"
sx={{
width: DRAWER_WIDTH,
flexShrink: 0,
'& .MuiDrawer-paper': {
width: DRAWER_WIDTH,
boxSizing: 'border-box'
}
}}
>
{/* Header */}
<Box sx={{ p: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="h6" noWrap sx={{ fontWeight: 700 }}>
D3RO Voice
</Typography>
<Chip label="v1.0" size="small" variant="outlined" />
</Box>
<Divider />
{/* Navigation */}
<List sx={{ flex: 1, pt: 1 }}>
{NAV_ITEMS.map((item) => (
<ListItemButton
key={item.route}
selected={currentRoute === item.route}
onClick={() => setCurrentRoute(item.route)}
sx={{ my: 0.5 }}
>
<ListItemIcon sx={{ minWidth: 40 }}>{item.icon}</ListItemIcon>
<ListItemText primary={item.label} />
</ListItemButton>
))}
</List>
<Divider />
{/* Bottom */}
<List>
<ListItemButton sx={{ my: 0.5 }}>
<ListItemIcon sx={{ minWidth: 40 }}>
<SettingsIcon />
</ListItemIcon>
<ListItemText primary="Settings" />
</ListItemButton>
</List>
</Drawer>
{/* Content Area */}
<Box
component="main"
sx={{
flexGrow: 1,
p: 3,
overflow: 'auto',
bgcolor: 'background.default'
}}
>
{currentRoute === 'dashboard' && <DashboardPage />}
{currentRoute === 'history' && (
<Typography variant="h5" color="text.secondary">
History (Phase 5)
</Typography>
)}
{currentRoute === 'dictionary' && (
<Typography variant="h5" color="text.secondary">
Dictionary (Phase 5)
</Typography>
)}
</Box>
</Box>
)
}

8
src/renderer/electron.d.ts vendored Normal file
View file

@ -0,0 +1,8 @@
// src/renderer/electron.d.ts
import type { ElectronAPI } from '../preload/index'
declare global {
interface Window {
electronAPI: ElectronAPI
}
}

12
src/renderer/index.html Normal file
View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>D3RO Voice</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

14
src/renderer/main.tsx Normal file
View file

@ -0,0 +1,14 @@
// src/renderer/main.tsx — 렌더러 진입점
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { App } from './App'
const root = document.getElementById('root')
if (root) {
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>
)
}

View file

@ -0,0 +1,67 @@
// src/renderer/pages/DashboardPage.tsx
import { Box, Card, CardContent, Typography, Grid } from '@mui/material'
import MicIcon from '@mui/icons-material/Mic'
import TimerIcon from '@mui/icons-material/Timer'
import TextFieldsIcon from '@mui/icons-material/TextFields'
import TodayIcon from '@mui/icons-material/Today'
interface StatCardProps {
title: string
value: string
icon: React.ReactElement
}
function StatCard({ title, value, icon }: StatCardProps): React.ReactElement {
return (
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Box sx={{ color: 'primary.main' }}>{icon}</Box>
<Typography variant="body2" color="text.secondary">
{title}
</Typography>
</Box>
<Typography variant="h4">{value}</Typography>
</CardContent>
</Card>
)
}
export function DashboardPage(): React.ReactElement {
return (
<Box>
<Typography variant="h5" sx={{ mb: 3, fontWeight: 600 }}>
Dashboard
</Typography>
<Grid container spacing={2}>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<StatCard title="Total Sessions" value="0" icon={<MicIcon />} />
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<StatCard title="Total Time" value="0:00" icon={<TimerIcon />} />
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<StatCard title="Total Words" value="0" icon={<TextFieldsIcon />} />
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<StatCard title="Streak" value="0 days" icon={<TodayIcon />} />
</Grid>
</Grid>
<Box sx={{ mt: 4 }}>
<Typography variant="h6" sx={{ mb: 2 }}>
Recent Sessions
</Typography>
<Card>
<CardContent>
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', py: 4 }}>
No sessions yet. Press the hotkey to start recording.
</Typography>
</CardContent>
</Card>
</Box>
</Box>
)
}

148
src/renderer/theme.ts Normal file
View file

@ -0,0 +1,148 @@
// src/renderer/theme.ts — MUI 7 테마 정의 (설계서 03 기반)
import { createTheme, type ThemeOptions } from '@mui/material/styles'
const commonOptions: ThemeOptions = {
typography: {
fontFamily: [
'-apple-system',
'BlinkMacSystemFont',
'"Segoe UI"',
'Roboto',
'"Helvetica Neue"',
'Arial',
'sans-serif'
].join(','),
h4: { fontWeight: 600, fontSize: '1.5rem' },
h5: { fontWeight: 600, fontSize: '1.25rem' },
h6: { fontWeight: 600, fontSize: '1rem' },
subtitle1: { fontWeight: 500 },
body1: { fontSize: '0.9375rem' },
body2: { fontSize: '0.8125rem' },
button: { textTransform: 'none' as const, fontWeight: 500 }
},
shape: {
borderRadius: 12
},
components: {
MuiButton: {
defaultProps: {
disableElevation: true
},
styleOverrides: {
root: {
textTransform: 'none',
fontWeight: 500,
borderRadius: 8,
padding: '8px 16px'
}
}
},
MuiCard: {
defaultProps: {
elevation: 0
},
styleOverrides: {
root: {
borderRadius: 12,
border: '1px solid'
}
}
},
MuiDrawer: {
styleOverrides: {
paper: {
width: 240,
borderRight: 'none'
}
}
},
MuiListItemButton: {
styleOverrides: {
root: {
borderRadius: 8,
marginLeft: 8,
marginRight: 8
}
}
},
MuiTextField: {
defaultProps: {
size: 'small',
variant: 'outlined'
}
},
MuiChip: {
styleOverrides: {
root: {
borderRadius: 6,
fontWeight: 500
}
}
}
}
}
export const lightTheme = createTheme({
...commonOptions,
palette: {
mode: 'light',
primary: {
main: 'rgb(31, 93, 242)',
light: 'rgb(71, 133, 255)',
dark: 'rgb(20, 65, 180)',
contrastText: '#FFFFFF'
},
secondary: {
main: 'rgb(108, 117, 125)',
light: 'rgb(173, 181, 189)',
dark: 'rgb(73, 80, 87)'
},
background: {
default: '#F9F9F9',
paper: '#FFFFFF'
},
text: {
primary: 'rgba(0, 0, 0, 0.87)',
secondary: 'rgba(0, 0, 0, 0.6)'
},
divider: 'rgba(0, 0, 0, 0.08)',
error: { main: '#D32F2F' },
success: { main: '#2E7D32' },
warning: { main: '#ED6C02' }
}
})
export const darkTheme = createTheme({
...commonOptions,
palette: {
mode: 'dark',
primary: {
main: 'rgb(71, 133, 255)',
light: 'rgb(120, 170, 255)',
dark: 'rgb(31, 93, 242)',
contrastText: '#FFFFFF'
},
secondary: {
main: 'rgb(173, 181, 189)',
light: 'rgb(206, 212, 218)',
dark: 'rgb(108, 117, 125)'
},
background: {
default: '#121212',
paper: '#1E1E1E'
},
text: {
primary: 'rgba(255, 255, 255, 0.87)',
secondary: 'rgba(255, 255, 255, 0.6)'
},
divider: 'rgba(255, 255, 255, 0.08)',
error: { main: '#EF5350' },
success: { main: '#4CAF50' },
warning: { main: '#FFA726' }
}
})
export function getTheme(mode: 'light' | 'dark') {
return mode === 'dark' ? darkTheme : lightTheme
}

58
src/shared/constants.ts Normal file
View file

@ -0,0 +1,58 @@
// src/shared/constants.ts
/** 타이밍 상수 (Speakly 리버스엔지니어링 기반) */
export const TIMING = {
/** 더블프레스 감지 간격 (ms) */
DOUBLE_PRESS_DURATION: 300,
/** 최소 녹음 시간 (ms) — 이하 자동 취소 */
MIN_AUDIO_DURATION: 700,
/** 녹음 후 STT 대기 시간 (ms) */
POST_RECORDING_WAIT: 4000,
/** 녹음 후 STT 대기 (버퍼 있을 때) (ms) */
POST_RECORDING_WAIT_BUFFERED: 6000,
/** STT 아이들 타임아웃 (ms) */
STT_IDLE_TIMEOUT: 30000,
/** 절대 최대 대기 시간 (ms) */
ABSOLUTE_MAX_WAIT: 120000,
/** 오디오 레벨 전송 간격 (ms) */
AUDIO_LEVEL_INTERVAL: 100,
/** 서비스 종료 타임아웃 (ms) */
SERVICE_DESTROY_TIMEOUT: 3000,
/** 사이드카 헬스체크 지연 (ms) */
SIDECAR_HEALTH_DELAY: 3000,
/** LLM 요청 타임아웃 (ms) */
LLM_REQUEST_TIMEOUT: 30000
} as const
/** 웨이브 바 상수 (RecordingTip) */
export const WAVE_BAR = {
COUNT: 9,
ANIMATION_INTERVAL: 100,
/** 코사인 분포 가중치 (Speakly 패턴) */
COS_WEIGHTS: Array.from({ length: 9 }, (_, i) => Math.cos((i - 4) * (Math.PI / 9)))
} as const
/** 오디오 포맷 */
export const AUDIO_FORMAT = {
SAMPLE_RATE: 16000,
CHANNELS: 1,
BIT_DEPTH: 16,
BYTES_PER_SAMPLE: 2
} as const
/** 윈도우 크기 */
export const WINDOW_SIZE = {
MAIN: { width: 1104, height: 816 },
RECORDING_TIP: { width: 280, height: 80 },
RESULT_POPUP: { width: 400, height: 200 }
} as const

162
src/shared/errors.ts Normal file
View file

@ -0,0 +1,162 @@
// 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 )
*/
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 .
*/
export type IPCResult<T> =
| { success: true; data: T }
| { success: false; error: D3ROErrorJSON }
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 } }
}

172
src/shared/ipc-channels.ts Normal file
View file

@ -0,0 +1,172 @@
// 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>

658
src/shared/types.ts Normal file
View file

@ -0,0 +1,658 @@
// 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
recordingStartedAt: number | null
}
export interface StartRecordingParams {
sessionId?: string
deviceId?: string
}
export interface StartRecordingResult {
sessionId: string
}
export interface StopRecordingParams {
sessionId: string
}
export interface StopRecordingResult {
sessionId: string
text: string
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 {
level: number
}
// ============================================================
// Audio (디바이스 & 캡처)
// ============================================================
export interface AudioDevice {
deviceId: string
label: string
isDefault: boolean
}
export interface SetDeviceParams {
deviceId: string
}
export interface TestDeviceParams {
deviceId: string
durationMs?: number
}
export interface TestDeviceResult {
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
engineVersion: string | null
gpuAccelerated: boolean
}
export interface STTModel {
id: string
name: string
sizeBytes: number
downloaded: boolean
languages: string[]
accuracy: number
speed: number
}
export interface SetSTTModelParams {
modelId: string
}
export interface DownloadModelParams {
modelId: string
}
export interface SetSTTLanguageParams {
language: string
}
export interface STTStatusChangedEvent {
status: STTStatus
}
export interface DownloadProgressEvent {
modelId: string
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
voiceId?: string
speed?: number
}
export interface TTSSpeakResult {
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
serverVersion: string | null
}
export interface LLMModel {
id: string
name: string
sizeBytes: number
parameterSize: string
quantization: string
modifiedAt: string
}
export type LLMAction = 'refine' | 'translate' | 'summarize' | 'expand' | 'grammar' | 'custom'
export interface LLMProcessParams {
text: string
action: LLMAction
targetLanguage?: string
customPrompt?: string
modelId?: string
}
export interface LLMProcessResult {
originalText: string
processedText: string
action: LLMAction
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
percent: number
downloadedBytes: number
totalBytes: number
}
// ============================================================
// Hotkey (핫키)
// ============================================================
export interface HotkeyBinding {
keyCode: number
ctrl: boolean
alt: boolean
shift: boolean
meta: boolean
displayLabel: string
}
export interface SetHotkeyParams {
binding: HotkeyBinding
}
export interface SetEnabledParams {
enabled: boolean
}
export type HotkeyAction = 'dictation' | 'hands-free' | 'command'
export interface HotkeyTriggeredEvent {
action: HotkeyAction
type: 'pressed' | 'released'
isDoublePress: boolean
}
export interface HotkeyRecordingResultEvent {
binding: HotkeyBinding | null
conflictReason: string | null
}
// ============================================================
// Config (설정)
// ============================================================
export interface AppConfig {
theme: ThemeMode
language: string
closeToTray: boolean
autoLaunch: boolean
soundEnabled: boolean
selectedDeviceId: string | null
sttModelId: string
sttLanguage: string
ttsVoiceId: string | null
ttsSpeed: number
ollamaServerUrl: string
llmModelId: string | null
defaultLLMAction: LLMAction
dictationShortcut: HotkeyBinding
handsFreeShortcut: HotkeyBinding
commandShortcut: HotkeyBinding
hotkeyEnabled: boolean
insertMethod: 'clipboard' | 'keyboard'
autoInsert: boolean
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
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
detectedLanguage: string | null
micDevice: string | null
wordCount: number
sttModel: string | null
llmModel: string | null
sttLatencyMs: number | null
llmLatencyMs: number | null
createdAt: number
updatedAt: number
appVersion: string
}
export interface HistoryQueryParams {
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'
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
text?: string
errorMessage?: string
}
export interface ShowResultPopupParams {
text: string
autoHideMs?: number
}
export interface TipMeasuredParams {
width: number
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
pid: number
}
export interface ShowNotificationParams {
title: string
body: string
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 {
totalRecordingTimeMs: number
totalWordCount: number
totalSessionCount: number
todayRecordingTimeMs: number
todayWordCount: number
todaySessionCount: number
streakDays: number
}
export interface StatsQueryParams {
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
}