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:
parent
e24bb8378c
commit
1d152d01a1
46 changed files with 10828 additions and 4 deletions
78
src/main/bootstrap.ts
Normal file
78
src/main/bootstrap.ts
Normal 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
34
src/main/index.ts
Normal 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에서 처리
|
||||
}
|
||||
})
|
||||
}
|
||||
37
src/main/ipc/audio-handlers.ts
Normal file
37
src/main/ipc/audio-handlers.ts
Normal 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 })
|
||||
})
|
||||
}
|
||||
72
src/main/ipc/config-handlers.ts
Normal file
72
src/main/ipc/config-handlers.ts
Normal 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'))
|
||||
})
|
||||
}
|
||||
67
src/main/ipc/hotkey-handlers.ts
Normal file
67
src/main/ipc/hotkey-handlers.ts
Normal 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
23
src/main/ipc/index.ts
Normal 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')
|
||||
}
|
||||
52
src/main/ipc/stt-handlers.ts
Normal file
52
src/main/ipc/stt-handlers.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
28
src/main/ipc/system-handlers.ts
Normal file
28
src/main/ipc/system-handlers.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
53
src/main/ipc/voice-handlers.ts
Normal file
53
src/main/ipc/voice-handlers.ts
Normal 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')
|
||||
})
|
||||
}
|
||||
31
src/main/ipc/window-handlers.ts
Normal file
31
src/main/ipc/window-handlers.ts
Normal 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
37
src/main/lifecycle.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
365
src/main/services/AudioCaptureService.ts
Normal file
365
src/main/services/AudioCaptureService.ts
Normal 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
|
||||
}
|
||||
119
src/main/services/ConfigService.ts
Normal file
119
src/main/services/ConfigService.ts
Normal 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)
|
||||
}
|
||||
536
src/main/services/HotkeyService.ts
Normal file
536
src/main/services/HotkeyService.ts
Normal 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
|
||||
}
|
||||
729
src/main/services/LocalSTTService.ts
Normal file
729
src/main/services/LocalSTTService.ts
Normal 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 }
|
||||
32
src/main/services/LoggerService.ts
Normal file
32
src/main/services/LoggerService.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
625
src/main/services/VoiceModeService.ts
Normal file
625
src/main/services/VoiceModeService.ts
Normal 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
|
||||
}
|
||||
26
src/main/services/index.ts
Normal file
26
src/main/services/index.ts
Normal 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
33
src/main/types/node-record-lpcm16.d.ts
vendored
Normal 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 }
|
||||
}
|
||||
55
src/main/windows/TrayManager.ts
Normal file
55
src/main/windows/TrayManager.ts
Normal 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
|
||||
}
|
||||
66
src/main/windows/WindowManager.ts
Normal file
66
src/main/windows/WindowManager.ts
Normal 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
164
src/preload/index.ts
Normal 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
26
src/renderer/App.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
110
src/renderer/components/AppLayout.tsx
Normal file
110
src/renderer/components/AppLayout.tsx
Normal 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
8
src/renderer/electron.d.ts
vendored
Normal 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
12
src/renderer/index.html
Normal 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
14
src/renderer/main.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
67
src/renderer/pages/DashboardPage.tsx
Normal file
67
src/renderer/pages/DashboardPage.tsx
Normal 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
148
src/renderer/theme.ts
Normal 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
58
src/shared/constants.ts
Normal 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
162
src/shared/errors.ts
Normal 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
172
src/shared/ipc-channels.ts
Normal 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
658
src/shared/types.ts
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue