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

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

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

View file

@ -28,6 +28,7 @@ allowed-tools: Read Write Edit Bash Glob Grep Agent
- 프리로드 브릿지 업데이트
6. **테스트**: 각 모듈 단위 테스트
7. **CLAUDE.md 업데이트**: 현재 상태 갱신
8. **Memory 갱신** (필수): `project_status.md`에 완료된 Phase, 작업 내역, 기술 결정 기록
## 구현 규칙 (위반 불가)
- 서비스는 **설계서 01**의 인터페이스를 그대로 구현 (메서드 시그니처, 이벤트 페이로드 일치)
@ -43,3 +44,4 @@ allowed-tools: Read Write Edit Bash Glob Grep Agent
- `npm run typecheck` 통과
- `npm run test` 통과
- CLAUDE.md "현재 상태" 업데이트됨
- Memory `project_status.md` 갱신됨 (Phase 완료 내역, 기술 결정, 다음 작업)

25
.eslintrc.cjs Normal file
View file

@ -0,0 +1,25 @@
module.exports = {
root: true,
env: {
browser: true,
node: true,
es2022: true
},
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'prettier'
],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module'
},
plugins: ['@typescript-eslint'],
rules: {
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'no-console': 'error'
}
}

12
.gitignore vendored
View file

@ -7,3 +7,15 @@ out/
.DS_Store
Thumbs.db
.claude/settings.local.json
# Electron
release/
*.unpacked
# Build
*.tsbuildinfo
# IDE
.idea/
*.swp
*.swo

7
.prettierrc Normal file
View file

@ -0,0 +1,7 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"tabWidth": 2
}

View file

@ -77,10 +77,28 @@ npm run typecheck # tsc --noEmit
6. **녹음 UI**: 9개 웨이브 바, cos 분포 가중치, 100ms 애니메이션
## 현재 상태
Phase: 0 (하네스 설정 완료, 구현 시작 전)
마지막 완료: 하네스 시스템 구축
다음 작업: Phase 1 — 프로젝트 초기화 + Electron 뼈대
차단 이슈: 없음
Phase: 2 완료
마지막 완료: Phase 2 — 로컬 STT (faster-whisper sidecar) + 핫키 (uiohook-napi) + VoiceModeService 오케스트레이터
다음 작업: Phase 3 — 텍스트 삽입 + 기본 UI (RecordingTip, ResultPopup)
차단 이슈: SoX 미설치 시 AudioCaptureService 동작 불가 (수동 설치 필요)
### Phase 2 구현 내용
- AudioCaptureService: node-record-lpcm16 + SoX 실제 마이크 캡처 (PCM16 16kHz mono, 60ms 프레임, RMS 레벨)
- HotkeyService: uiohook-napi 글로벌 키후킹, 더블프레스(300ms), holdMode/toggleMode, VK→uiohook 매핑
- LocalSTTService: faster-whisper Python sidecar 관리, STTState 상태머신, 이중 조건 플러시, 자동 재시작(3회)
- VoiceModeService: RecognitionState(9상태) + AudioState(4상태) 이중 상태머신, Action Queue 직렬화, accidentalPress(<700ms), Dictation/HandsFree 모드
- Python sidecar: FastAPI (GET /health, POST /load, POST /transcribe, POST /shutdown)
- IPC 핸들러: voice, stt, hotkey 추가 (기존 audio, config, window, system에 추가)
- Preload: voice/stt/hotkey API 전체 노출
- Bootstrap: 7단계 초기화 (logger→config→windows→tray→ipc→hotkey→voice-mode)
### Phase 1 구현 내용
- 프로젝트 초기화: package.json, TypeScript strict, electron-vite, ESLint, Prettier
- shared 타입: ipc-channels.ts (113채널), types.ts, errors.ts (ErrorCode enum), constants.ts
- 메인 프로세스: index.ts (단일 인스턴스), bootstrap.ts, lifecycle.ts
- 서비스: LoggerService, ConfigService (ESM dynamic import)
- Renderer: React 19 + MUI 7, AppLayout (Drawer 240px), DashboardPage
- 시스템 트레이, closeToTray
## 페이즈 로드맵
- Phase 1: 프로젝트 초기화 + Electron 뼈대 + 마이크 캡처

30
electron.vite.config.ts Normal file
View file

@ -0,0 +1,30 @@
import { resolve } from 'path'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
resolve: {
alias: {
'@shared': resolve('src/shared')
}
}
},
preload: {
plugins: [externalizeDepsPlugin()],
resolve: {
alias: {
'@shared': resolve('src/shared')
}
}
},
renderer: {
resolve: {
alias: {
'@shared': resolve('src/shared')
}
},
plugins: [react()]
}
})

5590
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

50
package.json Normal file
View file

@ -0,0 +1,50 @@
{
"name": "d3ro-voice",
"version": "1.0.0",
"description": "로컬 AI 음성 어시스턴트",
"main": "./out/main/index.js",
"scripts": {
"dev": "electron-vite dev",
"build": "electron-vite build",
"preview": "electron-vite preview",
"typecheck": "tsc --noEmit",
"lint": "eslint . --ext .ts,.tsx",
"format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
"test": "vitest run",
"test:watch": "vitest"
},
"author": "",
"license": "MIT",
"devDependencies": {
"@electron-toolkit/tsconfig": "^1.0.1",
"@types/node": "^22.13.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"@vitejs/plugin-react": "^4.3.0",
"electron": "^33.3.0",
"electron-vite": "^2.3.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"prettier": "^3.3.0",
"typescript": "^5.7.0",
"vite": "^5.4.0",
"vitest": "^2.1.0"
},
"dependencies": {
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@mui/icons-material": "^7.0.0",
"@mui/material": "^7.0.0",
"@rollup/rollup-win32-x64-msvc": "^4.60.1",
"electron-log": "^5.2.0",
"electron-store": "^10.0.0",
"node-record-lpcm16": "^1.0.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"uiohook-napi": "^1.5.5"
}
}

332
sidecar/main.py Normal file
View file

@ -0,0 +1,332 @@
"""
D3RO-VOICE STT Sidecar (FastAPI HTTP 서버)
faster-whisper를 사용한 로컬 음성 인식 서비스.
사용법:
python main.py --port 18765
엔드포인트:
GET /health - 헬스체크
POST /load - Whisper 모델 로딩
POST /transcribe - 오디오 전사 (multipart)
POST /shutdown - 서버 종료
"""
from __future__ import annotations
import argparse
import logging
import os
import signal
import sys
import time
from contextlib import asynccontextmanager
from typing import AsyncGenerator
import numpy as np
import uvicorn
from fastapi import FastAPI, File, Form, UploadFile
from fastapi.responses import JSONResponse
# ── 로깅 설정 ──────────────────────────────────────────────
logging.basicConfig(
level=logging.INFO,
format="[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
stream=sys.stdout,
)
logger = logging.getLogger("sidecar")
# ── 전역 상태 ──────────────────────────────────────────────
_model: "WhisperModel | None" = None
_model_id: str | None = None
_gpu_available: bool = False
_server: uvicorn.Server | None = None
# ── FastAPI 앱 ─────────────────────────────────────────────
@asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
"""서버 시작/종료 생명주기."""
logger.info("Sidecar 서버 시작")
_detect_gpu()
yield
logger.info("Sidecar 서버 종료")
app = FastAPI(title="D3RO-VOICE STT Sidecar", lifespan=lifespan)
def _detect_gpu() -> None:
"""GPU(CUDA) 사용 가능 여부를 감지한다."""
global _gpu_available
try:
import torch
_gpu_available = torch.cuda.is_available()
if _gpu_available:
device_name = torch.cuda.get_device_name(0)
logger.info("GPU 감지: %s", device_name)
else:
logger.info("GPU 미감지, CPU 모드로 동작")
except ImportError:
_gpu_available = False
logger.info("PyTorch 미설치, CPU 모드로 동작")
# ── 엔드포인트 ─────────────────────────────────────────────
@app.get("/health")
async def health() -> JSONResponse:
"""헬스체크. sidecar가 준비되었는지 확인한다."""
return JSONResponse(
content={
"status": "ready" if _model is not None else "no_model",
"model": _model_id,
"gpu": _gpu_available,
}
)
@app.post("/load")
async def load_model(body: dict) -> JSONResponse: # noqa: ANN001
"""Whisper 모델을 로딩한다.
Request body:
{ "model_id": "base" } -- tiny, base, small, medium, large-v3
Returns:
{ "status": "loaded", "model_id": "base", "load_time_ms": 1234 }
"""
global _model, _model_id
model_id: str = body.get("model_id", "base")
logger.info("모델 로딩 시작: %s", model_id)
start_time = time.monotonic()
try:
from faster_whisper import WhisperModel
device = "cuda" if _gpu_available else "cpu"
compute_type = "float16" if _gpu_available else "int8"
# 모델 크기별 compute_type 조정
if model_id in ("large-v3", "medium") and not _gpu_available:
compute_type = "int8"
_model = WhisperModel(
model_id,
device=device,
compute_type=compute_type,
)
_model_id = model_id
load_time_ms = int((time.monotonic() - start_time) * 1000)
logger.info(
"모델 로딩 완료: %s (device=%s, compute=%s, %dms)",
model_id,
device,
compute_type,
load_time_ms,
)
return JSONResponse(
content={
"status": "loaded",
"model_id": model_id,
"load_time_ms": load_time_ms,
}
)
except Exception as exc:
logger.error("모델 로딩 실패: %s", exc)
return JSONResponse(
status_code=500,
content={"status": "error", "message": str(exc)},
)
@app.post("/transcribe")
async def transcribe(
audio: UploadFile = File(...),
language: str = Form("auto"),
vad_filter: str = Form("true"),
initial_prompt: str = Form(""),
) -> JSONResponse:
"""오디오 파일을 전사한다.
Multipart form:
audio - PCM16 16kHz mono 바이너리 파일
language - 언어 코드 ('auto', 'ko', 'en', ...)
vad_filter - VAD 필터 활성화 ('true' / 'false')
initial_prompt - 초기 프롬프트 (컨텍스트 힌트)
Returns:
{
"text": "전사된 텍스트",
"segments": [...],
"language": "ko",
"duration": 3.5,
"processing_time": 1234
}
"""
if _model is None:
return JSONResponse(
status_code=503,
content={"status": "error", "message": "모델이 로딩되지 않았습니다"},
)
start_time = time.monotonic()
try:
# PCM16 바이너리 읽기
pcm_bytes = await audio.read()
if len(pcm_bytes) == 0:
return JSONResponse(
status_code=400,
content={"status": "error", "message": "오디오 데이터가 비어있습니다"},
)
# PCM16 → float32 변환 (-1.0 ~ 1.0)
audio_array = (
np.frombuffer(pcm_bytes, dtype=np.int16).astype(np.float32) / 32768.0
)
# 오디오 길이 계산 (16kHz mono 기준)
sample_rate = 16000
audio_duration = len(audio_array) / sample_rate
logger.info(
"전사 시작: %.1f초 오디오, language=%s, vad=%s",
audio_duration,
language,
vad_filter,
)
# 전사 옵션 구성
transcribe_kwargs: dict = {
"vad_filter": vad_filter.lower() == "true",
"beam_size": 5,
}
if language != "auto":
transcribe_kwargs["language"] = language
if initial_prompt:
transcribe_kwargs["initial_prompt"] = initial_prompt
# faster-whisper 전사 실행
segments_iter, info = _model.transcribe(audio_array, **transcribe_kwargs)
# 세그먼트 수집
segments_list: list[dict] = []
full_text_parts: list[str] = []
for segment in segments_iter:
seg_dict = {
"text": segment.text.strip(),
"start": round(segment.start, 3),
"end": round(segment.end, 3),
"avg_logprob": round(segment.avg_logprob, 4),
}
segments_list.append(seg_dict)
full_text_parts.append(segment.text.strip())
full_text = " ".join(full_text_parts).strip()
processing_time = int((time.monotonic() - start_time) * 1000)
detected_language = info.language if info.language else "unknown"
logger.info(
"전사 완료: '%s' (lang=%s, %.1f초, %dms)",
full_text[:80],
detected_language,
audio_duration,
processing_time,
)
return JSONResponse(
content={
"text": full_text,
"segments": segments_list,
"language": detected_language,
"duration": round(audio_duration, 3),
"processing_time": processing_time,
}
)
except Exception as exc:
logger.error("전사 실패: %s", exc, exc_info=True)
return JSONResponse(
status_code=500,
content={"status": "error", "message": str(exc)},
)
@app.post("/shutdown")
async def shutdown() -> JSONResponse:
"""서버를 graceful하게 종료한다."""
logger.info("종료 요청 수신")
if _server is not None:
_server.should_exit = True
return JSONResponse(content={"status": "shutting_down"})
# ── 메인 ───────────────────────────────────────────────────
def main() -> None:
"""CLI 진입점."""
global _server
parser = argparse.ArgumentParser(description="D3RO-VOICE STT Sidecar")
parser.add_argument(
"--port",
type=int,
default=18765,
help="HTTP 서버 포트 (기본: 18765)",
)
parser.add_argument(
"--host",
type=str,
default="127.0.0.1",
help="HTTP 서버 호스트 (기본: 127.0.0.1)",
)
args = parser.parse_args()
logger.info("D3RO-VOICE STT Sidecar 시작 (port=%d)", args.port)
# SIGINT/SIGTERM 핸들러
def signal_handler(signum: int, _frame: object) -> None:
sig_name = signal.Signals(signum).name
logger.info("시그널 수신: %s, 종료 시작", sig_name)
if _server is not None:
_server.should_exit = True
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
# uvicorn 서버 구성 및 시작
config = uvicorn.Config(
app=app,
host=args.host,
port=args.port,
log_level="warning", # uvicorn 자체 로그는 최소화 (우리 로거 사용)
access_log=False,
)
_server = uvicorn.Server(config)
_server.run()
logger.info("Sidecar 서버 종료 완료")
if __name__ == "__main__":
main()

5
sidecar/requirements.txt Normal file
View file

@ -0,0 +1,5 @@
faster-whisper>=1.0.0
fastapi>=0.109.0
uvicorn>=0.27.0
python-multipart>=0.0.6
numpy>=1.24.0

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

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

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

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

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

@ -0,0 +1,164 @@
// src/preload/index.ts
// contextBridge로 렌더러에 노출할 API 정의
import { contextBridge, ipcRenderer } from 'electron'
import { IPC_CHANNELS } from '@shared/ipc-channels'
import type {
AudioDevice,
SetDeviceParams,
TestDeviceParams,
TestDeviceResult,
AudioDeviceChangedEvent,
AppConfig,
ConfigGetParams,
ConfigSetParams,
ConfigResetParams,
SetThemeParams,
SetLanguageParams,
ThemeMode,
ConfigChangedEvent,
VoiceState,
VoiceMode,
AudioLevelEvent,
VoiceStateChangedEvent,
TranscriptionDeltaEvent,
TranscriptionCompleteEvent,
VoiceErrorEvent,
StartRecordingParams,
StartRecordingResult,
StopRecordingParams,
StopRecordingResult,
CancelRecordingParams,
SetVoiceModeParams,
STTStatus,
STTModel,
SetSTTModelParams,
SetSTTLanguageParams,
STTStatusChangedEvent,
HotkeyBinding,
SetHotkeyParams,
SetEnabledParams,
HotkeyTriggeredEvent,
PermissionStatus
} from '@shared/types'
import type { IPCResult } from '@shared/errors'
type Unsubscribe = () => void
function invoke<TResult>(channel: string, ...args: unknown[]): Promise<IPCResult<TResult>> {
return ipcRenderer.invoke(channel, ...args)
}
function send(channel: string, ...args: unknown[]): void {
ipcRenderer.send(channel, ...args)
}
function on<T>(channel: string, callback: (data: T) => void): Unsubscribe {
const listener = (_event: Electron.IpcRendererEvent, data: T) => callback(data)
ipcRenderer.on(channel, listener)
return () => ipcRenderer.removeListener(channel, listener)
}
const electronAPI = {
// ── Audio ──────────────────────────────────────────────
audio: {
getDevices: () => invoke<AudioDevice[]>(IPC_CHANNELS.AUDIO.GET_DEVICES),
getSelectedDevice: () => invoke<string | null>(IPC_CHANNELS.AUDIO.GET_SELECTED_DEVICE),
setSelectedDevice: (params: SetDeviceParams) =>
invoke<void>(IPC_CHANNELS.AUDIO.SET_SELECTED_DEVICE, params),
testDevice: (params: TestDeviceParams) =>
invoke<TestDeviceResult>(IPC_CHANNELS.AUDIO.TEST_DEVICE, params),
onDeviceChanged: (cb: (e: AudioDeviceChangedEvent) => void): Unsubscribe =>
on(IPC_CHANNELS.AUDIO.DEVICE_CHANGED, cb)
},
// ── Config ─────────────────────────────────────────────
config: {
get: (params: ConfigGetParams) => invoke<unknown>(IPC_CHANNELS.CONFIG.GET, params),
set: (params: ConfigSetParams) => invoke<void>(IPC_CHANNELS.CONFIG.SET, params),
getAll: () => invoke<AppConfig>(IPC_CHANNELS.CONFIG.GET_ALL),
reset: (params: ConfigResetParams) => invoke<void>(IPC_CHANNELS.CONFIG.RESET, params),
getTheme: () => invoke<ThemeMode>(IPC_CHANNELS.CONFIG.GET_THEME),
setTheme: (params: SetThemeParams) => invoke<void>(IPC_CHANNELS.CONFIG.SET_THEME, params),
getLanguage: () => invoke<string>(IPC_CHANNELS.CONFIG.GET_LANGUAGE),
setLanguage: (params: SetLanguageParams) =>
invoke<void>(IPC_CHANNELS.CONFIG.SET_LANGUAGE, params),
onChanged: (cb: (e: ConfigChangedEvent) => void): Unsubscribe =>
on(IPC_CHANNELS.CONFIG.CHANGED, cb)
},
// ── Voice ──────────────────────────────────────────────
voice: {
startRecording: (params: StartRecordingParams) =>
invoke<StartRecordingResult>(IPC_CHANNELS.VOICE.START_RECORDING, params),
stopRecording: (params: StopRecordingParams) =>
invoke<StopRecordingResult>(IPC_CHANNELS.VOICE.STOP_RECORDING, params),
cancelRecording: (params: CancelRecordingParams) =>
invoke<void>(IPC_CHANNELS.VOICE.CANCEL_RECORDING, params),
getState: () => invoke<VoiceState>(IPC_CHANNELS.VOICE.GET_STATE),
setMode: (params: SetVoiceModeParams) =>
invoke<void>(IPC_CHANNELS.VOICE.SET_MODE, params),
getMode: () => invoke<VoiceMode>(IPC_CHANNELS.VOICE.GET_MODE),
onStateChanged: (cb: (e: VoiceStateChangedEvent) => void): Unsubscribe =>
on(IPC_CHANNELS.VOICE.STATE_CHANGED, cb),
onTranscriptionDelta: (cb: (e: TranscriptionDeltaEvent) => void): Unsubscribe =>
on(IPC_CHANNELS.VOICE.TRANSCRIPTION_DELTA, cb),
onTranscriptionComplete: (cb: (e: TranscriptionCompleteEvent) => void): Unsubscribe =>
on(IPC_CHANNELS.VOICE.TRANSCRIPTION_COMPLETE, cb),
onError: (cb: (e: VoiceErrorEvent) => void): Unsubscribe =>
on(IPC_CHANNELS.VOICE.ERROR, cb),
onAudioLevel: (cb: (e: AudioLevelEvent) => void): Unsubscribe =>
on(IPC_CHANNELS.VOICE.AUDIO_LEVEL, cb)
},
// ── STT ────────────────────────────────────────────────
stt: {
getStatus: () => invoke<STTStatus>(IPC_CHANNELS.STT.GET_STATUS),
getModels: () => invoke<STTModel[]>(IPC_CHANNELS.STT.GET_MODELS),
getActiveModel: () => invoke<string | null>(IPC_CHANNELS.STT.GET_ACTIVE_MODEL),
setModel: (params: SetSTTModelParams) =>
invoke<void>(IPC_CHANNELS.STT.SET_MODEL, params),
getLanguage: () => invoke<string>(IPC_CHANNELS.STT.GET_LANGUAGE),
setLanguage: (params: SetSTTLanguageParams) =>
invoke<void>(IPC_CHANNELS.STT.SET_LANGUAGE, params),
onStatusChanged: (cb: (e: STTStatusChangedEvent) => void): Unsubscribe =>
on(IPC_CHANNELS.STT.STATUS_CHANGED, cb)
},
// ── Hotkey ─────────────────────────────────────────────
hotkey: {
getDictationShortcut: () =>
invoke<HotkeyBinding>(IPC_CHANNELS.HOTKEY.GET_DICTATION_SHORTCUT),
setDictationShortcut: (params: SetHotkeyParams) =>
invoke<void>(IPC_CHANNELS.HOTKEY.SET_DICTATION_SHORTCUT, params),
getHandsFreeShortcut: () =>
invoke<HotkeyBinding>(IPC_CHANNELS.HOTKEY.GET_HANDS_FREE_SHORTCUT),
setHandsFreeShortcut: (params: SetHotkeyParams) =>
invoke<void>(IPC_CHANNELS.HOTKEY.SET_HANDS_FREE_SHORTCUT, params),
isEnabled: () => invoke<boolean>(IPC_CHANNELS.HOTKEY.IS_ENABLED),
setEnabled: (params: SetEnabledParams) =>
invoke<void>(IPC_CHANNELS.HOTKEY.SET_ENABLED, params),
onTriggered: (cb: (e: HotkeyTriggeredEvent) => void): Unsubscribe =>
on(IPC_CHANNELS.HOTKEY.TRIGGERED, cb)
},
// ── Window ─────────────────────────────────────────────
window: {
minimize: () => send(IPC_CHANNELS.WINDOW.MINIMIZE),
maximize: () => send(IPC_CHANNELS.WINDOW.MAXIMIZE),
close: () => send(IPC_CHANNELS.WINDOW.CLOSE),
isMaximized: () => invoke<boolean>(IPC_CHANNELS.WINDOW.IS_MAXIMIZED)
},
// ── System ─────────────────────────────────────────────
system: {
getPlatform: () => invoke<NodeJS.Platform>(IPC_CHANNELS.SYSTEM.GET_PLATFORM),
getVersion: () => invoke<string>(IPC_CHANNELS.SYSTEM.GET_VERSION),
checkMicPermission: () =>
invoke<PermissionStatus>(IPC_CHANNELS.SYSTEM.CHECK_MIC_PERMISSION)
}
} as const
contextBridge.exposeInMainWorld('electronAPI', electronAPI)
export type ElectronAPI = typeof electronAPI

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

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

View file

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

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

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

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

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

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

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

View file

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

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

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

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

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

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

@ -0,0 +1,162 @@
// src/shared/errors.ts
export enum ErrorCode {
// === Success ===
Success = 0,
// === STT (100-199) ===
STTEngineNotInstalled = 100,
STTModelNotFound = 101,
STTModelNotLoaded = 102,
STTModelLoadFailed = 103,
STTModelDownloadFailed = 104,
STTModelDownloadCancelled = 105,
STTTranscriptionFailed = 110,
STTTranscriptionTimeout = 111,
STTTranscriptionCancelled = 112,
STTNoAudioData = 113,
STTAudioTooShort = 114,
STTLanguageNotSupported = 120,
STTSidecarSpawnFailed = 130,
STTSidecarCrashed = 131,
STTSidecarCommunicationFailed = 132,
STTGPUNotAvailable = 140,
// === TTS (200-299) ===
TTSEngineNotInstalled = 200,
TTSVoiceNotFound = 201,
TTSVoiceNotLoaded = 202,
TTSVoiceLoadFailed = 203,
TTSVoiceDownloadFailed = 204,
TTSSynthesisFailed = 210,
TTSPlaybackFailed = 211,
TTSPlaybackInterrupted = 212,
TTSTextTooLong = 220,
TTSTextEmpty = 221,
// === LLM / Ollama (300-399) ===
LLMServerUnreachable = 300,
LLMServerConnectionFailed = 301,
LLMServerTimeout = 302,
LLMModelNotFound = 310,
LLMModelNotLoaded = 311,
LLMModelLoadFailed = 312,
LLMModelPullFailed = 313,
LLMModelPullCancelled = 314,
LLMProcessingFailed = 320,
LLMProcessingTimeout = 321,
LLMProcessingCancelled = 322,
LLMResponseParseFailed = 323,
LLMInvalidAction = 330,
LLMPromptTooLong = 331,
// === Audio (400-499) ===
AudioDeviceNotFound = 400,
AudioDeviceAccessDenied = 401,
AudioDeviceBusy = 402,
AudioCaptureStartFailed = 410,
AudioCaptureStopFailed = 411,
AudioCaptureFailed = 412,
AudioNoPermission = 420,
AudioStreamError = 430,
AudioBufferOverflow = 431,
// === Hotkey (500-599) ===
HotkeyRegistrationFailed = 500,
HotkeyConflict = 501,
HotkeySystemReserved = 502,
HotkeyHookInitFailed = 510,
HotkeyHookCrashed = 511,
// === TextInsert (600-699) ===
TextInsertFailed = 600,
TextInsertClipboardSaveFailed = 601,
TextInsertClipboardRestoreFailed = 602,
TextInsertKeySimulationFailed = 603,
TextInsertNoActiveWindow = 610,
TextInsertTargetAppNotResponding = 611,
// === History / Dictionary / DB (700-799) ===
DBOpenFailed = 700,
DBMigrationFailed = 701,
DBQueryFailed = 702,
DBWriteFailed = 703,
HistoryNotFound = 710,
HistoryExportFailed = 711,
DictionaryNotFound = 720,
DictionaryDuplicate = 721,
DictionaryImportFailed = 722,
DictionaryExportFailed = 723,
DictionaryImportInvalidFormat = 724,
// === Config (800-899) ===
ConfigReadFailed = 800,
ConfigWriteFailed = 801,
ConfigInvalidValue = 802,
ConfigKeyNotFound = 803,
ConfigResetFailed = 804,
ConfigMigrationFailed = 810,
// === System / Window (900-999) ===
WindowCreationFailed = 900,
WindowNotFound = 901,
TrayCreationFailed = 910,
NotificationFailed = 920,
PermissionDenied = 930,
ExternalOpenFailed = 940,
SoundPlayFailed = 950,
AppAlreadyRunning = 960,
UnknownError = 999
}
/**
* D3RO-VOICE (Speakly NXError )
*/
export class D3ROError extends Error {
readonly code: ErrorCode
readonly details?: Record<string, unknown>
constructor(code: ErrorCode, message: string, details?: Record<string, unknown>) {
super(message)
this.name = 'D3ROError'
this.code = code
this.details = details
}
toJSON(): D3ROErrorJSON {
return {
code: this.code,
message: this.message,
details: this.details
}
}
static fromJSON(json: D3ROErrorJSON): D3ROError {
return new D3ROError(json.code, json.message, json.details)
}
}
export interface D3ROErrorJSON {
code: ErrorCode
message: string
details?: Record<string, unknown>
}
/**
* IPC .
*/
export type IPCResult<T> =
| { success: true; data: T }
| { success: false; error: D3ROErrorJSON }
export function ipcSuccess<T>(data: T): IPCResult<T> {
return { success: true, data }
}
export function ipcError<T>(
code: ErrorCode,
message: string,
details?: Record<string, unknown>
): IPCResult<T> {
return { success: false, error: { code, message, details } }
}

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

@ -0,0 +1,172 @@
// src/shared/ipc-channels.ts
// IPC 채널명 중앙 정의 — 모든 채널명은 이 파일에서만 정의한다.
export const IPC_CHANNELS = {
VOICE: {
START_RECORDING: 'voice:startRecording',
STOP_RECORDING: 'voice:stopRecording',
CANCEL_RECORDING: 'voice:cancelRecording',
GET_STATE: 'voice:getState',
SET_MODE: 'voice:setMode',
GET_MODE: 'voice:getMode',
// Main → Renderer events
STATE_CHANGED: 'voice:stateChanged',
TRANSCRIPTION_DELTA: 'voice:transcriptionDelta',
TRANSCRIPTION_COMPLETE: 'voice:transcriptionComplete',
ERROR: 'voice:error',
AUDIO_LEVEL: 'voice:audioLevel'
},
AUDIO: {
GET_DEVICES: 'audio:getDevices',
GET_SELECTED_DEVICE: 'audio:getSelectedDevice',
SET_SELECTED_DEVICE: 'audio:setSelectedDevice',
TEST_DEVICE: 'audio:testDevice',
// Main → Renderer events
DEVICE_CHANGED: 'audio:deviceChanged'
},
STT: {
GET_STATUS: 'stt:getStatus',
GET_MODELS: 'stt:getModels',
GET_ACTIVE_MODEL: 'stt:getActiveModel',
SET_MODEL: 'stt:setModel',
DOWNLOAD_MODEL: 'stt:downloadModel',
CANCEL_DOWNLOAD: 'stt:cancelDownload',
GET_LANGUAGE: 'stt:getLanguage',
SET_LANGUAGE: 'stt:setLanguage',
// Main → Renderer events
STATUS_CHANGED: 'stt:statusChanged',
DOWNLOAD_PROGRESS: 'stt:downloadProgress'
},
TTS: {
SPEAK: 'tts:speak',
STOP: 'tts:stop',
GET_VOICES: 'tts:getVoices',
GET_ACTIVE_VOICE: 'tts:getActiveVoice',
SET_VOICE: 'tts:setVoice',
GET_STATUS: 'tts:getStatus',
DOWNLOAD_VOICE: 'tts:downloadVoice',
// Main → Renderer events
STATUS_CHANGED: 'tts:statusChanged',
SPEAKING_STATE_CHANGED: 'tts:speakingStateChanged'
},
LLM: {
GET_STATUS: 'llm:getStatus',
GET_MODELS: 'llm:getModels',
GET_ACTIVE_MODEL: 'llm:getActiveModel',
SET_MODEL: 'llm:setModel',
PROCESS: 'llm:process',
CANCEL_PROCESS: 'llm:cancelProcess',
GET_SERVER_URL: 'llm:getServerUrl',
SET_SERVER_URL: 'llm:setServerUrl',
PULL_MODEL: 'llm:pullModel',
// Main → Renderer events
STATUS_CHANGED: 'llm:statusChanged',
PROCESS_PROGRESS: 'llm:processProgress',
PULL_PROGRESS: 'llm:pullProgress'
},
HOTKEY: {
GET_DICTATION_SHORTCUT: 'hotkey:getDictationShortcut',
SET_DICTATION_SHORTCUT: 'hotkey:setDictationShortcut',
GET_HANDS_FREE_SHORTCUT: 'hotkey:getHandsFreeShortcut',
SET_HANDS_FREE_SHORTCUT: 'hotkey:setHandsFreeShortcut',
GET_COMMAND_SHORTCUT: 'hotkey:getCommandShortcut',
SET_COMMAND_SHORTCUT: 'hotkey:setCommandShortcut',
IS_ENABLED: 'hotkey:isEnabled',
SET_ENABLED: 'hotkey:setEnabled',
START_RECORDING: 'hotkey:startRecording',
STOP_RECORDING: 'hotkey:stopRecording',
// Main → Renderer events
TRIGGERED: 'hotkey:triggered',
RECORDING_RESULT: 'hotkey:recordingResult'
},
CONFIG: {
GET: 'config:get',
SET: 'config:set',
GET_ALL: 'config:getAll',
RESET: 'config:reset',
GET_THEME: 'config:getTheme',
SET_THEME: 'config:setTheme',
GET_LANGUAGE: 'config:getLanguage',
SET_LANGUAGE: 'config:setLanguage',
GET_AUTO_LAUNCH: 'config:getAutoLaunch',
SET_AUTO_LAUNCH: 'config:setAutoLaunch',
GET_CLOSE_TO_TRAY: 'config:getCloseToTray',
SET_CLOSE_TO_TRAY: 'config:setCloseToTray',
// Main → Renderer events
CHANGED: 'config:changed'
},
HISTORY: {
GET_ALL: 'history:getAll',
GET_BY_ID: 'history:getById',
DELETE: 'history:delete',
DELETE_ALL: 'history:deleteAll',
SEARCH: 'history:search',
EXPORT: 'history:export',
// Main → Renderer events
ADDED: 'history:added'
},
DICTIONARY: {
GET_ALL: 'dictionary:getAll',
ADD: 'dictionary:add',
UPDATE: 'dictionary:update',
DELETE: 'dictionary:delete',
IMPORT: 'dictionary:import',
EXPORT: 'dictionary:export',
SEARCH: 'dictionary:search'
},
WINDOW: {
MINIMIZE: 'window:minimize',
MAXIMIZE: 'window:maximize',
CLOSE: 'window:close',
IS_MAXIMIZED: 'window:isMaximized',
SHOW_RECORDING_TIP: 'window:showRecordingTip',
HIDE_RECORDING_TIP: 'window:hideRecordingTip',
SHOW_RESULT_POPUP: 'window:showResultPopup',
HIDE_RESULT_POPUP: 'window:hideResultPopup',
TIP_MEASURED: 'window:tipMeasured',
// Main → Renderer events
TIP_STATE_CHANGED: 'window:tipStateChanged',
TIP_PREPARE: 'window:tipPrepare',
TIP_SHOW: 'window:tipShow'
},
SYSTEM: {
GET_PLATFORM: 'system:getPlatform',
GET_VERSION: 'system:getVersion',
CHECK_MIC_PERMISSION: 'system:checkMicPermission',
REQUEST_MIC_PERMISSION: 'system:requestMicPermission',
SHOW_NOTIFICATION: 'system:showNotification',
OPEN_EXTERNAL: 'system:openExternal',
GET_ACTIVE_APP: 'system:getActiveApp',
INSERT_TEXT: 'system:insertText',
PLAY_SOUND: 'system:playSound',
SET_SOUND_ENABLED: 'system:setSoundEnabled',
IS_SOUND_ENABLED: 'system:isSoundEnabled'
},
STATS: {
GET_SUMMARY: 'stats:getSummary',
GET_DAILY: 'stats:getDaily',
GET_WEEKLY: 'stats:getWeekly',
// Main → Renderer events
UPDATED: 'stats:updated'
}
} as const
// 타입 유틸리티: 채널명 유니온 추출
type NestedValues<T> = T extends Record<string, infer V>
? V extends string
? V
: NestedValues<V>
: never
export type IPCChannel = NestedValues<typeof IPC_CHANNELS>

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

@ -0,0 +1,658 @@
// src/shared/types.ts
// 모든 IPC 파라미터/반환 타입 정의
// ============================================================
// Common
// ============================================================
export type ThemeMode = 'light' | 'dark' | 'auto'
export type VoiceMode = 'dictation' | 'hands-free'
export type PermissionStatus = 'granted' | 'denied' | 'unknown'
// ============================================================
// Voice (음성 오케스트레이션)
// ============================================================
export enum RecognitionState {
IDLE = 'idle',
PREPARING = 'preparing',
CONNECTING = 'connecting',
READY = 'ready',
RECOGNIZING = 'recognizing',
COMPLETED = 'completed',
CANCELLED = 'cancelled',
ERROR = 'error',
DESTROYED = 'destroyed'
}
export enum AudioState {
IDLE = 'idle',
INITIALIZING = 'initializing',
STREAMING = 'streaming',
STOPPED = 'stopped'
}
export interface VoiceState {
recognitionState: RecognitionState
audioState: AudioState
mode: VoiceMode
sessionId: string | null
recordingStartedAt: number | null
}
export interface StartRecordingParams {
sessionId?: string
deviceId?: string
}
export interface StartRecordingResult {
sessionId: string
}
export interface StopRecordingParams {
sessionId: string
}
export interface StopRecordingResult {
sessionId: string
text: string
durationMs: number
}
export interface CancelRecordingParams {
sessionId: string
}
export interface SetVoiceModeParams {
mode: VoiceMode
}
// Voice events (Main → Renderer)
export interface VoiceStateChangedEvent {
previousState: RecognitionState
currentState: RecognitionState
audioState: AudioState
sessionId: string | null
}
export interface TranscriptionDeltaEvent {
sessionId: string
text: string
delta: string
isFinal: boolean
}
export interface TranscriptionCompleteEvent {
sessionId: string
text: string
durationMs: number
language: string
}
export interface VoiceErrorEvent {
sessionId: string | null
errorCode: number
message: string
}
export interface AudioLevelEvent {
level: number
}
// ============================================================
// Audio (디바이스 & 캡처)
// ============================================================
export interface AudioDevice {
deviceId: string
label: string
isDefault: boolean
}
export interface SetDeviceParams {
deviceId: string
}
export interface TestDeviceParams {
deviceId: string
durationMs?: number
}
export interface TestDeviceResult {
averageLevel: number
peakLevel: number
hasAudio: boolean
}
export interface AudioDeviceChangedEvent {
devices: AudioDevice[]
type: 'added' | 'removed' | 'default-changed'
}
// ============================================================
// STT (로컬 Whisper)
// ============================================================
export enum STTEngineState {
NOT_INSTALLED = 'not-installed',
DOWNLOADING = 'downloading',
LOADING = 'loading',
READY = 'ready',
PROCESSING = 'processing',
ERROR = 'error'
}
export interface STTStatus {
engineState: STTEngineState
activeModel: string | null
engineVersion: string | null
gpuAccelerated: boolean
}
export interface STTModel {
id: string
name: string
sizeBytes: number
downloaded: boolean
languages: string[]
accuracy: number
speed: number
}
export interface SetSTTModelParams {
modelId: string
}
export interface DownloadModelParams {
modelId: string
}
export interface SetSTTLanguageParams {
language: string
}
export interface STTStatusChangedEvent {
status: STTStatus
}
export interface DownloadProgressEvent {
modelId: string
percent: number
downloadedBytes: number
totalBytes: number
bytesPerSecond: number
}
// ============================================================
// TTS (로컬 TTS)
// ============================================================
export enum TTSEngineState {
NOT_INSTALLED = 'not-installed',
LOADING = 'loading',
READY = 'ready',
SPEAKING = 'speaking',
ERROR = 'error'
}
export interface TTSStatus {
engineState: TTSEngineState
activeVoice: string | null
engineVersion: string | null
}
export interface TTSVoice {
id: string
name: string
language: string
gender: 'male' | 'female' | 'neutral'
downloaded: boolean
sizeBytes: number
}
export interface TTSSpeakParams {
text: string
voiceId?: string
speed?: number
}
export interface TTSSpeakResult {
durationMs: number
}
export interface SetTTSVoiceParams {
voiceId: string
}
export interface DownloadVoiceParams {
voiceId: string
}
export interface TTSStatusChangedEvent {
status: TTSStatus
}
export interface SpeakingStateChangedEvent {
isSpeaking: boolean
text: string
}
// ============================================================
// LLM (Ollama)
// ============================================================
export enum LLMConnectionState {
DISCONNECTED = 'disconnected',
CONNECTING = 'connecting',
CONNECTED = 'connected',
ERROR = 'error'
}
export interface LLMStatus {
connectionState: LLMConnectionState
serverUrl: string
activeModel: string | null
serverVersion: string | null
}
export interface LLMModel {
id: string
name: string
sizeBytes: number
parameterSize: string
quantization: string
modifiedAt: string
}
export type LLMAction = 'refine' | 'translate' | 'summarize' | 'expand' | 'grammar' | 'custom'
export interface LLMProcessParams {
text: string
action: LLMAction
targetLanguage?: string
customPrompt?: string
modelId?: string
}
export interface LLMProcessResult {
originalText: string
processedText: string
action: LLMAction
processingTimeMs: number
tokenCount: number
}
export interface SetLLMModelParams {
modelId: string
}
export interface SetServerUrlParams {
url: string
}
export interface PullModelParams {
modelName: string
}
export interface LLMStatusChangedEvent {
status: LLMStatus
}
export interface LLMProcessProgressEvent {
text: string
token: string
done: boolean
}
export interface LLMPullProgressEvent {
modelName: string
status: string
percent: number
downloadedBytes: number
totalBytes: number
}
// ============================================================
// Hotkey (핫키)
// ============================================================
export interface HotkeyBinding {
keyCode: number
ctrl: boolean
alt: boolean
shift: boolean
meta: boolean
displayLabel: string
}
export interface SetHotkeyParams {
binding: HotkeyBinding
}
export interface SetEnabledParams {
enabled: boolean
}
export type HotkeyAction = 'dictation' | 'hands-free' | 'command'
export interface HotkeyTriggeredEvent {
action: HotkeyAction
type: 'pressed' | 'released'
isDoublePress: boolean
}
export interface HotkeyRecordingResultEvent {
binding: HotkeyBinding | null
conflictReason: string | null
}
// ============================================================
// Config (설정)
// ============================================================
export interface AppConfig {
theme: ThemeMode
language: string
closeToTray: boolean
autoLaunch: boolean
soundEnabled: boolean
selectedDeviceId: string | null
sttModelId: string
sttLanguage: string
ttsVoiceId: string | null
ttsSpeed: number
ollamaServerUrl: string
llmModelId: string | null
defaultLLMAction: LLMAction
dictationShortcut: HotkeyBinding
handsFreeShortcut: HotkeyBinding
commandShortcut: HotkeyBinding
hotkeyEnabled: boolean
insertMethod: 'clipboard' | 'keyboard'
autoInsert: boolean
maxHistoryEntries: number
}
export interface ConfigGetParams {
key: keyof AppConfig
}
export interface ConfigSetParams {
key: keyof AppConfig
value: AppConfig[keyof AppConfig]
}
export interface ConfigResetParams {
key?: keyof AppConfig
}
export interface SetThemeParams {
theme: ThemeMode
}
export interface SetLanguageParams {
language: string
}
export interface SetAutoLaunchParams {
enabled: boolean
}
export interface SetCloseToTrayParams {
enabled: boolean
}
export interface ConfigChangedEvent {
key: keyof AppConfig
value: AppConfig[keyof AppConfig]
previousValue: AppConfig[keyof AppConfig]
}
// ============================================================
// History (히스토리)
// ============================================================
export interface HistoryEntry {
id: string
originalText: string
polishedText: string | null
focusedApp: string | null
focusedAppName: string | null
focusedAppWindowTitle: string | null
mode: 'dictation' | 'translate' | 'command'
status: 'completed' | 'cancelled' | 'error'
errorCode: string | null
audioLocalPath: string | null
duration: number
detectedLanguage: string | null
micDevice: string | null
wordCount: number
sttModel: string | null
llmModel: string | null
sttLatencyMs: number | null
llmLatencyMs: number | null
createdAt: number
updatedAt: number
appVersion: string
}
export interface HistoryQueryParams {
page: number
pageSize: number
sortBy?: 'createdAt' | 'durationMs' | 'wordCount'
sortOrder?: 'asc' | 'desc'
}
export interface HistoryPage {
entries: HistoryEntry[]
total: number
page: number
pageSize: number
totalPages: number
}
export interface HistoryGetByIdParams {
id: string
}
export interface HistoryDeleteParams {
id: string
}
export interface HistorySearchParams {
query: string
page: number
pageSize: number
}
export interface HistoryExportParams {
format: 'json' | 'csv'
from?: string
to?: string
}
// ============================================================
// Dictionary (사전)
// ============================================================
export interface DictionaryEntry {
id: string
word: string
pronunciation: string | null
category: 'user' | 'auto' | 'technical'
usageCount: number
lastUsedAt: number | null
createdAt: number
updatedAt: number
}
export interface DictionaryQueryParams {
page: number
pageSize: number
sortBy?: 'word' | 'category' | 'usageCount' | 'createdAt'
sortOrder?: 'asc' | 'desc'
}
export interface DictionaryPage {
entries: DictionaryEntry[]
total: number
page: number
pageSize: number
totalPages: number
}
export interface DictionaryAddParams {
word: string
pronunciation?: string
category?: 'user' | 'auto' | 'technical'
}
export interface DictionaryUpdateParams {
id: string
word?: string
pronunciation?: string
category?: 'user' | 'auto' | 'technical'
}
export interface DictionaryDeleteParams {
id: string
}
export interface DictionaryImportParams {
filePath: string
format: 'json' | 'csv'
}
export interface DictionaryImportResult {
imported: number
skipped: number
errors: number
}
export interface DictionaryExportParams {
format: 'json' | 'csv'
}
export interface DictionarySearchParams {
query: string
page: number
pageSize: number
}
// ============================================================
// Window (윈도우 제어)
// ============================================================
export type RecordingTipState = 'opening' | 'recording' | 'thinking' | 'result' | 'error'
export interface ShowRecordingTipParams {
state: RecordingTipState
text?: string
errorMessage?: string
}
export interface ShowResultPopupParams {
text: string
autoHideMs?: number
}
export interface TipMeasuredParams {
width: number
height: number
}
export interface TipStateChangedEvent {
state: RecordingTipState
text?: string
errorMessage?: string
}
export interface TipPrepareEvent {
state: RecordingTipState
text?: string
}
export interface TipShowEvent {
state: RecordingTipState
}
// ============================================================
// System (시스템)
// ============================================================
export interface ActiveAppInfo {
name: string
title: string
pid: number
}
export interface ShowNotificationParams {
title: string
body: string
type?: 'info' | 'warning' | 'error'
}
export interface OpenExternalParams {
url: string
}
export interface InsertTextParams {
text: string
method?: 'clipboard' | 'keyboard'
}
export interface InsertTextResult {
success: boolean
insertedLength: number
}
export type SoundEffect =
| 'recording-start'
| 'recording-stop'
| 'transcription-complete'
| 'error'
| 'notification'
export interface PlaySoundParams {
sound: SoundEffect
}
export interface SetSoundEnabledParams {
enabled: boolean
}
// ============================================================
// Stats (통계)
// ============================================================
export interface StatsSummary {
totalRecordingTimeMs: number
totalWordCount: number
totalSessionCount: number
todayRecordingTimeMs: number
todayWordCount: number
todaySessionCount: number
streakDays: number
}
export interface StatsQueryParams {
from: string
to: string
}
export interface DailyStats {
date: string
recordingTimeMs: number
wordCount: number
sessionCount: number
}
export interface WeeklyStats {
weekStart: string
recordingTimeMs: number
wordCount: number
sessionCount: number
}

7
tsconfig.json Normal file
View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.web.json" }
]
}

25
tsconfig.node.json Normal file
View file

@ -0,0 +1,25 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
"compilerOptions": {
"composite": true,
"outDir": "./out",
"baseUrl": ".",
"paths": {
"@shared/*": ["src/shared/*"]
},
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": [
"src/main/**/*",
"src/preload/**/*",
"src/shared/**/*",
"electron.vite.config.ts"
]
}

24
tsconfig.web.json Normal file
View file

@ -0,0 +1,24 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
"compilerOptions": {
"composite": true,
"outDir": "./out",
"baseUrl": ".",
"paths": {
"@shared/*": ["src/shared/*"]
},
"jsx": "react-jsx",
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": [
"src/renderer/**/*",
"src/shared/**/*"
]
}