Phase 1+2 구현: Electron 뼈대 + STT/핫키/오케스트레이터
Phase 1: - 프로젝트 초기화 (TypeScript strict, electron-vite, ESLint, Prettier) - shared 타입 (ipc-channels 113채널, types, errors, constants) - 메인 프로세스 뼈대 (bootstrap, lifecycle, 단일 인스턴스) - LoggerService, ConfigService (electron-store ESM dynamic import) - React 19 + MUI 7 Dashboard, 시스템 트레이 Phase 2: - AudioCaptureService (node-record-lpcm16, PCM16 16kHz mono) - HotkeyService (uiohook-napi, 더블프레스, holdMode/toggleMode) - LocalSTTService (faster-whisper Python sidecar, 이중 조건 플러시) - VoiceModeService 오케스트레이터 (이중 상태머신, Action Queue) - Python sidecar (FastAPI: health/load/transcribe/shutdown) - IPC 핸들러 (voice, stt, hotkey) + Preload API 확장
This commit is contained in:
parent
e24bb8378c
commit
1d152d01a1
46 changed files with 10828 additions and 4 deletions
|
|
@ -28,6 +28,7 @@ allowed-tools: Read Write Edit Bash Glob Grep Agent
|
||||||
- 프리로드 브릿지 업데이트
|
- 프리로드 브릿지 업데이트
|
||||||
6. **테스트**: 각 모듈 단위 테스트
|
6. **테스트**: 각 모듈 단위 테스트
|
||||||
7. **CLAUDE.md 업데이트**: 현재 상태 갱신
|
7. **CLAUDE.md 업데이트**: 현재 상태 갱신
|
||||||
|
8. **Memory 갱신** (필수): `project_status.md`에 완료된 Phase, 작업 내역, 기술 결정 기록
|
||||||
|
|
||||||
## 구현 규칙 (위반 불가)
|
## 구현 규칙 (위반 불가)
|
||||||
- 서비스는 **설계서 01**의 인터페이스를 그대로 구현 (메서드 시그니처, 이벤트 페이로드 일치)
|
- 서비스는 **설계서 01**의 인터페이스를 그대로 구현 (메서드 시그니처, 이벤트 페이로드 일치)
|
||||||
|
|
@ -43,3 +44,4 @@ allowed-tools: Read Write Edit Bash Glob Grep Agent
|
||||||
- `npm run typecheck` 통과
|
- `npm run typecheck` 통과
|
||||||
- `npm run test` 통과
|
- `npm run test` 통과
|
||||||
- CLAUDE.md "현재 상태" 업데이트됨
|
- CLAUDE.md "현재 상태" 업데이트됨
|
||||||
|
- Memory `project_status.md` 갱신됨 (Phase 완료 내역, 기술 결정, 다음 작업)
|
||||||
|
|
|
||||||
25
.eslintrc.cjs
Normal file
25
.eslintrc.cjs
Normal 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
12
.gitignore
vendored
|
|
@ -7,3 +7,15 @@ out/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
|
||||||
|
# Electron
|
||||||
|
release/
|
||||||
|
*.unpacked
|
||||||
|
|
||||||
|
# Build
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
|
||||||
7
.prettierrc
Normal file
7
.prettierrc
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "none",
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2
|
||||||
|
}
|
||||||
26
CLAUDE.md
26
CLAUDE.md
|
|
@ -77,10 +77,28 @@ npm run typecheck # tsc --noEmit
|
||||||
6. **녹음 UI**: 9개 웨이브 바, cos 분포 가중치, 100ms 애니메이션
|
6. **녹음 UI**: 9개 웨이브 바, cos 분포 가중치, 100ms 애니메이션
|
||||||
|
|
||||||
## 현재 상태
|
## 현재 상태
|
||||||
Phase: 0 (하네스 설정 완료, 구현 시작 전)
|
Phase: 2 완료
|
||||||
마지막 완료: 하네스 시스템 구축
|
마지막 완료: Phase 2 — 로컬 STT (faster-whisper sidecar) + 핫키 (uiohook-napi) + VoiceModeService 오케스트레이터
|
||||||
다음 작업: Phase 1 — 프로젝트 초기화 + Electron 뼈대
|
다음 작업: 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 뼈대 + 마이크 캡처
|
- Phase 1: 프로젝트 초기화 + Electron 뼈대 + 마이크 캡처
|
||||||
|
|
|
||||||
30
electron.vite.config.ts
Normal file
30
electron.vite.config.ts
Normal 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
5590
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
50
package.json
Normal file
50
package.json
Normal 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
332
sidecar/main.py
Normal 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
5
sidecar/requirements.txt
Normal 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
78
src/main/bootstrap.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
// src/main/bootstrap.ts — 초기화 시퀀스
|
||||||
|
|
||||||
|
import { app, dialog } from 'electron'
|
||||||
|
import { initLoggerService, getLogger } from './services/LoggerService'
|
||||||
|
import { initConfigService } from './services/ConfigService'
|
||||||
|
import { getHotkeyService } from './services/HotkeyService'
|
||||||
|
import { getVoiceModeService } from './services/VoiceModeService'
|
||||||
|
import { createMainWindow } from './windows/WindowManager'
|
||||||
|
import { createTray } from './windows/TrayManager'
|
||||||
|
import { registerAllIpcHandlers } from './ipc'
|
||||||
|
|
||||||
|
const logger = getLogger('bootstrap')
|
||||||
|
|
||||||
|
interface BootstrapStep {
|
||||||
|
name: string
|
||||||
|
critical: boolean
|
||||||
|
fn: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bootstrap(): Promise<void> {
|
||||||
|
const steps: BootstrapStep[] = [
|
||||||
|
{ name: 'logger', critical: false, fn: initLogger },
|
||||||
|
{ name: 'config', critical: false, fn: initConfig },
|
||||||
|
{ name: 'create-windows', critical: true, fn: createWindows },
|
||||||
|
{ name: 'tray', critical: false, fn: initTray },
|
||||||
|
{ name: 'ipc-handlers', critical: true, fn: initIpcHandlers },
|
||||||
|
{ name: 'hotkey', critical: false, fn: initHotkey },
|
||||||
|
{ name: 'voice-mode', critical: false, fn: initVoiceMode }
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const step of steps) {
|
||||||
|
try {
|
||||||
|
await step.fn()
|
||||||
|
logger.info(`[bootstrap] ${step.name} initialized`)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[bootstrap] ${step.name} failed:`, error)
|
||||||
|
if (step.critical) {
|
||||||
|
dialog.showErrorBox(
|
||||||
|
'D3RO-VOICE 초기화 실패',
|
||||||
|
`${step.name}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
app.quit()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initLogger(): Promise<void> {
|
||||||
|
initLoggerService()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initConfig(): Promise<void> {
|
||||||
|
initConfigService()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createWindows(): Promise<void> {
|
||||||
|
createMainWindow()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initTray(): Promise<void> {
|
||||||
|
createTray()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initIpcHandlers(): Promise<void> {
|
||||||
|
registerAllIpcHandlers()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initHotkey(): Promise<void> {
|
||||||
|
const hotkey = getHotkeyService()
|
||||||
|
hotkey.loadFromConfig()
|
||||||
|
hotkey.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initVoiceMode(): Promise<void> {
|
||||||
|
const voiceMode = getVoiceModeService()
|
||||||
|
voiceMode.connectHotkey()
|
||||||
|
}
|
||||||
34
src/main/index.ts
Normal file
34
src/main/index.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
// src/main/index.ts — 앱 진입점
|
||||||
|
|
||||||
|
import { app } from 'electron'
|
||||||
|
import { bootstrap } from './bootstrap'
|
||||||
|
import { setupLifecycle } from './lifecycle'
|
||||||
|
import { getMainWindow } from './windows/WindowManager'
|
||||||
|
|
||||||
|
// 단일 인스턴스 잠금
|
||||||
|
const gotTheLock = app.requestSingleInstanceLock()
|
||||||
|
|
||||||
|
if (!gotTheLock) {
|
||||||
|
app.quit()
|
||||||
|
} else {
|
||||||
|
app.on('second-instance', () => {
|
||||||
|
// 기존 인스턴스의 메인 윈도우를 활성화
|
||||||
|
const mainWindow = getMainWindow()
|
||||||
|
if (mainWindow) {
|
||||||
|
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||||
|
mainWindow.focus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.whenReady().then(async () => {
|
||||||
|
await bootstrap()
|
||||||
|
setupLifecycle()
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
// Windows에서는 모든 윈도우 닫아도 앱 유지 (트레이)
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
// closeToTray 설정 확인은 lifecycle에서 처리
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
37
src/main/ipc/audio-handlers.ts
Normal file
37
src/main/ipc/audio-handlers.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
// src/main/ipc/audio-handlers.ts
|
||||||
|
|
||||||
|
import { ipcMain } from 'electron'
|
||||||
|
import { IPC_CHANNELS } from '@shared/ipc-channels'
|
||||||
|
import { ipcSuccess, ipcError, ErrorCode } from '@shared/errors'
|
||||||
|
import { getAudioCaptureService } from '../services/AudioCaptureService'
|
||||||
|
import { configGet, configSet } from '../services/ConfigService'
|
||||||
|
import type { SetDeviceParams } from '@shared/types'
|
||||||
|
|
||||||
|
export function registerAudioHandlers(): void {
|
||||||
|
ipcMain.handle(IPC_CHANNELS.AUDIO.GET_DEVICES, async () => {
|
||||||
|
try {
|
||||||
|
const devices = await getAudioCaptureService().getDevices()
|
||||||
|
return ipcSuccess(devices)
|
||||||
|
} catch {
|
||||||
|
return ipcError(ErrorCode.AudioDeviceNotFound, 'Failed to enumerate audio devices')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.AUDIO.GET_SELECTED_DEVICE, async () => {
|
||||||
|
return ipcSuccess(configGet('selectedDeviceId'))
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.AUDIO.SET_SELECTED_DEVICE, async (_event, params: SetDeviceParams) => {
|
||||||
|
try {
|
||||||
|
configSet('selectedDeviceId', params.deviceId)
|
||||||
|
return ipcSuccess(undefined)
|
||||||
|
} catch {
|
||||||
|
return ipcError(ErrorCode.AudioDeviceNotFound, 'Failed to set device')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.AUDIO.TEST_DEVICE, async () => {
|
||||||
|
// Phase 1: 스텁
|
||||||
|
return ipcSuccess({ averageLevel: 0, peakLevel: 0, hasAudio: false })
|
||||||
|
})
|
||||||
|
}
|
||||||
72
src/main/ipc/config-handlers.ts
Normal file
72
src/main/ipc/config-handlers.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
// src/main/ipc/config-handlers.ts
|
||||||
|
|
||||||
|
import { ipcMain } from 'electron'
|
||||||
|
import { IPC_CHANNELS } from '@shared/ipc-channels'
|
||||||
|
import { ipcSuccess, ipcError, ErrorCode } from '@shared/errors'
|
||||||
|
import { configGet, configSet, configGetAll, configReset } from '../services/ConfigService'
|
||||||
|
import type {
|
||||||
|
ConfigGetParams,
|
||||||
|
ConfigSetParams,
|
||||||
|
ConfigResetParams,
|
||||||
|
SetThemeParams,
|
||||||
|
SetLanguageParams,
|
||||||
|
AppConfig
|
||||||
|
} from '@shared/types'
|
||||||
|
|
||||||
|
export function registerConfigHandlers(): void {
|
||||||
|
ipcMain.handle(IPC_CHANNELS.CONFIG.GET, async (_event, params: ConfigGetParams) => {
|
||||||
|
try {
|
||||||
|
return ipcSuccess(configGet(params.key))
|
||||||
|
} catch {
|
||||||
|
return ipcError(ErrorCode.ConfigReadFailed, `Failed to read config key: ${params.key}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.CONFIG.SET, async (_event, params: ConfigSetParams) => {
|
||||||
|
try {
|
||||||
|
configSet(params.key, params.value as AppConfig[typeof params.key])
|
||||||
|
return ipcSuccess(undefined)
|
||||||
|
} catch {
|
||||||
|
return ipcError(ErrorCode.ConfigWriteFailed, `Failed to write config key: ${params.key}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.CONFIG.GET_ALL, async () => {
|
||||||
|
return ipcSuccess(configGetAll())
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.CONFIG.RESET, async (_event, params: ConfigResetParams) => {
|
||||||
|
try {
|
||||||
|
configReset(params.key)
|
||||||
|
return ipcSuccess(undefined)
|
||||||
|
} catch {
|
||||||
|
return ipcError(ErrorCode.ConfigResetFailed, 'Failed to reset config')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.CONFIG.GET_THEME, async () => {
|
||||||
|
return ipcSuccess(configGet('theme'))
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.CONFIG.SET_THEME, async (_event, params: SetThemeParams) => {
|
||||||
|
configSet('theme', params.theme)
|
||||||
|
return ipcSuccess(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.CONFIG.GET_LANGUAGE, async () => {
|
||||||
|
return ipcSuccess(configGet('language'))
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.CONFIG.SET_LANGUAGE, async (_event, params: SetLanguageParams) => {
|
||||||
|
configSet('language', params.language)
|
||||||
|
return ipcSuccess(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.CONFIG.GET_AUTO_LAUNCH, async () => {
|
||||||
|
return ipcSuccess(configGet('autoLaunch'))
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.CONFIG.GET_CLOSE_TO_TRAY, async () => {
|
||||||
|
return ipcSuccess(configGet('closeToTray'))
|
||||||
|
})
|
||||||
|
}
|
||||||
67
src/main/ipc/hotkey-handlers.ts
Normal file
67
src/main/ipc/hotkey-handlers.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
// src/main/ipc/hotkey-handlers.ts
|
||||||
|
|
||||||
|
import { ipcMain } from 'electron'
|
||||||
|
import { IPC_CHANNELS } from '@shared/ipc-channels'
|
||||||
|
import { ipcSuccess, ipcError, ErrorCode } from '@shared/errors'
|
||||||
|
import { getHotkeyService } from '../services/HotkeyService'
|
||||||
|
import { configGet, configSet } from '../services/ConfigService'
|
||||||
|
import type { SetHotkeyParams, SetEnabledParams } from '@shared/types'
|
||||||
|
|
||||||
|
export function registerHotkeyHandlers(): void {
|
||||||
|
ipcMain.handle(IPC_CHANNELS.HOTKEY.GET_DICTATION_SHORTCUT, async () => {
|
||||||
|
return ipcSuccess(configGet('dictationShortcut'))
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.HOTKEY.SET_DICTATION_SHORTCUT, async (_event, params: SetHotkeyParams) => {
|
||||||
|
try {
|
||||||
|
configSet('dictationShortcut', params.binding)
|
||||||
|
getHotkeyService().loadFromConfig()
|
||||||
|
return ipcSuccess(undefined)
|
||||||
|
} catch {
|
||||||
|
return ipcError(ErrorCode.HotkeyRegistrationFailed, 'Failed to set dictation shortcut')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.HOTKEY.GET_HANDS_FREE_SHORTCUT, async () => {
|
||||||
|
return ipcSuccess(configGet('handsFreeShortcut'))
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.HOTKEY.SET_HANDS_FREE_SHORTCUT, async (_event, params: SetHotkeyParams) => {
|
||||||
|
try {
|
||||||
|
configSet('handsFreeShortcut', params.binding)
|
||||||
|
getHotkeyService().loadFromConfig()
|
||||||
|
return ipcSuccess(undefined)
|
||||||
|
} catch {
|
||||||
|
return ipcError(ErrorCode.HotkeyRegistrationFailed, 'Failed to set hands-free shortcut')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.HOTKEY.GET_COMMAND_SHORTCUT, async () => {
|
||||||
|
return ipcSuccess(configGet('commandShortcut'))
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.HOTKEY.SET_COMMAND_SHORTCUT, async (_event, params: SetHotkeyParams) => {
|
||||||
|
try {
|
||||||
|
configSet('commandShortcut', params.binding)
|
||||||
|
getHotkeyService().loadFromConfig()
|
||||||
|
return ipcSuccess(undefined)
|
||||||
|
} catch {
|
||||||
|
return ipcError(ErrorCode.HotkeyRegistrationFailed, 'Failed to set command shortcut')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.HOTKEY.IS_ENABLED, async () => {
|
||||||
|
return ipcSuccess(configGet('hotkeyEnabled'))
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.HOTKEY.SET_ENABLED, async (_event, params: SetEnabledParams) => {
|
||||||
|
configSet('hotkeyEnabled', params.enabled)
|
||||||
|
const hotkey = getHotkeyService()
|
||||||
|
if (params.enabled) {
|
||||||
|
hotkey.start()
|
||||||
|
} else {
|
||||||
|
hotkey.stop()
|
||||||
|
}
|
||||||
|
return ipcSuccess(undefined)
|
||||||
|
})
|
||||||
|
}
|
||||||
23
src/main/ipc/index.ts
Normal file
23
src/main/ipc/index.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
// src/main/ipc/index.ts — IPC 핸들러 일괄 등록
|
||||||
|
|
||||||
|
import { registerAudioHandlers } from './audio-handlers'
|
||||||
|
import { registerConfigHandlers } from './config-handlers'
|
||||||
|
import { registerWindowHandlers } from './window-handlers'
|
||||||
|
import { registerSystemHandlers } from './system-handlers'
|
||||||
|
import { registerVoiceHandlers } from './voice-handlers'
|
||||||
|
import { registerSTTHandlers } from './stt-handlers'
|
||||||
|
import { registerHotkeyHandlers } from './hotkey-handlers'
|
||||||
|
import { getLogger } from '../services/LoggerService'
|
||||||
|
|
||||||
|
const logger = getLogger('ipc')
|
||||||
|
|
||||||
|
export function registerAllIpcHandlers(): void {
|
||||||
|
registerAudioHandlers()
|
||||||
|
registerConfigHandlers()
|
||||||
|
registerWindowHandlers()
|
||||||
|
registerSystemHandlers()
|
||||||
|
registerVoiceHandlers()
|
||||||
|
registerSTTHandlers()
|
||||||
|
registerHotkeyHandlers()
|
||||||
|
logger.info('All IPC handlers registered')
|
||||||
|
}
|
||||||
52
src/main/ipc/stt-handlers.ts
Normal file
52
src/main/ipc/stt-handlers.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
// src/main/ipc/stt-handlers.ts
|
||||||
|
|
||||||
|
import { ipcMain } from 'electron'
|
||||||
|
import { IPC_CHANNELS } from '@shared/ipc-channels'
|
||||||
|
import { ipcSuccess, ipcError, ErrorCode } from '@shared/errors'
|
||||||
|
import { getLocalSTTService } from '../services/LocalSTTService'
|
||||||
|
import { configGet, configSet } from '../services/ConfigService'
|
||||||
|
import type { SetSTTModelParams, SetSTTLanguageParams } from '@shared/types'
|
||||||
|
|
||||||
|
export function registerSTTHandlers(): void {
|
||||||
|
ipcMain.handle(IPC_CHANNELS.STT.GET_STATUS, async () => {
|
||||||
|
try {
|
||||||
|
const stt = getLocalSTTService()
|
||||||
|
return ipcSuccess(stt.getStatus())
|
||||||
|
} catch {
|
||||||
|
return ipcError(ErrorCode.STTSidecarCommunicationFailed, 'Failed to get STT status')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.STT.GET_MODELS, async () => {
|
||||||
|
try {
|
||||||
|
const stt = getLocalSTTService()
|
||||||
|
return ipcSuccess(await stt.getModels())
|
||||||
|
} catch {
|
||||||
|
return ipcError(ErrorCode.STTModelNotFound, 'Failed to get models')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.STT.GET_ACTIVE_MODEL, async () => {
|
||||||
|
return ipcSuccess(configGet('sttModelId'))
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.STT.SET_MODEL, async (_event, params: SetSTTModelParams) => {
|
||||||
|
try {
|
||||||
|
configSet('sttModelId', params.modelId)
|
||||||
|
const stt = getLocalSTTService()
|
||||||
|
await stt.initialize(params.modelId)
|
||||||
|
return ipcSuccess(undefined)
|
||||||
|
} catch {
|
||||||
|
return ipcError(ErrorCode.STTModelLoadFailed, 'Failed to set STT model')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.STT.GET_LANGUAGE, async () => {
|
||||||
|
return ipcSuccess(configGet('sttLanguage'))
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.STT.SET_LANGUAGE, async (_event, params: SetSTTLanguageParams) => {
|
||||||
|
configSet('sttLanguage', params.language)
|
||||||
|
return ipcSuccess(undefined)
|
||||||
|
})
|
||||||
|
}
|
||||||
28
src/main/ipc/system-handlers.ts
Normal file
28
src/main/ipc/system-handlers.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
// src/main/ipc/system-handlers.ts
|
||||||
|
|
||||||
|
import { ipcMain, app, systemPreferences } from 'electron'
|
||||||
|
import { IPC_CHANNELS } from '@shared/ipc-channels'
|
||||||
|
import { ipcSuccess } from '@shared/errors'
|
||||||
|
import type { PermissionStatus } from '@shared/types'
|
||||||
|
|
||||||
|
export function registerSystemHandlers(): void {
|
||||||
|
ipcMain.handle(IPC_CHANNELS.SYSTEM.GET_PLATFORM, async () => {
|
||||||
|
return ipcSuccess(process.platform)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.SYSTEM.GET_VERSION, async () => {
|
||||||
|
return ipcSuccess(app.getVersion())
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.SYSTEM.CHECK_MIC_PERMISSION, async () => {
|
||||||
|
let status: PermissionStatus = 'unknown'
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
const macStatus = systemPreferences.getMediaAccessStatus('microphone')
|
||||||
|
status = macStatus === 'granted' ? 'granted' : macStatus === 'denied' ? 'denied' : 'unknown'
|
||||||
|
} else {
|
||||||
|
// Windows: 마이크 권한은 별도 체크 불필요 (시스템 설정에서 관리)
|
||||||
|
status = 'granted'
|
||||||
|
}
|
||||||
|
return ipcSuccess(status)
|
||||||
|
})
|
||||||
|
}
|
||||||
53
src/main/ipc/voice-handlers.ts
Normal file
53
src/main/ipc/voice-handlers.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
// src/main/ipc/voice-handlers.ts
|
||||||
|
|
||||||
|
import { ipcMain } from 'electron'
|
||||||
|
import { IPC_CHANNELS } from '@shared/ipc-channels'
|
||||||
|
import { ipcSuccess, ipcError, ErrorCode } from '@shared/errors'
|
||||||
|
import { getVoiceModeService } from '../services/VoiceModeService'
|
||||||
|
import type { StartRecordingParams, StopRecordingParams, CancelRecordingParams, SetVoiceModeParams } from '@shared/types'
|
||||||
|
|
||||||
|
export function registerVoiceHandlers(): void {
|
||||||
|
ipcMain.handle(IPC_CHANNELS.VOICE.START_RECORDING, async (_event, params: StartRecordingParams) => {
|
||||||
|
try {
|
||||||
|
const voice = getVoiceModeService()
|
||||||
|
await voice.startSession('dictation')
|
||||||
|
const session = voice.currentSession
|
||||||
|
return ipcSuccess({ sessionId: session?.id ?? params.sessionId ?? '' })
|
||||||
|
} catch {
|
||||||
|
return ipcError(ErrorCode.AudioCaptureStartFailed, 'Failed to start recording')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.VOICE.STOP_RECORDING, async (_event, params: StopRecordingParams) => {
|
||||||
|
try {
|
||||||
|
const voice = getVoiceModeService()
|
||||||
|
await voice.stopSession()
|
||||||
|
return ipcSuccess({
|
||||||
|
sessionId: params.sessionId,
|
||||||
|
text: voice.currentSession?.transcription ?? '',
|
||||||
|
durationMs: voice.currentSession ? Date.now() - voice.currentSession.startedAt : 0
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return ipcError(ErrorCode.STTTranscriptionFailed, 'Failed to stop recording')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.VOICE.CANCEL_RECORDING, async (_event, _params: CancelRecordingParams) => {
|
||||||
|
getVoiceModeService().cancelSession()
|
||||||
|
return ipcSuccess(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.VOICE.GET_STATE, async () => {
|
||||||
|
return ipcSuccess(getVoiceModeService().getState())
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.VOICE.SET_MODE, async (_event, params: SetVoiceModeParams) => {
|
||||||
|
// Phase 2: 모드만 설정에 저장 (실제 모드 전환은 핫키에서 처리)
|
||||||
|
return ipcSuccess(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.VOICE.GET_MODE, async () => {
|
||||||
|
const voice = getVoiceModeService()
|
||||||
|
return ipcSuccess(voice.currentSession?.mode ?? 'dictation')
|
||||||
|
})
|
||||||
|
}
|
||||||
31
src/main/ipc/window-handlers.ts
Normal file
31
src/main/ipc/window-handlers.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
// src/main/ipc/window-handlers.ts
|
||||||
|
|
||||||
|
import { ipcMain } from 'electron'
|
||||||
|
import { IPC_CHANNELS } from '@shared/ipc-channels'
|
||||||
|
import { ipcSuccess } from '@shared/errors'
|
||||||
|
import { getMainWindow } from '../windows/WindowManager'
|
||||||
|
|
||||||
|
export function registerWindowHandlers(): void {
|
||||||
|
ipcMain.on(IPC_CHANNELS.WINDOW.MINIMIZE, () => {
|
||||||
|
getMainWindow()?.minimize()
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.on(IPC_CHANNELS.WINDOW.MAXIMIZE, () => {
|
||||||
|
const win = getMainWindow()
|
||||||
|
if (win) {
|
||||||
|
if (win.isMaximized()) {
|
||||||
|
win.unmaximize()
|
||||||
|
} else {
|
||||||
|
win.maximize()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.on(IPC_CHANNELS.WINDOW.CLOSE, () => {
|
||||||
|
getMainWindow()?.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.WINDOW.IS_MAXIMIZED, async () => {
|
||||||
|
return ipcSuccess(getMainWindow()?.isMaximized() ?? false)
|
||||||
|
})
|
||||||
|
}
|
||||||
37
src/main/lifecycle.ts
Normal file
37
src/main/lifecycle.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
// src/main/lifecycle.ts — 종료 시퀀스
|
||||||
|
|
||||||
|
import { app } from 'electron'
|
||||||
|
import { getLogger } from './services/LoggerService'
|
||||||
|
import { getConfigService } from './services/ConfigService'
|
||||||
|
|
||||||
|
const logger = getLogger('lifecycle')
|
||||||
|
|
||||||
|
let isQuitting = false
|
||||||
|
|
||||||
|
export function getIsQuitting(): boolean {
|
||||||
|
return isQuitting
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setIsQuitting(value: boolean): void {
|
||||||
|
isQuitting = value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupLifecycle(): void {
|
||||||
|
app.on('before-quit', () => {
|
||||||
|
logger.info('[lifecycle] before-quit')
|
||||||
|
isQuitting = true
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('will-quit', () => {
|
||||||
|
logger.info('[lifecycle] will-quit — flushing config')
|
||||||
|
try {
|
||||||
|
const configService = getConfigService()
|
||||||
|
if (configService) {
|
||||||
|
// electron-store는 동기적으로 디스크에 쓰므로 별도 flush 불필요
|
||||||
|
logger.info('[lifecycle] config flush complete')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('[lifecycle] config flush failed:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
365
src/main/services/AudioCaptureService.ts
Normal file
365
src/main/services/AudioCaptureService.ts
Normal file
|
|
@ -0,0 +1,365 @@
|
||||||
|
// src/main/services/AudioCaptureService.ts
|
||||||
|
// 마이크 PCM 캡처 서비스. 설계서 01의 IAudioCaptureService 구현.
|
||||||
|
// node-record-lpcm16 + SoX로 PCM16 16kHz mono 캡처.
|
||||||
|
|
||||||
|
import { EventEmitter } from 'events'
|
||||||
|
import { record } from 'node-record-lpcm16'
|
||||||
|
import type { Recording } from 'node-record-lpcm16'
|
||||||
|
import type { Readable } from 'stream'
|
||||||
|
import { getLogger } from './LoggerService'
|
||||||
|
import { configGet } from './ConfigService'
|
||||||
|
import type { AudioDevice } from '@shared/types'
|
||||||
|
import { AUDIO_FORMAT, TIMING } from '@shared/constants'
|
||||||
|
import { D3ROError, ErrorCode } from '@shared/errors'
|
||||||
|
|
||||||
|
const logger = getLogger('AudioCaptureService')
|
||||||
|
|
||||||
|
/** 60ms 프레임 크기 (바이트): 16000 * 2 * 0.06 = 1920 */
|
||||||
|
const FRAME_SIZE_BYTES = (AUDIO_FORMAT.SAMPLE_RATE * AUDIO_FORMAT.BYTES_PER_SAMPLE * 60) / 1000
|
||||||
|
|
||||||
|
export type CaptureState = 'idle' | 'starting' | 'capturing' | 'stopping' | 'error'
|
||||||
|
|
||||||
|
interface AudioCaptureEvents {
|
||||||
|
'audio-data': (payload: { buffer: Buffer; timestamp: number }) => void
|
||||||
|
'audio-level': (payload: { level: number; timestamp: number }) => void
|
||||||
|
'device-changed': (payload: {
|
||||||
|
previous: AudioDevice | null
|
||||||
|
current: AudioDevice
|
||||||
|
}) => void
|
||||||
|
started: (payload: { deviceId: string }) => void
|
||||||
|
stopped: (payload: { reason: 'manual' | 'device-lost' | 'error' }) => void
|
||||||
|
error: (payload: { error: D3ROError }) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PCM16 버퍼에서 RMS(Root Mean Square) 오디오 레벨을 계산한다.
|
||||||
|
* 반환값은 0.0 ~ 1.0 범위로 정규화된다.
|
||||||
|
*/
|
||||||
|
function calculateRMS(buffer: Buffer): number {
|
||||||
|
const samples = buffer.length / AUDIO_FORMAT.BYTES_PER_SAMPLE
|
||||||
|
if (samples === 0) return 0
|
||||||
|
|
||||||
|
let sumSquares = 0
|
||||||
|
for (let i = 0; i < buffer.length; i += AUDIO_FORMAT.BYTES_PER_SAMPLE) {
|
||||||
|
const sample = buffer.readInt16LE(i)
|
||||||
|
sumSquares += sample * sample
|
||||||
|
}
|
||||||
|
|
||||||
|
const rms = Math.sqrt(sumSquares / samples)
|
||||||
|
// PCM16 최대값 32768로 나눠서 0.0~1.0 범위로 정규화
|
||||||
|
return Math.min(1.0, rms / 32768)
|
||||||
|
}
|
||||||
|
|
||||||
|
class AudioCaptureService extends EventEmitter {
|
||||||
|
private _state: CaptureState = 'idle'
|
||||||
|
private _currentDevice: AudioDevice | null = null
|
||||||
|
private _refCount = 0
|
||||||
|
private _recording: Recording | null = null
|
||||||
|
private _stream: Readable | null = null
|
||||||
|
private _levelInterval: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
/** 프레임 조립용 잔여 바이트 버퍼 */
|
||||||
|
private _residualBuffer: Buffer = Buffer.alloc(0)
|
||||||
|
|
||||||
|
/** RMS 계산용 누적 버퍼 (100ms 간격 emit) */
|
||||||
|
private _levelAccumulator: Buffer[] = []
|
||||||
|
|
||||||
|
get state(): CaptureState {
|
||||||
|
return this._state
|
||||||
|
}
|
||||||
|
|
||||||
|
get currentDevice(): AudioDevice | null {
|
||||||
|
return this._currentDevice
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(deviceId?: string): Promise<void> {
|
||||||
|
if (this._state === 'capturing') {
|
||||||
|
this._refCount++
|
||||||
|
logger.debug(`Reference count increased to ${this._refCount}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._state !== 'idle' && this._state !== 'error') {
|
||||||
|
logger.warn(`Cannot start capture in state: ${this._state}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this._state = 'starting'
|
||||||
|
const selectedDeviceId = deviceId ?? configGet('selectedDeviceId')
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info(
|
||||||
|
`Starting audio capture (device: ${selectedDeviceId ?? 'default'}, ` +
|
||||||
|
`format: ${AUDIO_FORMAT.SAMPLE_RATE}Hz ${AUDIO_FORMAT.CHANNELS}ch ${AUDIO_FORMAT.BIT_DEPTH}bit)`
|
||||||
|
)
|
||||||
|
|
||||||
|
// node-record-lpcm16 으로 SoX rec 프로세스 spawn
|
||||||
|
const recordingOptions: Record<string, unknown> = {
|
||||||
|
sampleRate: AUDIO_FORMAT.SAMPLE_RATE,
|
||||||
|
channels: AUDIO_FORMAT.CHANNELS,
|
||||||
|
recorder: 'sox',
|
||||||
|
audioType: 'raw', // 헤더 없는 PCM raw 출력
|
||||||
|
endOnSilence: false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 특정 디바이스가 지정된 경우 AUDIODEV 환경변수로 전달
|
||||||
|
if (selectedDeviceId && selectedDeviceId !== 'default') {
|
||||||
|
recordingOptions.device = selectedDeviceId
|
||||||
|
}
|
||||||
|
|
||||||
|
this._recording = record(recordingOptions)
|
||||||
|
this._stream = this._recording.stream()
|
||||||
|
this._residualBuffer = Buffer.alloc(0)
|
||||||
|
this._levelAccumulator = []
|
||||||
|
|
||||||
|
// 스트림 데이터 수신: 60ms 프레임 단위로 잘라 emit
|
||||||
|
this._stream.on('data', (chunk: Buffer) => {
|
||||||
|
this._onAudioChunk(chunk)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 스트림 에러 처리
|
||||||
|
this._stream.on('error', (errorMessage: string | Error) => {
|
||||||
|
const msg = typeof errorMessage === 'string' ? errorMessage : errorMessage.message
|
||||||
|
logger.error(`Audio stream error: ${msg}`)
|
||||||
|
|
||||||
|
// SoX 미설치 감지
|
||||||
|
const isSoxMissing =
|
||||||
|
msg.includes('ENOENT') ||
|
||||||
|
msg.includes('not found') ||
|
||||||
|
msg.includes('is not recognized')
|
||||||
|
|
||||||
|
const errorCode = isSoxMissing
|
||||||
|
? ErrorCode.AudioCaptureStartFailed
|
||||||
|
: ErrorCode.AudioStreamError
|
||||||
|
|
||||||
|
const d3roError = new D3ROError(
|
||||||
|
errorCode,
|
||||||
|
isSoxMissing
|
||||||
|
? 'SoX가 설치되어 있지 않거나 PATH에 없습니다. SoX를 설치해주세요: https://sox.sourceforge.net'
|
||||||
|
: `Audio stream error: ${msg}`
|
||||||
|
)
|
||||||
|
|
||||||
|
this._handleError(d3roError, 'error')
|
||||||
|
})
|
||||||
|
|
||||||
|
// SoX 프로세스 종료 감지
|
||||||
|
this._stream.on('end', () => {
|
||||||
|
if (this._state === 'capturing') {
|
||||||
|
logger.warn('Audio stream ended unexpectedly')
|
||||||
|
this._handleError(
|
||||||
|
new D3ROError(ErrorCode.AudioCaptureFailed, 'Audio capture process terminated unexpectedly'),
|
||||||
|
'device-lost'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// SoX child process 에러 이벤트 (spawn 실패 등)
|
||||||
|
if (this._recording.process) {
|
||||||
|
this._recording.process.on('error', (err: Error) => {
|
||||||
|
logger.error(`SoX process spawn error: ${err.message}`)
|
||||||
|
|
||||||
|
const d3roError = new D3ROError(
|
||||||
|
ErrorCode.AudioCaptureStartFailed,
|
||||||
|
`SoX 프로세스 시작 실패: ${err.message}. SoX가 설치되어 있는지 확인해주세요.`
|
||||||
|
)
|
||||||
|
|
||||||
|
this._handleError(d3roError, 'error')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
this._currentDevice = {
|
||||||
|
deviceId: selectedDeviceId ?? 'default',
|
||||||
|
label: 'Default Microphone',
|
||||||
|
isDefault: !selectedDeviceId || selectedDeviceId === 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
this._state = 'capturing'
|
||||||
|
this._refCount = 1
|
||||||
|
|
||||||
|
// 100ms 간격으로 audio-level 이벤트 emit
|
||||||
|
this._levelInterval = setInterval(() => {
|
||||||
|
this._emitAudioLevel()
|
||||||
|
}, TIMING.AUDIO_LEVEL_INTERVAL)
|
||||||
|
|
||||||
|
this.emit('started', { deviceId: this._currentDevice.deviceId })
|
||||||
|
logger.info('Audio capture started')
|
||||||
|
} catch (error) {
|
||||||
|
this._state = 'error'
|
||||||
|
const d3roError = new D3ROError(
|
||||||
|
ErrorCode.AudioCaptureStartFailed,
|
||||||
|
`Failed to start audio capture: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
this.emit('error', { error: d3roError })
|
||||||
|
throw d3roError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
if (this._state !== 'capturing') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this._refCount--
|
||||||
|
if (this._refCount > 0) {
|
||||||
|
logger.debug(`Reference count decreased to ${this._refCount}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this._state = 'stopping'
|
||||||
|
this._cleanup()
|
||||||
|
this._state = 'idle'
|
||||||
|
this._currentDevice = null
|
||||||
|
this.emit('stopped', { reason: 'manual' })
|
||||||
|
logger.info('Audio capture stopped')
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDevices(): Promise<AudioDevice[]> {
|
||||||
|
// 현재 Phase에서는 기본 디바이스만 반환. 실제 디바이스 열거는 추후.
|
||||||
|
logger.debug('Getting audio devices (default only)')
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
deviceId: 'default',
|
||||||
|
label: 'Default Microphone',
|
||||||
|
isDefault: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentDevice(): AudioDevice | null {
|
||||||
|
return this._currentDevice
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this._cleanup()
|
||||||
|
this._state = 'idle'
|
||||||
|
this._currentDevice = null
|
||||||
|
this._refCount = 0
|
||||||
|
this.removeAllListeners()
|
||||||
|
logger.info('AudioCaptureService disposed')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 수신된 오디오 청크를 60ms 프레임(1920 bytes) 단위로 분할하여 emit한다.
|
||||||
|
* 잔여 바이트는 다음 청크와 결합한다.
|
||||||
|
*/
|
||||||
|
private _onAudioChunk(chunk: Buffer): void {
|
||||||
|
// 잔여 버퍼와 새 청크를 결합
|
||||||
|
const combined = this._residualBuffer.length > 0
|
||||||
|
? Buffer.concat([this._residualBuffer, chunk])
|
||||||
|
: chunk
|
||||||
|
|
||||||
|
let offset = 0
|
||||||
|
|
||||||
|
// 60ms 프레임 단위로 분할하여 emit
|
||||||
|
while (offset + FRAME_SIZE_BYTES <= combined.length) {
|
||||||
|
const frame = combined.subarray(offset, offset + FRAME_SIZE_BYTES)
|
||||||
|
offset += FRAME_SIZE_BYTES
|
||||||
|
|
||||||
|
this.emit('audio-data', {
|
||||||
|
buffer: Buffer.from(frame), // 방어적 복사
|
||||||
|
timestamp: Date.now()
|
||||||
|
})
|
||||||
|
|
||||||
|
// RMS 계산용 누적
|
||||||
|
this._levelAccumulator.push(frame)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 남은 바이트는 잔여 버퍼에 보관
|
||||||
|
if (offset < combined.length) {
|
||||||
|
this._residualBuffer = Buffer.from(combined.subarray(offset))
|
||||||
|
} else {
|
||||||
|
this._residualBuffer = Buffer.alloc(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 100ms 간격으로 누적된 오디오 데이터의 RMS 레벨을 계산하여 emit한다.
|
||||||
|
*/
|
||||||
|
private _emitAudioLevel(): void {
|
||||||
|
if (this._levelAccumulator.length === 0) {
|
||||||
|
this.emit('audio-level', { level: 0, timestamp: Date.now() })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 누적된 프레임들을 하나로 합쳐서 RMS 계산
|
||||||
|
const combined = Buffer.concat(this._levelAccumulator)
|
||||||
|
this._levelAccumulator = []
|
||||||
|
|
||||||
|
const level = calculateRMS(combined)
|
||||||
|
this.emit('audio-level', { level, timestamp: Date.now() })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 에러 발생 시 리소스 정리 + 에러 이벤트 emit.
|
||||||
|
*/
|
||||||
|
private _handleError(error: D3ROError, stopReason: 'device-lost' | 'error'): void {
|
||||||
|
if (this._state === 'idle' || this._state === 'stopping') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this._cleanup()
|
||||||
|
this._state = 'error'
|
||||||
|
this._currentDevice = null
|
||||||
|
this.emit('error', { error })
|
||||||
|
this.emit('stopped', { reason: stopReason })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 녹음 프로세스와 타이머를 정리한다.
|
||||||
|
*/
|
||||||
|
private _cleanup(): void {
|
||||||
|
if (this._levelInterval) {
|
||||||
|
clearInterval(this._levelInterval)
|
||||||
|
this._levelInterval = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._stream) {
|
||||||
|
this._stream.removeAllListeners()
|
||||||
|
this._stream = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._recording) {
|
||||||
|
try {
|
||||||
|
this._recording.stop()
|
||||||
|
} catch {
|
||||||
|
// 이미 종료된 프로세스 kill 시 에러 무시
|
||||||
|
}
|
||||||
|
this._recording = null
|
||||||
|
}
|
||||||
|
|
||||||
|
this._residualBuffer = Buffer.alloc(0)
|
||||||
|
this._levelAccumulator = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// EventEmitter 타입 오버라이드
|
||||||
|
override on<K extends keyof AudioCaptureEvents>(
|
||||||
|
event: K,
|
||||||
|
listener: AudioCaptureEvents[K]
|
||||||
|
): this {
|
||||||
|
return super.on(event, listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
override off<K extends keyof AudioCaptureEvents>(
|
||||||
|
event: K,
|
||||||
|
listener: AudioCaptureEvents[K]
|
||||||
|
): this {
|
||||||
|
return super.off(event, listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
override emit<K extends keyof AudioCaptureEvents>(
|
||||||
|
event: K,
|
||||||
|
...args: Parameters<AudioCaptureEvents[K]>
|
||||||
|
): boolean {
|
||||||
|
return super.emit(event, ...args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 싱글톤
|
||||||
|
let instance: AudioCaptureService | null = null
|
||||||
|
|
||||||
|
export function getAudioCaptureService(): AudioCaptureService {
|
||||||
|
if (!instance) {
|
||||||
|
instance = new AudioCaptureService()
|
||||||
|
}
|
||||||
|
return instance
|
||||||
|
}
|
||||||
119
src/main/services/ConfigService.ts
Normal file
119
src/main/services/ConfigService.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
// src/main/services/ConfigService.ts
|
||||||
|
// electron-store 기반 설정 관리. 설계서 02의 AppConfig 타입 사용.
|
||||||
|
|
||||||
|
import { EventEmitter } from 'events'
|
||||||
|
import type { AppConfig, ConfigChangedEvent } from '@shared/types'
|
||||||
|
import { getLogger } from './LoggerService'
|
||||||
|
|
||||||
|
const logger = getLogger('ConfigService')
|
||||||
|
|
||||||
|
// electron-store v10은 ESM 전용이므로 동적 import 필요
|
||||||
|
interface ElectronStore<T extends Record<string, unknown>> {
|
||||||
|
get<K extends keyof T>(key: K): T[K]
|
||||||
|
set<K extends keyof T>(key: K, value: T[K]): void
|
||||||
|
store: T
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONFIG_DEFAULTS: AppConfig = {
|
||||||
|
theme: 'auto',
|
||||||
|
language: 'ko',
|
||||||
|
closeToTray: true,
|
||||||
|
autoLaunch: false,
|
||||||
|
soundEnabled: true,
|
||||||
|
selectedDeviceId: null,
|
||||||
|
sttModelId: 'base',
|
||||||
|
sttLanguage: 'auto',
|
||||||
|
ttsVoiceId: null,
|
||||||
|
ttsSpeed: 1.0,
|
||||||
|
ollamaServerUrl: 'http://localhost:11434',
|
||||||
|
llmModelId: null,
|
||||||
|
defaultLLMAction: 'refine',
|
||||||
|
dictationShortcut: {
|
||||||
|
keyCode: 0xa5, // Right Alt
|
||||||
|
ctrl: false,
|
||||||
|
alt: false,
|
||||||
|
shift: false,
|
||||||
|
meta: false,
|
||||||
|
displayLabel: 'Right Alt'
|
||||||
|
},
|
||||||
|
handsFreeShortcut: {
|
||||||
|
keyCode: 0xa5,
|
||||||
|
ctrl: false,
|
||||||
|
alt: false,
|
||||||
|
shift: false,
|
||||||
|
meta: false,
|
||||||
|
displayLabel: 'Right Alt (double)'
|
||||||
|
},
|
||||||
|
commandShortcut: {
|
||||||
|
keyCode: 0xa5,
|
||||||
|
ctrl: true,
|
||||||
|
alt: false,
|
||||||
|
shift: false,
|
||||||
|
meta: false,
|
||||||
|
displayLabel: 'Ctrl + Right Alt'
|
||||||
|
},
|
||||||
|
hotkeyEnabled: true,
|
||||||
|
insertMethod: 'clipboard',
|
||||||
|
autoInsert: true,
|
||||||
|
maxHistoryEntries: 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
let store: ElectronStore<AppConfig> | null = null
|
||||||
|
const emitter = new EventEmitter()
|
||||||
|
|
||||||
|
export async function initConfigService(): Promise<void> {
|
||||||
|
const { default: Store } = await import('electron-store')
|
||||||
|
store = new Store<AppConfig>({
|
||||||
|
name: 'd3ro-voice-config',
|
||||||
|
defaults: CONFIG_DEFAULTS
|
||||||
|
})
|
||||||
|
logger.info('ConfigService initialized')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConfigService(): ElectronStore<AppConfig> | null {
|
||||||
|
return store
|
||||||
|
}
|
||||||
|
|
||||||
|
export function configGet<K extends keyof AppConfig>(key: K): AppConfig[K] {
|
||||||
|
if (!store) {
|
||||||
|
logger.warn(`ConfigService not initialized, returning default for "${key}"`)
|
||||||
|
return CONFIG_DEFAULTS[key]
|
||||||
|
}
|
||||||
|
return store.get(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function configSet<K extends keyof AppConfig>(key: K, value: AppConfig[K]): void {
|
||||||
|
if (!store) {
|
||||||
|
logger.warn(`ConfigService not initialized, cannot set "${key}"`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const previousValue = store.get(key)
|
||||||
|
store.set(key, value)
|
||||||
|
|
||||||
|
const event: ConfigChangedEvent = {
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
previousValue
|
||||||
|
}
|
||||||
|
emitter.emit('config-changed', event)
|
||||||
|
logger.debug(`Config changed: ${key}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function configGetAll(): AppConfig {
|
||||||
|
if (!store) return { ...CONFIG_DEFAULTS }
|
||||||
|
return store.store
|
||||||
|
}
|
||||||
|
|
||||||
|
export function configReset(key?: keyof AppConfig): void {
|
||||||
|
if (!store) return
|
||||||
|
if (key) {
|
||||||
|
store.set(key, CONFIG_DEFAULTS[key])
|
||||||
|
} else {
|
||||||
|
store.store = { ...CONFIG_DEFAULTS }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onConfigChanged(callback: (event: ConfigChangedEvent) => void): () => void {
|
||||||
|
emitter.on('config-changed', callback)
|
||||||
|
return () => emitter.off('config-changed', callback)
|
||||||
|
}
|
||||||
536
src/main/services/HotkeyService.ts
Normal file
536
src/main/services/HotkeyService.ts
Normal file
|
|
@ -0,0 +1,536 @@
|
||||||
|
// src/main/services/HotkeyService.ts
|
||||||
|
// uiohook-napi 기반 글로벌 키보드 후킹 서비스.
|
||||||
|
// 설계서 01의 IHotkeyService 구현. Speakly HotkeyService + HotkeyConfig 패턴 참조.
|
||||||
|
|
||||||
|
import { EventEmitter } from 'events'
|
||||||
|
import { uIOhook, UiohookKey } from 'uiohook-napi'
|
||||||
|
import type { UiohookKeyboardEvent } from 'uiohook-napi'
|
||||||
|
import { getLogger } from './LoggerService'
|
||||||
|
import { configGet } from './ConfigService'
|
||||||
|
import { D3ROError, ErrorCode } from '@shared/errors'
|
||||||
|
import { TIMING } from '@shared/constants'
|
||||||
|
import type { HotkeyBinding } from '@shared/types'
|
||||||
|
|
||||||
|
const logger = getLogger('HotkeyService')
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 내부 타입
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export interface HotkeyConfig {
|
||||||
|
/** 핫키 식별자 (예: 'voice-dictation', 'voice-handsfree') */
|
||||||
|
id: string
|
||||||
|
/** uiohook 키코드 */
|
||||||
|
keyCode: number
|
||||||
|
/** 수정자 키 목록 */
|
||||||
|
modifiers: HotkeyModifier[]
|
||||||
|
/** true=hold-to-talk (누르고 있는 동안 활성), false=toggle */
|
||||||
|
holdMode: boolean
|
||||||
|
/** 더블프레스 감지 활성화 여부 */
|
||||||
|
doublePressEnabled: boolean
|
||||||
|
/** 활성화 여부 */
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HotkeyModifier = 'ctrl' | 'alt' | 'shift' | 'meta'
|
||||||
|
|
||||||
|
interface HotkeyServiceEvents {
|
||||||
|
'hotkey-pressed': (payload: { config: HotkeyConfig; timestamp: number }) => void
|
||||||
|
'hotkey-released': (payload: {
|
||||||
|
config: HotkeyConfig
|
||||||
|
durationMs: number
|
||||||
|
timestamp: number
|
||||||
|
}) => void
|
||||||
|
'double-press': (payload: {
|
||||||
|
config: HotkeyConfig
|
||||||
|
intervalMs: number
|
||||||
|
timestamp: number
|
||||||
|
}) => void
|
||||||
|
error: (payload: { error: D3ROError }) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Windows VK 코드 → uiohook 키코드 매핑
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Windows Virtual-Key 코드를 uiohook-napi 키코드로 변환한다.
|
||||||
|
* ConfigService에 저장된 HotkeyBinding.keyCode는 Windows VK 코드이므로
|
||||||
|
* uiohook 이벤트와 비교하려면 변환이 필요하다.
|
||||||
|
*/
|
||||||
|
const VK_TO_UIOHOOK: ReadonlyMap<number, number> = new Map([
|
||||||
|
// 수정자 키
|
||||||
|
[0xa0, UiohookKey.Shift], // VK_LSHIFT
|
||||||
|
[0xa1, UiohookKey.ShiftRight], // VK_RSHIFT
|
||||||
|
[0xa2, UiohookKey.Ctrl], // VK_LCONTROL
|
||||||
|
[0xa3, UiohookKey.CtrlRight], // VK_RCONTROL
|
||||||
|
[0xa4, UiohookKey.Alt], // VK_LMENU
|
||||||
|
[0xa5, UiohookKey.AltRight], // VK_RMENU (Right Alt)
|
||||||
|
[0x5b, UiohookKey.Meta], // VK_LWIN
|
||||||
|
[0x5c, UiohookKey.MetaRight], // VK_RWIN
|
||||||
|
|
||||||
|
// 기능 키
|
||||||
|
[0x70, UiohookKey.F1],
|
||||||
|
[0x71, UiohookKey.F2],
|
||||||
|
[0x72, UiohookKey.F3],
|
||||||
|
[0x73, UiohookKey.F4],
|
||||||
|
[0x74, UiohookKey.F5],
|
||||||
|
[0x75, UiohookKey.F6],
|
||||||
|
[0x76, UiohookKey.F7],
|
||||||
|
[0x77, UiohookKey.F8],
|
||||||
|
[0x78, UiohookKey.F9],
|
||||||
|
[0x79, UiohookKey.F10],
|
||||||
|
[0x7a, UiohookKey.F11],
|
||||||
|
[0x7b, UiohookKey.F12],
|
||||||
|
|
||||||
|
// 일반 키
|
||||||
|
[0x20, UiohookKey.Space],
|
||||||
|
[0x0d, UiohookKey.Enter],
|
||||||
|
[0x1b, UiohookKey.Escape],
|
||||||
|
[0x08, UiohookKey.Backspace],
|
||||||
|
[0x09, UiohookKey.Tab],
|
||||||
|
[0x2d, UiohookKey.Insert],
|
||||||
|
[0x2e, UiohookKey.Delete],
|
||||||
|
[0x24, UiohookKey.Home],
|
||||||
|
[0x23, UiohookKey.End],
|
||||||
|
[0x21, UiohookKey.PageUp],
|
||||||
|
[0x22, UiohookKey.PageDown],
|
||||||
|
[0x25, UiohookKey.ArrowLeft],
|
||||||
|
[0x26, UiohookKey.ArrowUp],
|
||||||
|
[0x27, UiohookKey.ArrowRight],
|
||||||
|
[0x28, UiohookKey.ArrowDown],
|
||||||
|
|
||||||
|
// 알파벳 (VK_A=0x41 ~ VK_Z=0x5A)
|
||||||
|
[0x41, UiohookKey.A],
|
||||||
|
[0x42, UiohookKey.B],
|
||||||
|
[0x43, UiohookKey.C],
|
||||||
|
[0x44, UiohookKey.D],
|
||||||
|
[0x45, UiohookKey.E],
|
||||||
|
[0x46, UiohookKey.F],
|
||||||
|
[0x47, UiohookKey.G],
|
||||||
|
[0x48, UiohookKey.H],
|
||||||
|
[0x49, UiohookKey.I],
|
||||||
|
[0x4a, UiohookKey.J],
|
||||||
|
[0x4b, UiohookKey.K],
|
||||||
|
[0x4c, UiohookKey.L],
|
||||||
|
[0x4d, UiohookKey.M],
|
||||||
|
[0x4e, UiohookKey.N],
|
||||||
|
[0x4f, UiohookKey.O],
|
||||||
|
[0x50, UiohookKey.P],
|
||||||
|
[0x51, UiohookKey.Q],
|
||||||
|
[0x52, UiohookKey.R],
|
||||||
|
[0x53, UiohookKey.S],
|
||||||
|
[0x54, UiohookKey.T],
|
||||||
|
[0x55, UiohookKey.U],
|
||||||
|
[0x56, UiohookKey.V],
|
||||||
|
[0x57, UiohookKey.W],
|
||||||
|
[0x58, UiohookKey.X],
|
||||||
|
[0x59, UiohookKey.Y],
|
||||||
|
[0x5a, UiohookKey.Z],
|
||||||
|
|
||||||
|
// 숫자 (VK_0=0x30 ~ VK_9=0x39)
|
||||||
|
[0x30, UiohookKey['0']],
|
||||||
|
[0x31, UiohookKey['1']],
|
||||||
|
[0x32, UiohookKey['2']],
|
||||||
|
[0x33, UiohookKey['3']],
|
||||||
|
[0x34, UiohookKey['4']],
|
||||||
|
[0x35, UiohookKey['5']],
|
||||||
|
[0x36, UiohookKey['6']],
|
||||||
|
[0x37, UiohookKey['7']],
|
||||||
|
[0x38, UiohookKey['8']],
|
||||||
|
[0x39, UiohookKey['9']]
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HotkeyBinding(Windows VK 코드)을 HotkeyConfig(uiohook 키코드)로 변환한다.
|
||||||
|
*/
|
||||||
|
function bindingToConfig(
|
||||||
|
id: string,
|
||||||
|
binding: HotkeyBinding,
|
||||||
|
holdMode: boolean,
|
||||||
|
doublePressEnabled: boolean
|
||||||
|
): HotkeyConfig {
|
||||||
|
const uiohookKeyCode = VK_TO_UIOHOOK.get(binding.keyCode)
|
||||||
|
|
||||||
|
if (uiohookKeyCode === undefined) {
|
||||||
|
logger.warn(
|
||||||
|
`Unknown VK code 0x${binding.keyCode.toString(16)} for hotkey "${id}", ` +
|
||||||
|
`using raw value ${binding.keyCode}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const modifiers: HotkeyModifier[] = []
|
||||||
|
if (binding.ctrl) modifiers.push('ctrl')
|
||||||
|
if (binding.alt) modifiers.push('alt')
|
||||||
|
if (binding.shift) modifiers.push('shift')
|
||||||
|
if (binding.meta) modifiers.push('meta')
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
keyCode: uiohookKeyCode ?? binding.keyCode,
|
||||||
|
modifiers,
|
||||||
|
holdMode,
|
||||||
|
doublePressEnabled,
|
||||||
|
enabled: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// HotkeyService 클래스
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
class HotkeyService extends EventEmitter {
|
||||||
|
private _isRunning = false
|
||||||
|
private _registeredHotkeys: Map<string, HotkeyConfig> = new Map()
|
||||||
|
|
||||||
|
/** 더블프레스 감지용: 마지막 press 시각 */
|
||||||
|
private _lastPressTime: Map<string, number> = new Map()
|
||||||
|
|
||||||
|
/** hold duration 계산용: press 시작 시각 */
|
||||||
|
private _pressStartTime: Map<string, number> = new Map()
|
||||||
|
|
||||||
|
/** 키 반복(auto-repeat) 방지: 현재 눌려있는 키 */
|
||||||
|
private _isKeyDown: Map<string, boolean> = new Map()
|
||||||
|
|
||||||
|
/** uiohook 이벤트 핸들러 (바인딩 해제용) */
|
||||||
|
private _onKeyDown: ((e: UiohookKeyboardEvent) => void) | null = null
|
||||||
|
private _onKeyUp: ((e: UiohookKeyboardEvent) => void) | null = null
|
||||||
|
|
||||||
|
get isRunning(): boolean {
|
||||||
|
return this._isRunning
|
||||||
|
}
|
||||||
|
|
||||||
|
get registeredHotkeys(): ReadonlyMap<string, HotkeyConfig> {
|
||||||
|
return this._registeredHotkeys
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* uiohook 글로벌 키보드 후킹을 시작한다.
|
||||||
|
* 이미 실행 중이면 무시.
|
||||||
|
*/
|
||||||
|
start(): void {
|
||||||
|
if (this._isRunning) {
|
||||||
|
logger.warn('HotkeyService already running')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this._onKeyDown = (e: UiohookKeyboardEvent) => this._handleKeyDown(e)
|
||||||
|
this._onKeyUp = (e: UiohookKeyboardEvent) => this._handleKeyUp(e)
|
||||||
|
|
||||||
|
uIOhook.on('keydown', this._onKeyDown)
|
||||||
|
uIOhook.on('keyup', this._onKeyUp)
|
||||||
|
uIOhook.start()
|
||||||
|
|
||||||
|
this._isRunning = true
|
||||||
|
logger.info('uiohook started, global keyboard hook active')
|
||||||
|
} catch (error) {
|
||||||
|
const d3roError = new D3ROError(
|
||||||
|
ErrorCode.HotkeyHookInitFailed,
|
||||||
|
`Failed to start uiohook: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
this.emit('error', { error: d3roError })
|
||||||
|
logger.error(d3roError.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* uiohook 글로벌 키보드 후킹을 중지한다.
|
||||||
|
*/
|
||||||
|
stop(): void {
|
||||||
|
if (!this._isRunning) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
uIOhook.stop()
|
||||||
|
|
||||||
|
if (this._onKeyDown) {
|
||||||
|
uIOhook.removeListener('keydown', this._onKeyDown)
|
||||||
|
this._onKeyDown = null
|
||||||
|
}
|
||||||
|
if (this._onKeyUp) {
|
||||||
|
uIOhook.removeListener('keyup', this._onKeyUp)
|
||||||
|
this._onKeyUp = null
|
||||||
|
}
|
||||||
|
|
||||||
|
this._isRunning = false
|
||||||
|
this._isKeyDown.clear()
|
||||||
|
this._pressStartTime.clear()
|
||||||
|
logger.info('uiohook stopped')
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`Failed to stop uiohook: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 핫키를 등록한다. 동일 ID가 이미 등록되어 있으면 갱신한다.
|
||||||
|
*/
|
||||||
|
registerHotkey(config: HotkeyConfig): void {
|
||||||
|
if (!config.enabled) {
|
||||||
|
logger.debug(`Hotkey "${config.id}" is disabled, skipping registration`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this._registeredHotkeys.set(config.id, config)
|
||||||
|
logger.info(
|
||||||
|
`Hotkey registered: "${config.id}" ` +
|
||||||
|
`(keyCode=${config.keyCode}, modifiers=[${config.modifiers.join(',')}], ` +
|
||||||
|
`holdMode=${config.holdMode}, doublePress=${config.doublePressEnabled})`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 핫키 등록을 해제한다.
|
||||||
|
*/
|
||||||
|
unregisterHotkey(id: string): void {
|
||||||
|
if (this._registeredHotkeys.delete(id)) {
|
||||||
|
this._lastPressTime.delete(id)
|
||||||
|
this._pressStartTime.delete(id)
|
||||||
|
this._isKeyDown.delete(id)
|
||||||
|
logger.info(`Hotkey unregistered: "${id}"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ConfigService에서 핫키 설정을 로드하여 등록한다.
|
||||||
|
*/
|
||||||
|
loadFromConfig(): void {
|
||||||
|
const hotkeyEnabled = configGet('hotkeyEnabled')
|
||||||
|
if (!hotkeyEnabled) {
|
||||||
|
logger.info('Hotkeys disabled in config')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const dictationBinding = configGet('dictationShortcut')
|
||||||
|
const handsFreeBinding = configGet('handsFreeShortcut')
|
||||||
|
const commandBinding = configGet('commandShortcut')
|
||||||
|
|
||||||
|
// 기존 핫키 초기화
|
||||||
|
this._registeredHotkeys.clear()
|
||||||
|
|
||||||
|
// Dictation: hold-to-talk, 더블프레스 비활성
|
||||||
|
this.registerHotkey(
|
||||||
|
bindingToConfig('voice-dictation', dictationBinding, true, false)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Hands-free: toggle, 더블프레스 활성
|
||||||
|
this.registerHotkey(
|
||||||
|
bindingToConfig('voice-handsfree', handsFreeBinding, false, true)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Command: toggle, 더블프레스 비활성
|
||||||
|
this.registerHotkey(
|
||||||
|
bindingToConfig('voice-command', commandBinding, false, false)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Loaded ${this._registeredHotkeys.size} hotkeys from config`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 서비스 리소스를 정리한다.
|
||||||
|
*/
|
||||||
|
dispose(): void {
|
||||||
|
this.stop()
|
||||||
|
this._registeredHotkeys.clear()
|
||||||
|
this._lastPressTime.clear()
|
||||||
|
this._pressStartTime.clear()
|
||||||
|
this._isKeyDown.clear()
|
||||||
|
this.removeAllListeners()
|
||||||
|
logger.info('HotkeyService disposed')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 내부: 키 이벤트 처리
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 키 다운 이벤트를 처리한다.
|
||||||
|
* 등록된 핫키 중 매칭되는 것을 찾아 적절한 이벤트를 발행한다.
|
||||||
|
*/
|
||||||
|
private _handleKeyDown(e: UiohookKeyboardEvent): void {
|
||||||
|
const matched = this._findMatchingHotkey(e)
|
||||||
|
if (!matched) return
|
||||||
|
|
||||||
|
const { id } = matched
|
||||||
|
|
||||||
|
// 키 반복(auto-repeat) 무시: 이미 눌려있으면 건너뜀
|
||||||
|
if (this._isKeyDown.get(id)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this._isKeyDown.set(id, true)
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
// press 시작 시각 기록 (hold duration 계산용)
|
||||||
|
this._pressStartTime.set(id, now)
|
||||||
|
|
||||||
|
// 더블프레스 감지
|
||||||
|
if (matched.doublePressEnabled) {
|
||||||
|
const lastPress = this._lastPressTime.get(id)
|
||||||
|
|
||||||
|
if (lastPress !== undefined && now - lastPress < TIMING.DOUBLE_PRESS_DURATION) {
|
||||||
|
// 300ms 이내 연속 두 번 press = 더블프레스
|
||||||
|
this._lastPressTime.delete(id)
|
||||||
|
|
||||||
|
logger.debug(`Double-press detected: "${id}" (interval=${now - lastPress}ms)`)
|
||||||
|
this.emit('double-press', {
|
||||||
|
config: matched,
|
||||||
|
intervalMs: now - lastPress,
|
||||||
|
timestamp: now
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this._lastPressTime.set(id, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`Hotkey pressed: "${id}"`)
|
||||||
|
this.emit('hotkey-pressed', {
|
||||||
|
config: matched,
|
||||||
|
timestamp: now
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 키 업 이벤트를 처리한다.
|
||||||
|
*/
|
||||||
|
private _handleKeyUp(e: UiohookKeyboardEvent): void {
|
||||||
|
const matched = this._findMatchingHotkey(e)
|
||||||
|
if (!matched) return
|
||||||
|
|
||||||
|
const { id } = matched
|
||||||
|
|
||||||
|
// 눌려있지 않은 키의 release는 무시
|
||||||
|
if (!this._isKeyDown.get(id)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this._isKeyDown.set(id, false)
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const pressStart = this._pressStartTime.get(id)
|
||||||
|
const durationMs = pressStart !== undefined ? now - pressStart : 0
|
||||||
|
this._pressStartTime.delete(id)
|
||||||
|
|
||||||
|
logger.debug(`Hotkey released: "${id}" (duration=${durationMs}ms)`)
|
||||||
|
this.emit('hotkey-released', {
|
||||||
|
config: matched,
|
||||||
|
durationMs,
|
||||||
|
timestamp: now
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* uiohook 키 이벤트와 등록된 핫키를 매칭한다.
|
||||||
|
* 키코드와 수정자 키가 모두 일치해야 매칭 성공.
|
||||||
|
*/
|
||||||
|
private _findMatchingHotkey(e: UiohookKeyboardEvent): HotkeyConfig | null {
|
||||||
|
for (const config of this._registeredHotkeys.values()) {
|
||||||
|
if (!config.enabled) continue
|
||||||
|
|
||||||
|
// 키코드 일치 확인
|
||||||
|
if (e.keycode !== config.keyCode) continue
|
||||||
|
|
||||||
|
// 수정자 키 일치 확인
|
||||||
|
// 핫키에 지정된 수정자가 모두 눌려 있어야 하고,
|
||||||
|
// 지정되지 않은 수정자는 눌려 있으면 안 된다.
|
||||||
|
//
|
||||||
|
// 단, 핫키의 주 키 자체가 수정자 키인 경우(예: Right Alt)에는
|
||||||
|
// 해당 수정자의 altKey 등이 true로 올 수 있으므로, 주 키가 수정자인 경우
|
||||||
|
// 해당 수정자 검사를 건너뛴다.
|
||||||
|
const isKeyModifier = this._isModifierKeyCode(config.keyCode)
|
||||||
|
|
||||||
|
const wantsCtrl = config.modifiers.includes('ctrl')
|
||||||
|
const wantsAlt = config.modifiers.includes('alt')
|
||||||
|
const wantsShift = config.modifiers.includes('shift')
|
||||||
|
const wantsMeta = config.modifiers.includes('meta')
|
||||||
|
|
||||||
|
const ctrlMatch = isKeyModifier && this._isCtrlKeyCode(config.keyCode)
|
||||||
|
? true
|
||||||
|
: e.ctrlKey === wantsCtrl
|
||||||
|
const altMatch = isKeyModifier && this._isAltKeyCode(config.keyCode)
|
||||||
|
? true
|
||||||
|
: e.altKey === wantsAlt
|
||||||
|
const shiftMatch = isKeyModifier && this._isShiftKeyCode(config.keyCode)
|
||||||
|
? true
|
||||||
|
: e.shiftKey === wantsShift
|
||||||
|
const metaMatch = isKeyModifier && this._isMetaKeyCode(config.keyCode)
|
||||||
|
? true
|
||||||
|
: e.metaKey === wantsMeta
|
||||||
|
|
||||||
|
if (ctrlMatch && altMatch && shiftMatch && metaMatch) {
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 주어진 uiohook 키코드가 수정자 키(Ctrl/Alt/Shift/Meta)인지 확인 */
|
||||||
|
private _isModifierKeyCode(keyCode: number): boolean {
|
||||||
|
return (
|
||||||
|
this._isCtrlKeyCode(keyCode) ||
|
||||||
|
this._isAltKeyCode(keyCode) ||
|
||||||
|
this._isShiftKeyCode(keyCode) ||
|
||||||
|
this._isMetaKeyCode(keyCode)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private _isCtrlKeyCode(keyCode: number): boolean {
|
||||||
|
return keyCode === UiohookKey.Ctrl || keyCode === UiohookKey.CtrlRight
|
||||||
|
}
|
||||||
|
|
||||||
|
private _isAltKeyCode(keyCode: number): boolean {
|
||||||
|
return keyCode === UiohookKey.Alt || keyCode === UiohookKey.AltRight
|
||||||
|
}
|
||||||
|
|
||||||
|
private _isShiftKeyCode(keyCode: number): boolean {
|
||||||
|
return keyCode === UiohookKey.Shift || keyCode === UiohookKey.ShiftRight
|
||||||
|
}
|
||||||
|
|
||||||
|
private _isMetaKeyCode(keyCode: number): boolean {
|
||||||
|
return keyCode === UiohookKey.Meta || keyCode === UiohookKey.MetaRight
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// EventEmitter 타입 오버라이드
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
override on<K extends keyof HotkeyServiceEvents>(
|
||||||
|
event: K,
|
||||||
|
listener: HotkeyServiceEvents[K]
|
||||||
|
): this {
|
||||||
|
return super.on(event, listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
override off<K extends keyof HotkeyServiceEvents>(
|
||||||
|
event: K,
|
||||||
|
listener: HotkeyServiceEvents[K]
|
||||||
|
): this {
|
||||||
|
return super.off(event, listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
override emit<K extends keyof HotkeyServiceEvents>(
|
||||||
|
event: K,
|
||||||
|
...args: Parameters<HotkeyServiceEvents[K]>
|
||||||
|
): boolean {
|
||||||
|
return super.emit(event, ...args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 싱글톤
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
let instance: HotkeyService | null = null
|
||||||
|
|
||||||
|
export function getHotkeyService(): HotkeyService {
|
||||||
|
if (!instance) {
|
||||||
|
instance = new HotkeyService()
|
||||||
|
}
|
||||||
|
return instance
|
||||||
|
}
|
||||||
729
src/main/services/LocalSTTService.ts
Normal file
729
src/main/services/LocalSTTService.ts
Normal file
|
|
@ -0,0 +1,729 @@
|
||||||
|
// src/main/services/LocalSTTService.ts
|
||||||
|
// faster-whisper sidecar를 관리하고 오디오 버퍼를 텍스트로 변환한다.
|
||||||
|
// 싱글톤 + EventEmitter 패턴. Speakly VoiceRecognitionService의
|
||||||
|
// 상태 머신 및 이중 조건 플러시 패턴 적용.
|
||||||
|
|
||||||
|
import { EventEmitter } from 'events'
|
||||||
|
import { type ChildProcess, spawn } from 'child_process'
|
||||||
|
import path from 'path'
|
||||||
|
import { app } from 'electron'
|
||||||
|
import { getLogger } from './LoggerService'
|
||||||
|
import { configGet } from './ConfigService'
|
||||||
|
import { D3ROError, ErrorCode } from '@shared/errors'
|
||||||
|
import type { STTModel, STTStatus, STTEngineState } from '@shared/types'
|
||||||
|
|
||||||
|
// ── 내부 타입 정의 ────────────────────────────────────────
|
||||||
|
|
||||||
|
/** STT 엔진 상태 머신 */
|
||||||
|
const enum STTState {
|
||||||
|
Uninitialized = 'uninitialized',
|
||||||
|
Loading = 'loading',
|
||||||
|
Ready = 'ready',
|
||||||
|
Transcribing = 'transcribing',
|
||||||
|
Error = 'error',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 전사 결과 세그먼트 */
|
||||||
|
export interface TranscriptionSegment {
|
||||||
|
readonly text: string
|
||||||
|
readonly start: number
|
||||||
|
readonly end: number
|
||||||
|
readonly confidence: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 전사 결과 */
|
||||||
|
export interface TranscriptionResult {
|
||||||
|
readonly text: string
|
||||||
|
readonly segments: TranscriptionSegment[]
|
||||||
|
readonly language: string
|
||||||
|
readonly duration: number
|
||||||
|
readonly processingTime: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 전사 옵션 */
|
||||||
|
export interface TranscribeOptions {
|
||||||
|
language?: string
|
||||||
|
initialPrompt?: string
|
||||||
|
vadFilter?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** sidecar /health 응답 */
|
||||||
|
interface HealthResponse {
|
||||||
|
status: string
|
||||||
|
model: string | null
|
||||||
|
gpu: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** sidecar /load 응답 */
|
||||||
|
interface LoadResponse {
|
||||||
|
status: string
|
||||||
|
model_id: string
|
||||||
|
load_time_ms: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** sidecar /transcribe 응답 */
|
||||||
|
interface TranscribeResponse {
|
||||||
|
text: string
|
||||||
|
segments: Array<{
|
||||||
|
text: string
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
avg_logprob: number
|
||||||
|
}>
|
||||||
|
language: string
|
||||||
|
duration: number
|
||||||
|
processing_time: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 이벤트 페이로드 */
|
||||||
|
export interface LocalSTTEvents {
|
||||||
|
'transcription-delta': { text: string; isFinal: boolean }
|
||||||
|
'transcription-complete': { result: TranscriptionResult }
|
||||||
|
'model-loaded': { model: STTModel; loadTimeMs: number }
|
||||||
|
'error': { error: D3ROError }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 상수 ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const SIDECAR_PORT = 18765
|
||||||
|
const HEALTH_CHECK_INTERVAL_MS = 1000
|
||||||
|
const HEALTH_CHECK_TIMEOUT_MS = 30000
|
||||||
|
const MAX_RESTART_COUNT = 3
|
||||||
|
const SIDECAR_REQUEST_TIMEOUT_MS = 120000
|
||||||
|
|
||||||
|
/** 알려진 Whisper 모델 카탈로그 */
|
||||||
|
const MODEL_CATALOG: STTModel[] = [
|
||||||
|
{
|
||||||
|
id: 'tiny',
|
||||||
|
name: 'Tiny',
|
||||||
|
sizeBytes: 75_000_000,
|
||||||
|
downloaded: false,
|
||||||
|
languages: ['auto', 'ko', 'en', 'ja', 'zh'],
|
||||||
|
accuracy: 1,
|
||||||
|
speed: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'base',
|
||||||
|
name: 'Base',
|
||||||
|
sizeBytes: 141_000_000,
|
||||||
|
downloaded: false,
|
||||||
|
languages: ['auto', 'ko', 'en', 'ja', 'zh'],
|
||||||
|
accuracy: 2,
|
||||||
|
speed: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'small',
|
||||||
|
name: 'Small',
|
||||||
|
sizeBytes: 466_000_000,
|
||||||
|
downloaded: false,
|
||||||
|
languages: ['auto', 'ko', 'en', 'ja', 'zh'],
|
||||||
|
accuracy: 3,
|
||||||
|
speed: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'medium',
|
||||||
|
name: 'Medium',
|
||||||
|
sizeBytes: 1_500_000_000,
|
||||||
|
downloaded: false,
|
||||||
|
languages: ['auto', 'ko', 'en', 'ja', 'zh'],
|
||||||
|
accuracy: 4,
|
||||||
|
speed: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'large-v3',
|
||||||
|
name: 'Large V3',
|
||||||
|
sizeBytes: 3_100_000_000,
|
||||||
|
downloaded: false,
|
||||||
|
languages: ['auto', 'ko', 'en', 'ja', 'zh'],
|
||||||
|
accuracy: 5,
|
||||||
|
speed: 1,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// ── 서비스 구현 ───────────────────────────────────────────
|
||||||
|
|
||||||
|
const logger = getLogger('LocalSTTService')
|
||||||
|
|
||||||
|
class LocalSTTService extends EventEmitter {
|
||||||
|
private _state: STTState = STTState.Uninitialized
|
||||||
|
private _sidecarProcess: ChildProcess | null = null
|
||||||
|
private _port: number = SIDECAR_PORT
|
||||||
|
private _currentModelId: string | null = null
|
||||||
|
private _restartCount: number = 0
|
||||||
|
private _disposed: boolean = false
|
||||||
|
private _gpuAccelerated: boolean = false
|
||||||
|
|
||||||
|
// ── 이중 조건 플러시 (Speakly 패턴) ──
|
||||||
|
private _modelReady: boolean = false
|
||||||
|
private _audioBuffer: Buffer[] = []
|
||||||
|
private _pendingResolve: ((result: TranscriptionResult) => void) | null = null
|
||||||
|
private _pendingReject: ((error: D3ROError) => void) | null = null
|
||||||
|
// errorEmitted 플래그로 이벤트 중복 방지
|
||||||
|
private _errorEmitted: boolean = false
|
||||||
|
|
||||||
|
// ── 상태 접근자 ──
|
||||||
|
|
||||||
|
get state(): STTState {
|
||||||
|
return this._state
|
||||||
|
}
|
||||||
|
|
||||||
|
get currentModelId(): string | null {
|
||||||
|
return this._currentModelId
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 공개 메서드 ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whisper sidecar 프로세스 시작 + 모델 로딩.
|
||||||
|
* 이미 로딩된 모델과 같으면 무시.
|
||||||
|
*/
|
||||||
|
async initialize(modelId?: string): Promise<void> {
|
||||||
|
const targetModel = modelId ?? configGet('sttModelId')
|
||||||
|
|
||||||
|
if (this._disposed) {
|
||||||
|
throw new D3ROError(
|
||||||
|
ErrorCode.STTSidecarSpawnFailed,
|
||||||
|
'LocalSTTService가 이미 dispose되었습니다',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미 같은 모델이 로딩된 상태면 무시
|
||||||
|
if (
|
||||||
|
this._state === STTState.Ready &&
|
||||||
|
this._currentModelId === targetModel
|
||||||
|
) {
|
||||||
|
logger.debug(`모델 ${targetModel}이 이미 로딩되어 있습니다`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this._setState(STTState.Loading)
|
||||||
|
this._errorEmitted = false
|
||||||
|
|
||||||
|
try {
|
||||||
|
// sidecar가 아직 실행 중이 아니면 시작
|
||||||
|
if (!this._sidecarProcess || this._sidecarProcess.exitCode !== null) {
|
||||||
|
await this._spawnSidecar()
|
||||||
|
await this._waitForHealth()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모델 로딩
|
||||||
|
await this._loadModel(targetModel)
|
||||||
|
this._currentModelId = targetModel
|
||||||
|
this._modelReady = true
|
||||||
|
this._setState(STTState.Ready)
|
||||||
|
|
||||||
|
// 이중 조건 플러시 시도
|
||||||
|
this._tryFlushAll()
|
||||||
|
|
||||||
|
logger.info(`STT 초기화 완료: 모델=${targetModel}`)
|
||||||
|
} catch (err) {
|
||||||
|
this._setState(STTState.Error)
|
||||||
|
const d3roErr =
|
||||||
|
err instanceof D3ROError
|
||||||
|
? err
|
||||||
|
: new D3ROError(
|
||||||
|
ErrorCode.STTModelLoadFailed,
|
||||||
|
`STT 초기화 실패: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
)
|
||||||
|
this._emitError(d3roErr)
|
||||||
|
throw d3roErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 오디오 버퍼를 전사.
|
||||||
|
* PCM16 16kHz mono 포맷이어야 한다.
|
||||||
|
* 이중 조건 플러시: 모델 로딩과 오디오 버퍼링이 모두 완료되면 실행.
|
||||||
|
*/
|
||||||
|
async transcribe(
|
||||||
|
audioBuffer: Buffer,
|
||||||
|
options?: TranscribeOptions,
|
||||||
|
): Promise<TranscriptionResult> {
|
||||||
|
if (this._disposed) {
|
||||||
|
throw new D3ROError(
|
||||||
|
ErrorCode.STTTranscriptionFailed,
|
||||||
|
'LocalSTTService가 이미 dispose되었습니다',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audioBuffer.length === 0) {
|
||||||
|
throw new D3ROError(ErrorCode.STTNoAudioData, '오디오 데이터가 비어있습니다')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모델이 아직 준비되지 않았으면 버퍼에 적재하고 대기
|
||||||
|
if (!this._modelReady) {
|
||||||
|
logger.debug('모델 로딩 중, 오디오 버퍼에 적재')
|
||||||
|
this._audioBuffer.push(audioBuffer)
|
||||||
|
|
||||||
|
return new Promise<TranscriptionResult>((resolve, reject) => {
|
||||||
|
this._pendingResolve = resolve
|
||||||
|
this._pendingReject = reject
|
||||||
|
// 이중 조건 플러시 시도 (모델이 이미 준비되었을 수 있음)
|
||||||
|
this._tryFlushAll()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모델 준비 완료 상태: 직접 전사
|
||||||
|
return this._sendToSidecar(audioBuffer, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 다운로드된 모델 목록 조회.
|
||||||
|
* 실제 다운로드 여부는 sidecar에 위임 (faster-whisper가 자동 다운로드).
|
||||||
|
*/
|
||||||
|
getModels(): STTModel[] {
|
||||||
|
return MODEL_CATALOG.map((m) => ({
|
||||||
|
...m,
|
||||||
|
// 현재 로딩된 모델은 downloaded=true로 표시
|
||||||
|
downloaded: m.id === this._currentModelId ? true : m.downloaded,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 현재 상태 조회 */
|
||||||
|
getStatus(): STTStatus {
|
||||||
|
const stateMap: Record<STTState, STTEngineState> = {
|
||||||
|
[STTState.Uninitialized]: 'not-installed' as STTEngineState,
|
||||||
|
[STTState.Loading]: 'loading' as STTEngineState,
|
||||||
|
[STTState.Ready]: 'ready' as STTEngineState,
|
||||||
|
[STTState.Transcribing]: 'processing' as STTEngineState,
|
||||||
|
[STTState.Error]: 'error' as STTEngineState,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
engineState: stateMap[this._state],
|
||||||
|
activeModel: this._currentModelId,
|
||||||
|
engineVersion: null,
|
||||||
|
gpuAccelerated: this._gpuAccelerated,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** sidecar 프로세스 종료 및 리소스 정리 */
|
||||||
|
async dispose(): Promise<void> {
|
||||||
|
if (this._disposed) return
|
||||||
|
this._disposed = true
|
||||||
|
|
||||||
|
logger.info('LocalSTTService dispose 시작')
|
||||||
|
|
||||||
|
// pending promise를 reject
|
||||||
|
if (this._pendingReject) {
|
||||||
|
this._pendingReject(
|
||||||
|
new D3ROError(ErrorCode.STTTranscriptionCancelled, '서비스 종료로 전사 취소'),
|
||||||
|
)
|
||||||
|
this._pendingResolve = null
|
||||||
|
this._pendingReject = null
|
||||||
|
}
|
||||||
|
|
||||||
|
this._audioBuffer = []
|
||||||
|
this._modelReady = false
|
||||||
|
|
||||||
|
await this._shutdownSidecar()
|
||||||
|
this._setState(STTState.Uninitialized)
|
||||||
|
|
||||||
|
logger.info('LocalSTTService dispose 완료')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 이중 조건 플러시 (Speakly 핵심 패턴) ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모델 로딩과 오디오 버퍼링이 모두 완료되면 실행.
|
||||||
|
* 설정 메시지(모델) 먼저, 오디오 데이터 후.
|
||||||
|
*/
|
||||||
|
private _tryFlushAll(): void {
|
||||||
|
if (!this._modelReady) return
|
||||||
|
if (this._audioBuffer.length === 0) return
|
||||||
|
if (!this._pendingResolve) return
|
||||||
|
|
||||||
|
const merged = Buffer.concat(this._audioBuffer)
|
||||||
|
this._audioBuffer = []
|
||||||
|
|
||||||
|
const resolve = this._pendingResolve
|
||||||
|
const reject = this._pendingReject
|
||||||
|
this._pendingResolve = null
|
||||||
|
this._pendingReject = null
|
||||||
|
|
||||||
|
this._sendToSidecar(merged)
|
||||||
|
.then(resolve)
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (reject) {
|
||||||
|
reject(
|
||||||
|
err instanceof D3ROError
|
||||||
|
? err
|
||||||
|
: new D3ROError(
|
||||||
|
ErrorCode.STTTranscriptionFailed,
|
||||||
|
`전사 실패: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sidecar 관리 ──
|
||||||
|
|
||||||
|
private _getSidecarPath(): string {
|
||||||
|
const basePath = app.isPackaged ? process.resourcesPath : app.getAppPath()
|
||||||
|
return path.join(basePath, 'sidecar', 'main.py')
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _spawnSidecar(): Promise<void> {
|
||||||
|
const sidecarPath = this._getSidecarPath()
|
||||||
|
logger.info(`Sidecar 시작: python ${sidecarPath} --port ${this._port}`)
|
||||||
|
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
const pythonCmd = process.platform === 'win32' ? 'python' : 'python3'
|
||||||
|
|
||||||
|
try {
|
||||||
|
this._sidecarProcess = spawn(
|
||||||
|
pythonCmd,
|
||||||
|
[sidecarPath, '--port', String(this._port)],
|
||||||
|
{
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
env: { ...process.env },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
const d3roErr = new D3ROError(
|
||||||
|
ErrorCode.STTSidecarSpawnFailed,
|
||||||
|
`Sidecar 프로세스 생성 실패: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
)
|
||||||
|
reject(d3roErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sidecarLogger = getLogger('sidecar')
|
||||||
|
|
||||||
|
this._sidecarProcess.stdout?.on('data', (data: Buffer) => {
|
||||||
|
const text = data.toString().trim()
|
||||||
|
if (text) {
|
||||||
|
sidecarLogger.info(text)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
this._sidecarProcess.stderr?.on('data', (data: Buffer) => {
|
||||||
|
const text = data.toString().trim()
|
||||||
|
if (text) {
|
||||||
|
sidecarLogger.warn(text)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
this._sidecarProcess.on('error', (err: Error) => {
|
||||||
|
logger.error(`Sidecar 프로세스 에러: ${err.message}`)
|
||||||
|
reject(
|
||||||
|
new D3ROError(
|
||||||
|
ErrorCode.STTSidecarSpawnFailed,
|
||||||
|
`Sidecar 프로세스 에러: ${err.message}`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
this._sidecarProcess.on('exit', (code: number | null, signal: string | null) => {
|
||||||
|
logger.warn(`Sidecar 프로세스 종료: code=${code}, signal=${signal}`)
|
||||||
|
this._sidecarProcess = null
|
||||||
|
this._modelReady = false
|
||||||
|
|
||||||
|
if (!this._disposed) {
|
||||||
|
this._handleSidecarCrash()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// spawn 자체는 비동기적이므로 즉시 resolve
|
||||||
|
// 실제 준비는 _waitForHealth에서 확인
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _waitForHealth(): Promise<void> {
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
while (Date.now() - startTime < HEALTH_CHECK_TIMEOUT_MS) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://localhost:${this._port}/health`, {
|
||||||
|
signal: AbortSignal.timeout(2000),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = (await response.json()) as HealthResponse
|
||||||
|
this._gpuAccelerated = data.gpu
|
||||||
|
logger.info(
|
||||||
|
`Sidecar 헬스체크 성공: status=${data.status}, gpu=${data.gpu}`,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 아직 준비 안 됨, 재시도
|
||||||
|
}
|
||||||
|
|
||||||
|
await this._sleep(HEALTH_CHECK_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new D3ROError(
|
||||||
|
ErrorCode.STTSidecarCommunicationFailed,
|
||||||
|
`Sidecar 헬스체크 타임아웃 (${HEALTH_CHECK_TIMEOUT_MS}ms)`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _loadModel(modelId: string): Promise<void> {
|
||||||
|
logger.info(`모델 로딩 시작: ${modelId}`)
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
const response = await fetch(`http://localhost:${this._port}/load`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ model_id: modelId }),
|
||||||
|
signal: AbortSignal.timeout(SIDECAR_REQUEST_TIMEOUT_MS),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
throw new D3ROError(
|
||||||
|
ErrorCode.STTModelLoadFailed,
|
||||||
|
`모델 로딩 실패 (HTTP ${response.status}): ${errorText}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as LoadResponse
|
||||||
|
const loadTimeMs = Date.now() - startTime
|
||||||
|
|
||||||
|
const model = MODEL_CATALOG.find((m) => m.id === modelId)
|
||||||
|
if (model) {
|
||||||
|
this.emit('model-loaded', {
|
||||||
|
model: { ...model, downloaded: true },
|
||||||
|
loadTimeMs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`모델 로딩 완료: ${data.model_id}, ${loadTimeMs}ms`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HTTP 전사 요청 ──
|
||||||
|
|
||||||
|
private async _sendToSidecar(
|
||||||
|
audioBuffer: Buffer,
|
||||||
|
options?: TranscribeOptions,
|
||||||
|
): Promise<TranscriptionResult> {
|
||||||
|
if (!this._sidecarProcess || this._sidecarProcess.exitCode !== null) {
|
||||||
|
throw new D3ROError(
|
||||||
|
ErrorCode.STTSidecarCommunicationFailed,
|
||||||
|
'Sidecar 프로세스가 실행 중이 아닙니다',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
this._setState(STTState.Transcribing)
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const language = options?.language ?? configGet('sttLanguage')
|
||||||
|
const vadFilter = options?.vadFilter ?? true
|
||||||
|
const initialPrompt = options?.initialPrompt ?? ''
|
||||||
|
|
||||||
|
// Node 18+ 내장 fetch + FormData + Blob으로 multipart 전송
|
||||||
|
const formData = new FormData()
|
||||||
|
// Buffer → ArrayBuffer 복사 후 Blob 생성 (Node/Electron 타입 호환)
|
||||||
|
const arrayBuf = audioBuffer.buffer.slice(
|
||||||
|
audioBuffer.byteOffset,
|
||||||
|
audioBuffer.byteOffset + audioBuffer.byteLength,
|
||||||
|
) as ArrayBuffer
|
||||||
|
formData.append(
|
||||||
|
'audio',
|
||||||
|
new Blob([arrayBuf], { type: 'application/octet-stream' }),
|
||||||
|
'audio.pcm',
|
||||||
|
)
|
||||||
|
formData.append('language', language)
|
||||||
|
formData.append('vad_filter', String(vadFilter))
|
||||||
|
if (initialPrompt) {
|
||||||
|
formData.append('initial_prompt', initialPrompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`http://localhost:${this._port}/transcribe`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
signal: AbortSignal.timeout(SIDECAR_REQUEST_TIMEOUT_MS),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
throw new D3ROError(
|
||||||
|
ErrorCode.STTTranscriptionFailed,
|
||||||
|
`전사 실패 (HTTP ${response.status}): ${errorText}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as TranscribeResponse
|
||||||
|
const processingTime = Date.now() - startTime
|
||||||
|
|
||||||
|
const result: TranscriptionResult = {
|
||||||
|
text: data.text,
|
||||||
|
segments: data.segments.map((seg) => ({
|
||||||
|
text: seg.text,
|
||||||
|
start: seg.start,
|
||||||
|
end: seg.end,
|
||||||
|
confidence: Math.exp(seg.avg_logprob),
|
||||||
|
})),
|
||||||
|
language: data.language,
|
||||||
|
duration: data.duration,
|
||||||
|
processingTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 중간 결과 이벤트 (isFinal=true)
|
||||||
|
this.emit('transcription-delta', { text: result.text, isFinal: true })
|
||||||
|
this.emit('transcription-complete', { result })
|
||||||
|
|
||||||
|
this._setState(STTState.Ready)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`전사 완료: "${result.text.substring(0, 50)}..." (${processingTime}ms, lang=${result.language})`,
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
} catch (err) {
|
||||||
|
this._setState(STTState.Ready) // 에러 후에도 Ready 복귀 (sidecar가 살아있으면)
|
||||||
|
|
||||||
|
if (err instanceof D3ROError) {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
|
||||||
|
// 타임아웃 구분
|
||||||
|
if (message.includes('abort') || message.includes('timeout')) {
|
||||||
|
throw new D3ROError(
|
||||||
|
ErrorCode.STTTranscriptionTimeout,
|
||||||
|
`전사 타임아웃: ${message}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new D3ROError(
|
||||||
|
ErrorCode.STTTranscriptionFailed,
|
||||||
|
`전사 실패: ${message}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sidecar Crash 처리 ──
|
||||||
|
|
||||||
|
private _handleSidecarCrash(): void {
|
||||||
|
if (this._disposed) return
|
||||||
|
|
||||||
|
this._restartCount++
|
||||||
|
logger.warn(`Sidecar crash 감지, 재시작 시도 ${this._restartCount}/${MAX_RESTART_COUNT}`)
|
||||||
|
|
||||||
|
if (this._restartCount > MAX_RESTART_COUNT) {
|
||||||
|
const err = new D3ROError(
|
||||||
|
ErrorCode.STTSidecarCrashed,
|
||||||
|
`Sidecar가 ${MAX_RESTART_COUNT}회 crash 후 재시작 포기`,
|
||||||
|
)
|
||||||
|
this._setState(STTState.Error)
|
||||||
|
this._emitError(err)
|
||||||
|
|
||||||
|
// pending promise reject
|
||||||
|
if (this._pendingReject) {
|
||||||
|
this._pendingReject(err)
|
||||||
|
this._pendingResolve = null
|
||||||
|
this._pendingReject = null
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 비동기 재시작
|
||||||
|
const modelToReload = this._currentModelId
|
||||||
|
this._currentModelId = null
|
||||||
|
this._modelReady = false
|
||||||
|
|
||||||
|
// setTimeout으로 이벤트 루프에 양보
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this._disposed) return
|
||||||
|
this.initialize(modelToReload ?? undefined).catch((err: unknown) => {
|
||||||
|
logger.error(
|
||||||
|
`Sidecar 재시작 실패: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}, 1000 * this._restartCount) // 점진적 백오프
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _shutdownSidecar(): Promise<void> {
|
||||||
|
if (!this._sidecarProcess || this._sidecarProcess.exitCode !== null) {
|
||||||
|
this._sidecarProcess = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Sidecar 종료 요청')
|
||||||
|
|
||||||
|
try {
|
||||||
|
// POST /shutdown 요청
|
||||||
|
await fetch(`http://localhost:${this._port}/shutdown`, {
|
||||||
|
method: 'POST',
|
||||||
|
signal: AbortSignal.timeout(3000),
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// 이미 종료되었거나 통신 불가 — 무시
|
||||||
|
}
|
||||||
|
|
||||||
|
// 프로세스가 아직 살아있으면 강제 종료
|
||||||
|
if (this._sidecarProcess && this._sidecarProcess.exitCode === null) {
|
||||||
|
logger.warn('Sidecar graceful shutdown 실패, SIGKILL 전송')
|
||||||
|
this._sidecarProcess.kill('SIGKILL')
|
||||||
|
}
|
||||||
|
|
||||||
|
this._sidecarProcess = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 내부 유틸 ──
|
||||||
|
|
||||||
|
private _setState(newState: STTState): void {
|
||||||
|
if (this._state === newState) return
|
||||||
|
const prev = this._state
|
||||||
|
this._state = newState
|
||||||
|
logger.debug(`STTState: ${prev} -> ${newState}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
private _emitError(error: D3ROError): void {
|
||||||
|
if (this._errorEmitted) return
|
||||||
|
this._errorEmitted = true
|
||||||
|
this.emit('error', { error })
|
||||||
|
logger.error(`STT 에러: [${error.code}] ${error.message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
private _sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 타입 안전한 이벤트 메서드 오버라이드 ──
|
||||||
|
|
||||||
|
override emit<K extends keyof LocalSTTEvents>(
|
||||||
|
event: K,
|
||||||
|
payload: LocalSTTEvents[K],
|
||||||
|
): boolean {
|
||||||
|
return super.emit(event, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
override on<K extends keyof LocalSTTEvents>(
|
||||||
|
event: K,
|
||||||
|
listener: (payload: LocalSTTEvents[K]) => void,
|
||||||
|
): this {
|
||||||
|
return super.on(event, listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
override off<K extends keyof LocalSTTEvents>(
|
||||||
|
event: K,
|
||||||
|
listener: (payload: LocalSTTEvents[K]) => void,
|
||||||
|
): this {
|
||||||
|
return super.off(event, listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 싱글톤 ──
|
||||||
|
|
||||||
|
let instance: LocalSTTService | null = null
|
||||||
|
|
||||||
|
export function getLocalSTTService(): LocalSTTService {
|
||||||
|
if (!instance) {
|
||||||
|
instance = new LocalSTTService()
|
||||||
|
}
|
||||||
|
return instance
|
||||||
|
}
|
||||||
|
|
||||||
|
export { LocalSTTService, STTState }
|
||||||
32
src/main/services/LoggerService.ts
Normal file
32
src/main/services/LoggerService.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
// src/main/services/LoggerService.ts
|
||||||
|
// electron-log 래퍼. 카테고리별 로깅을 지원한다.
|
||||||
|
|
||||||
|
import log from 'electron-log'
|
||||||
|
|
||||||
|
export interface CategoryLogger {
|
||||||
|
info(message: string, ...args: unknown[]): void
|
||||||
|
warn(message: string, ...args: unknown[]): void
|
||||||
|
error(message: string, ...args: unknown[]): void
|
||||||
|
debug(message: string, ...args: unknown[]): void
|
||||||
|
}
|
||||||
|
|
||||||
|
let initialized = false
|
||||||
|
|
||||||
|
export function initLoggerService(): void {
|
||||||
|
if (initialized) return
|
||||||
|
initialized = true
|
||||||
|
|
||||||
|
log.transports.file.maxSize = 5 * 1024 * 1024 // 5MB
|
||||||
|
log.transports.file.format = '[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] {text}'
|
||||||
|
log.transports.console.format = '[{h}:{i}:{s}.{ms}] [{level}] {text}'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLogger(category: string): CategoryLogger {
|
||||||
|
const prefix = `[${category}]`
|
||||||
|
return {
|
||||||
|
info: (message: string, ...args: unknown[]) => log.info(`${prefix} ${message}`, ...args),
|
||||||
|
warn: (message: string, ...args: unknown[]) => log.warn(`${prefix} ${message}`, ...args),
|
||||||
|
error: (message: string, ...args: unknown[]) => log.error(`${prefix} ${message}`, ...args),
|
||||||
|
debug: (message: string, ...args: unknown[]) => log.debug(`${prefix} ${message}`, ...args)
|
||||||
|
}
|
||||||
|
}
|
||||||
625
src/main/services/VoiceModeService.ts
Normal file
625
src/main/services/VoiceModeService.ts
Normal file
|
|
@ -0,0 +1,625 @@
|
||||||
|
// src/main/services/VoiceModeService.ts
|
||||||
|
// 전체 음성 파이프라인 오케스트레이터.
|
||||||
|
// 설계서 01의 IVoiceModeService 구현. Speakly VoiceModeService의
|
||||||
|
// 상태 머신, 이중 조건 플러시, Action Queue 패턴 적용.
|
||||||
|
|
||||||
|
import { EventEmitter } from 'events'
|
||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
import { getLogger } from './LoggerService'
|
||||||
|
import { getAudioCaptureService } from './AudioCaptureService'
|
||||||
|
import { getLocalSTTService } from './LocalSTTService'
|
||||||
|
import type { TranscriptionResult } from './LocalSTTService'
|
||||||
|
import { getHotkeyService } from './HotkeyService'
|
||||||
|
import type { HotkeyConfig } from './HotkeyService'
|
||||||
|
import { configGet } from './ConfigService'
|
||||||
|
import { D3ROError, ErrorCode } from '@shared/errors'
|
||||||
|
import { TIMING } from '@shared/constants'
|
||||||
|
import { RecognitionState, AudioState } from '@shared/types'
|
||||||
|
import type { VoiceMode, VoiceState } from '@shared/types'
|
||||||
|
|
||||||
|
const logger = getLogger('VoiceModeService')
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 내부 타입
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
interface VoiceSession {
|
||||||
|
id: string
|
||||||
|
mode: VoiceMode
|
||||||
|
startedAt: number
|
||||||
|
recognitionState: RecognitionState
|
||||||
|
audioState: AudioState
|
||||||
|
audioBufferDurationMs: number
|
||||||
|
transcription: string
|
||||||
|
processedText: string | null
|
||||||
|
accidentalPress: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VoiceAction {
|
||||||
|
type: 'press' | 'release' | 'escape'
|
||||||
|
timestamp: number
|
||||||
|
mode: VoiceMode
|
||||||
|
hotkeyId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VoiceModeEvents {
|
||||||
|
'session-started': (payload: { session: VoiceSession }) => void
|
||||||
|
'recognition-state-changed': (payload: {
|
||||||
|
previous: RecognitionState
|
||||||
|
current: RecognitionState
|
||||||
|
}) => void
|
||||||
|
'audio-state-changed': (payload: {
|
||||||
|
previous: AudioState
|
||||||
|
current: AudioState
|
||||||
|
}) => void
|
||||||
|
'transcription-update': (payload: { text: string; isFinal: boolean }) => void
|
||||||
|
'session-completed': (payload: { session: VoiceSession; finalText: string }) => void
|
||||||
|
'session-cancelled': (payload: {
|
||||||
|
session: VoiceSession
|
||||||
|
reason: 'user' | 'timeout' | 'too-short'
|
||||||
|
}) => void
|
||||||
|
'audio-level': (payload: { level: number }) => void
|
||||||
|
error: (payload: { error: D3ROError; session: VoiceSession | null }) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 터미널 상태 헬퍼
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const TERMINAL_STATES = new Set<RecognitionState>([
|
||||||
|
RecognitionState.COMPLETED,
|
||||||
|
RecognitionState.CANCELLED,
|
||||||
|
RecognitionState.ERROR,
|
||||||
|
RecognitionState.DESTROYED
|
||||||
|
])
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// VoiceModeService
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
class VoiceModeService extends EventEmitter {
|
||||||
|
private _session: VoiceSession | null = null
|
||||||
|
private _recognitionState = RecognitionState.IDLE
|
||||||
|
private _audioState = AudioState.IDLE
|
||||||
|
|
||||||
|
// 이중 조건 플러시
|
||||||
|
private _sttReady = false
|
||||||
|
private _audioStarted = false
|
||||||
|
private _audioBuffer: Buffer[] = []
|
||||||
|
private _audioBufferBytes = 0
|
||||||
|
|
||||||
|
// 에러 가드
|
||||||
|
private _errorEmitted = false
|
||||||
|
|
||||||
|
// Action Queue (이벤트 직렬화)
|
||||||
|
private _actionQueue: VoiceAction[] = []
|
||||||
|
private _isProcessingQueue = false
|
||||||
|
|
||||||
|
// 리스너 해제용 참조
|
||||||
|
private _audioDataHandler: ((payload: { buffer: Buffer }) => void) | null = null
|
||||||
|
private _audioLevelHandler: ((payload: { level: number }) => void) | null = null
|
||||||
|
private _hotkeyPressHandler: ((payload: { config: HotkeyConfig; timestamp: number }) => void) | null = null
|
||||||
|
private _hotkeyReleaseHandler: ((payload: { config: HotkeyConfig; durationMs: number; timestamp: number }) => void) | null = null
|
||||||
|
private _doublePressHandler: ((payload: { config: HotkeyConfig }) => void) | null = null
|
||||||
|
|
||||||
|
private _disposed = false
|
||||||
|
|
||||||
|
get currentSession(): VoiceSession | null {
|
||||||
|
return this._session
|
||||||
|
}
|
||||||
|
|
||||||
|
get isActive(): boolean {
|
||||||
|
return this._session !== null && !this._isInTerminalState()
|
||||||
|
}
|
||||||
|
|
||||||
|
getState(): VoiceState {
|
||||||
|
return {
|
||||||
|
recognitionState: this._recognitionState,
|
||||||
|
audioState: this._audioState,
|
||||||
|
mode: configGet('defaultLLMAction') === 'translate' ? 'hands-free' : 'dictation',
|
||||||
|
sessionId: this._session?.id ?? null,
|
||||||
|
recordingStartedAt: this._session?.startedAt ?? null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 초기화 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HotkeyService 이벤트를 구독하여 핫키 → 세션 제어를 연결한다.
|
||||||
|
* bootstrap에서 호출한다.
|
||||||
|
*/
|
||||||
|
connectHotkey(): void {
|
||||||
|
const hotkey = getHotkeyService()
|
||||||
|
|
||||||
|
this._hotkeyPressHandler = (payload) => {
|
||||||
|
const mode = this._resolveMode(payload.config)
|
||||||
|
this._enqueueAction({ type: 'press', timestamp: payload.timestamp, mode, hotkeyId: payload.config.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
this._hotkeyReleaseHandler = (payload) => {
|
||||||
|
const mode = this._resolveMode(payload.config)
|
||||||
|
this._enqueueAction({ type: 'release', timestamp: payload.timestamp, mode, hotkeyId: payload.config.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
this._doublePressHandler = (payload) => {
|
||||||
|
// 더블프레스 → hands-free 모드 토글
|
||||||
|
this._enqueueAction({ type: 'press', timestamp: Date.now(), mode: 'hands-free', hotkeyId: payload.config.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
hotkey.on('hotkey-pressed', this._hotkeyPressHandler)
|
||||||
|
hotkey.on('hotkey-released', this._hotkeyReleaseHandler)
|
||||||
|
hotkey.on('double-press', this._doublePressHandler)
|
||||||
|
|
||||||
|
logger.info('Hotkey events connected')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 세션 제어 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
async startSession(mode: VoiceMode): Promise<void> {
|
||||||
|
if (this._disposed) return
|
||||||
|
if (this._session && !this._isInTerminalState()) {
|
||||||
|
logger.warn('Session already active, ignoring startSession')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 세션 생성
|
||||||
|
this._session = {
|
||||||
|
id: randomUUID(),
|
||||||
|
mode,
|
||||||
|
startedAt: Date.now(),
|
||||||
|
recognitionState: RecognitionState.PREPARING,
|
||||||
|
audioState: AudioState.IDLE,
|
||||||
|
audioBufferDurationMs: 0,
|
||||||
|
transcription: '',
|
||||||
|
processedText: null,
|
||||||
|
accidentalPress: false
|
||||||
|
}
|
||||||
|
|
||||||
|
this._errorEmitted = false
|
||||||
|
this._sttReady = false
|
||||||
|
this._audioStarted = false
|
||||||
|
this._audioBuffer = []
|
||||||
|
this._audioBufferBytes = 0
|
||||||
|
|
||||||
|
this._setRecognitionState(RecognitionState.PREPARING)
|
||||||
|
this.emit('session-started', { session: this._session })
|
||||||
|
logger.info(`Session started: ${this._session.id} (mode: ${mode})`)
|
||||||
|
|
||||||
|
// 이중 조건 플러시: STT 초기화 + 오디오 캡처를 병렬 시작
|
||||||
|
const sttPromise = this._initSTT()
|
||||||
|
const audioPromise = this._startAudio()
|
||||||
|
|
||||||
|
// 둘 다 에러여도 개별 처리하므로 allSettled
|
||||||
|
await Promise.allSettled([sttPromise, audioPromise])
|
||||||
|
}
|
||||||
|
|
||||||
|
async stopSession(): Promise<void> {
|
||||||
|
if (!this._session || this._isInTerminalState()) return
|
||||||
|
|
||||||
|
const session = this._session
|
||||||
|
const duration = Date.now() - session.startedAt
|
||||||
|
|
||||||
|
// accidentalPress 체크 (700ms 미만)
|
||||||
|
if (duration < TIMING.MIN_AUDIO_DURATION) {
|
||||||
|
session.accidentalPress = true
|
||||||
|
logger.info(`Accidental press detected (${duration}ms < ${TIMING.MIN_AUDIO_DURATION}ms)`)
|
||||||
|
this._cancelSession('too-short')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 오디오 캡처 중지
|
||||||
|
await this._stopAudio()
|
||||||
|
|
||||||
|
// 버퍼가 있으면 STT에 전달
|
||||||
|
if (this._audioBuffer.length > 0 && this._sttReady) {
|
||||||
|
await this._transcribe()
|
||||||
|
} else if (this._audioBuffer.length > 0 && !this._sttReady) {
|
||||||
|
// STT 아직 준비 안 됨 → tryFlushAll이 처리
|
||||||
|
logger.info('Waiting for STT to be ready before transcribing')
|
||||||
|
// 타임아웃 설정
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this._session?.id === session.id && !this._isInTerminalState()) {
|
||||||
|
logger.warn('STT readiness timeout, cancelling session')
|
||||||
|
this._cancelSession('timeout')
|
||||||
|
}
|
||||||
|
}, TIMING.POST_RECORDING_WAIT_BUFFERED)
|
||||||
|
} else {
|
||||||
|
// 오디오 없음
|
||||||
|
logger.warn('No audio buffer, cancelling session')
|
||||||
|
this._cancelSession('too-short')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelSession(): void {
|
||||||
|
this._cancelSession('user')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 상태 머신 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
private _isInTerminalState(): boolean {
|
||||||
|
return TERMINAL_STATES.has(this._recognitionState)
|
||||||
|
}
|
||||||
|
|
||||||
|
private _setRecognitionState(state: RecognitionState): void {
|
||||||
|
if (this._recognitionState === state) return
|
||||||
|
if (this._isInTerminalState() && state !== RecognitionState.IDLE) return
|
||||||
|
|
||||||
|
const previous = this._recognitionState
|
||||||
|
this._recognitionState = state
|
||||||
|
if (this._session) {
|
||||||
|
this._session.recognitionState = state
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('recognition-state-changed', { previous, current: state })
|
||||||
|
logger.debug(`RecognitionState: ${previous} → ${state}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
private _setAudioState(state: AudioState): void {
|
||||||
|
if (this._audioState === state) return
|
||||||
|
|
||||||
|
const previous = this._audioState
|
||||||
|
this._audioState = state
|
||||||
|
if (this._session) {
|
||||||
|
this._session.audioState = state
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('audio-state-changed', { previous, current: state })
|
||||||
|
logger.debug(`AudioState: ${previous} → ${state}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── STT 초기화 ─────────────────────────────────────────
|
||||||
|
|
||||||
|
private async _initSTT(): Promise<void> {
|
||||||
|
try {
|
||||||
|
this._setRecognitionState(RecognitionState.CONNECTING)
|
||||||
|
const stt = getLocalSTTService()
|
||||||
|
const modelId = configGet('sttModelId')
|
||||||
|
|
||||||
|
await stt.initialize(modelId)
|
||||||
|
|
||||||
|
if (this._isInTerminalState()) return
|
||||||
|
|
||||||
|
this._sttReady = true
|
||||||
|
this._setRecognitionState(RecognitionState.READY)
|
||||||
|
logger.info('STT ready')
|
||||||
|
|
||||||
|
this._tryFlushAll()
|
||||||
|
} catch (error) {
|
||||||
|
if (this._isInTerminalState()) return
|
||||||
|
this._handleError(
|
||||||
|
new D3ROError(
|
||||||
|
ErrorCode.STTModelLoadFailed,
|
||||||
|
`STT initialization failed: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 오디오 캡처 ────────────────────────────────────────
|
||||||
|
|
||||||
|
private async _startAudio(): Promise<void> {
|
||||||
|
try {
|
||||||
|
this._setAudioState(AudioState.INITIALIZING)
|
||||||
|
const audio = getAudioCaptureService()
|
||||||
|
|
||||||
|
// 오디오 데이터 수신
|
||||||
|
this._audioDataHandler = (payload) => {
|
||||||
|
if (this._isInTerminalState()) return
|
||||||
|
this._audioBuffer.push(payload.buffer)
|
||||||
|
this._audioBufferBytes += payload.buffer.length
|
||||||
|
|
||||||
|
// 버퍼 duration 업데이트 (16kHz, 16bit, mono)
|
||||||
|
const durationMs = (this._audioBufferBytes / 2 / 16000) * 1000
|
||||||
|
if (this._session) {
|
||||||
|
this._session.audioBufferDurationMs = durationMs
|
||||||
|
}
|
||||||
|
|
||||||
|
this._tryFlushAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
this._audioLevelHandler = (payload) => {
|
||||||
|
this.emit('audio-level', { level: payload.level })
|
||||||
|
}
|
||||||
|
|
||||||
|
audio.on('audio-data', this._audioDataHandler)
|
||||||
|
audio.on('audio-level', this._audioLevelHandler)
|
||||||
|
|
||||||
|
await audio.start()
|
||||||
|
|
||||||
|
if (this._isInTerminalState()) return
|
||||||
|
|
||||||
|
this._audioStarted = true
|
||||||
|
this._setAudioState(AudioState.STREAMING)
|
||||||
|
logger.info('Audio capture started')
|
||||||
|
|
||||||
|
this._tryFlushAll()
|
||||||
|
} catch (error) {
|
||||||
|
if (this._isInTerminalState()) return
|
||||||
|
this._setAudioState(AudioState.STOPPED)
|
||||||
|
this._handleError(
|
||||||
|
new D3ROError(
|
||||||
|
ErrorCode.AudioCaptureStartFailed,
|
||||||
|
`Audio capture failed: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _stopAudio(): Promise<void> {
|
||||||
|
this._setAudioState(AudioState.STOPPED)
|
||||||
|
|
||||||
|
const audio = getAudioCaptureService()
|
||||||
|
if (this._audioDataHandler) {
|
||||||
|
audio.off('audio-data', this._audioDataHandler)
|
||||||
|
this._audioDataHandler = null
|
||||||
|
}
|
||||||
|
if (this._audioLevelHandler) {
|
||||||
|
audio.off('audio-level', this._audioLevelHandler)
|
||||||
|
this._audioLevelHandler = null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await audio.stop()
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Audio stop error: ${error instanceof Error ? error.message : String(error)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 이중 조건 플러시 ───────────────────────────────────
|
||||||
|
|
||||||
|
private _tryFlushAll(): void {
|
||||||
|
if (!this._sttReady || !this._audioStarted) return
|
||||||
|
if (this._audioBuffer.length === 0) return
|
||||||
|
if (this._isInTerminalState()) return
|
||||||
|
|
||||||
|
// 아직 녹음 중이면 flush 하지 않음 (stopSession에서 처리)
|
||||||
|
if (this._audioState === AudioState.STREAMING) return
|
||||||
|
|
||||||
|
this._transcribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 전사 ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async _transcribe(): Promise<void> {
|
||||||
|
if (this._audioBuffer.length === 0) return
|
||||||
|
if (this._isInTerminalState()) return
|
||||||
|
|
||||||
|
this._setRecognitionState(RecognitionState.RECOGNIZING)
|
||||||
|
|
||||||
|
const merged = Buffer.concat(this._audioBuffer)
|
||||||
|
this._audioBuffer = []
|
||||||
|
this._audioBufferBytes = 0
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stt = getLocalSTTService()
|
||||||
|
const language = configGet('sttLanguage')
|
||||||
|
|
||||||
|
const result: TranscriptionResult = await stt.transcribe(merged, {
|
||||||
|
language: language === 'auto' ? undefined : language
|
||||||
|
})
|
||||||
|
|
||||||
|
if (this._isInTerminalState()) return
|
||||||
|
|
||||||
|
if (this._session) {
|
||||||
|
this._session.transcription = result.text
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('transcription-update', { text: result.text, isFinal: true })
|
||||||
|
|
||||||
|
// Phase 2: LLM 후처리 없이 바로 완료
|
||||||
|
this._completeSession(result.text)
|
||||||
|
} catch (error) {
|
||||||
|
if (this._isInTerminalState()) return
|
||||||
|
this._handleError(
|
||||||
|
new D3ROError(
|
||||||
|
ErrorCode.STTTranscriptionFailed,
|
||||||
|
`Transcription failed: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 세션 완료/취소 ─────────────────────────────────────
|
||||||
|
|
||||||
|
private _completeSession(finalText: string): void {
|
||||||
|
if (!this._session) return
|
||||||
|
|
||||||
|
this._setRecognitionState(RecognitionState.COMPLETED)
|
||||||
|
this._setAudioState(AudioState.STOPPED)
|
||||||
|
|
||||||
|
const session = { ...this._session }
|
||||||
|
logger.info(`Session completed: "${finalText.substring(0, 50)}${finalText.length > 50 ? '...' : ''}"`)
|
||||||
|
|
||||||
|
this.emit('session-completed', { session, finalText })
|
||||||
|
|
||||||
|
// IDLE로 복귀
|
||||||
|
this._resetToIdle()
|
||||||
|
}
|
||||||
|
|
||||||
|
private _cancelSession(reason: 'user' | 'timeout' | 'too-short'): void {
|
||||||
|
if (!this._session) return
|
||||||
|
|
||||||
|
this._setRecognitionState(RecognitionState.CANCELLED)
|
||||||
|
this._setAudioState(AudioState.STOPPED)
|
||||||
|
|
||||||
|
const session = { ...this._session }
|
||||||
|
logger.info(`Session cancelled: ${reason}`)
|
||||||
|
|
||||||
|
// 오디오 정리
|
||||||
|
this._stopAudio()
|
||||||
|
|
||||||
|
this.emit('session-cancelled', { session, reason })
|
||||||
|
|
||||||
|
this._resetToIdle()
|
||||||
|
}
|
||||||
|
|
||||||
|
private _resetToIdle(): void {
|
||||||
|
this._session = null
|
||||||
|
this._audioBuffer = []
|
||||||
|
this._audioBufferBytes = 0
|
||||||
|
this._sttReady = false
|
||||||
|
this._audioStarted = false
|
||||||
|
this._errorEmitted = false
|
||||||
|
|
||||||
|
// 약간의 딜레이 후 IDLE로 전이 (UI 애니메이션용)
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!this._session) {
|
||||||
|
this._setRecognitionState(RecognitionState.IDLE)
|
||||||
|
this._setAudioState(AudioState.IDLE)
|
||||||
|
}
|
||||||
|
}, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 에러 처리 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
private _handleError(error: D3ROError): void {
|
||||||
|
if (this._errorEmitted) return
|
||||||
|
this._errorEmitted = true
|
||||||
|
|
||||||
|
logger.error(`VoiceMode error [${error.code}]: ${error.message}`)
|
||||||
|
this._setRecognitionState(RecognitionState.ERROR)
|
||||||
|
|
||||||
|
this._stopAudio()
|
||||||
|
|
||||||
|
this.emit('error', { error, session: this._session ? { ...this._session } : null })
|
||||||
|
this._resetToIdle()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Action Queue (이벤트 직렬화) ───────────────────────
|
||||||
|
|
||||||
|
private _enqueueAction(action: VoiceAction): void {
|
||||||
|
this._actionQueue.push(action)
|
||||||
|
this._processQueue()
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _processQueue(): Promise<void> {
|
||||||
|
if (this._isProcessingQueue) return
|
||||||
|
this._isProcessingQueue = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (this._actionQueue.length > 0) {
|
||||||
|
const action = this._actionQueue.shift()!
|
||||||
|
await this._processAction(action)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this._isProcessingQueue = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _processAction(action: VoiceAction): Promise<void> {
|
||||||
|
try {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'press':
|
||||||
|
await this._handlePress(action)
|
||||||
|
break
|
||||||
|
case 'release':
|
||||||
|
await this._handleRelease(action)
|
||||||
|
break
|
||||||
|
case 'escape':
|
||||||
|
this.cancelSession()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Action processing error: ${error instanceof Error ? error.message : String(error)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _handlePress(action: VoiceAction): Promise<void> {
|
||||||
|
if (action.mode === 'dictation') {
|
||||||
|
// Dictation: hold-to-talk — press로 시작
|
||||||
|
if (!this.isActive) {
|
||||||
|
await this.startSession('dictation')
|
||||||
|
}
|
||||||
|
} else if (action.mode === 'hands-free') {
|
||||||
|
// HandsFree: toggle
|
||||||
|
if (this.isActive) {
|
||||||
|
await this.stopSession()
|
||||||
|
} else {
|
||||||
|
await this.startSession('hands-free')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _handleRelease(action: VoiceAction): Promise<void> {
|
||||||
|
if (action.mode === 'dictation' && this.isActive) {
|
||||||
|
// Dictation: hold-to-talk — release로 종료 (200ms 딜레이)
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 200))
|
||||||
|
await this.stopSession()
|
||||||
|
}
|
||||||
|
// HandsFree: release 무시
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 유틸리티 ───────────────────────────────────────────
|
||||||
|
|
||||||
|
private _resolveMode(config: HotkeyConfig): VoiceMode {
|
||||||
|
if (config.id === 'voice-handsfree' || config.doublePressEnabled) {
|
||||||
|
return 'hands-free'
|
||||||
|
}
|
||||||
|
return 'dictation'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 종료 ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this._disposed = true
|
||||||
|
this._actionQueue = []
|
||||||
|
|
||||||
|
// 진행 중 세션 취소
|
||||||
|
if (this._session && !this._isInTerminalState()) {
|
||||||
|
this._cancelSession('user')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 핫키 리스너 해제
|
||||||
|
const hotkey = getHotkeyService()
|
||||||
|
if (this._hotkeyPressHandler) {
|
||||||
|
hotkey.off('hotkey-pressed', this._hotkeyPressHandler)
|
||||||
|
}
|
||||||
|
if (this._hotkeyReleaseHandler) {
|
||||||
|
hotkey.off('hotkey-released', this._hotkeyReleaseHandler)
|
||||||
|
}
|
||||||
|
if (this._doublePressHandler) {
|
||||||
|
hotkey.off('double-press', this._doublePressHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 오디오 리스너 해제
|
||||||
|
this._stopAudio()
|
||||||
|
|
||||||
|
this._setRecognitionState(RecognitionState.DESTROYED)
|
||||||
|
this.removeAllListeners()
|
||||||
|
logger.info('VoiceModeService disposed')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── EventEmitter 타입 오버라이드 ───────────────────────
|
||||||
|
|
||||||
|
override on<K extends keyof VoiceModeEvents>(
|
||||||
|
event: K,
|
||||||
|
listener: VoiceModeEvents[K]
|
||||||
|
): this {
|
||||||
|
return super.on(event, listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
override off<K extends keyof VoiceModeEvents>(
|
||||||
|
event: K,
|
||||||
|
listener: VoiceModeEvents[K]
|
||||||
|
): this {
|
||||||
|
return super.off(event, listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
override emit<K extends keyof VoiceModeEvents>(
|
||||||
|
event: K,
|
||||||
|
...args: Parameters<VoiceModeEvents[K]>
|
||||||
|
): boolean {
|
||||||
|
return super.emit(event, ...args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 싱글톤 ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
let instance: VoiceModeService | null = null
|
||||||
|
|
||||||
|
export function getVoiceModeService(): VoiceModeService {
|
||||||
|
if (!instance) {
|
||||||
|
instance = new VoiceModeService()
|
||||||
|
}
|
||||||
|
return instance
|
||||||
|
}
|
||||||
26
src/main/services/index.ts
Normal file
26
src/main/services/index.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
// src/main/services/index.ts — 서비스 레지스트리
|
||||||
|
|
||||||
|
export { initLoggerService, getLogger } from './LoggerService'
|
||||||
|
export {
|
||||||
|
initConfigService,
|
||||||
|
getConfigService,
|
||||||
|
configGet,
|
||||||
|
configSet,
|
||||||
|
configGetAll,
|
||||||
|
configReset,
|
||||||
|
onConfigChanged
|
||||||
|
} from './ConfigService'
|
||||||
|
export {
|
||||||
|
getLocalSTTService,
|
||||||
|
LocalSTTService,
|
||||||
|
STTState
|
||||||
|
} from './LocalSTTService'
|
||||||
|
export type {
|
||||||
|
TranscriptionResult,
|
||||||
|
TranscriptionSegment,
|
||||||
|
TranscribeOptions,
|
||||||
|
LocalSTTEvents
|
||||||
|
} from './LocalSTTService'
|
||||||
|
export { getHotkeyService } from './HotkeyService'
|
||||||
|
export { getVoiceModeService } from './VoiceModeService'
|
||||||
|
export { getAudioCaptureService } from './AudioCaptureService'
|
||||||
33
src/main/types/node-record-lpcm16.d.ts
vendored
Normal file
33
src/main/types/node-record-lpcm16.d.ts
vendored
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Type declarations for node-record-lpcm16 (no @types package available)
|
||||||
|
|
||||||
|
declare module 'node-record-lpcm16' {
|
||||||
|
import { ChildProcess } from 'child_process'
|
||||||
|
import { Readable } from 'stream'
|
||||||
|
|
||||||
|
interface RecordingOptions {
|
||||||
|
sampleRate?: number
|
||||||
|
channels?: number
|
||||||
|
compress?: boolean
|
||||||
|
threshold?: number
|
||||||
|
thresholdStart?: number | null
|
||||||
|
thresholdEnd?: number | null
|
||||||
|
silence?: string
|
||||||
|
recorder?: 'sox' | 'rec' | 'arecord'
|
||||||
|
endOnSilence?: boolean
|
||||||
|
audioType?: string
|
||||||
|
device?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Recording {
|
||||||
|
process: ChildProcess
|
||||||
|
stop(): void
|
||||||
|
pause(): void
|
||||||
|
resume(): void
|
||||||
|
isPaused(): boolean
|
||||||
|
stream(): Readable
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(options?: RecordingOptions): Recording
|
||||||
|
|
||||||
|
export { record, Recording, RecordingOptions }
|
||||||
|
}
|
||||||
55
src/main/windows/TrayManager.ts
Normal file
55
src/main/windows/TrayManager.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
// src/main/windows/TrayManager.ts
|
||||||
|
|
||||||
|
import { Tray, Menu, app, nativeImage } from 'electron'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { getMainWindow } from './WindowManager'
|
||||||
|
import { getLogger } from '../services/LoggerService'
|
||||||
|
import { setIsQuitting } from '../lifecycle'
|
||||||
|
|
||||||
|
const logger = getLogger('TrayManager')
|
||||||
|
|
||||||
|
let tray: Tray | null = null
|
||||||
|
|
||||||
|
export function createTray(): void {
|
||||||
|
// 16x16 빈 아이콘 생성 (리소스 아이콘이 없을 때 폴백)
|
||||||
|
const icon = nativeImage.createEmpty()
|
||||||
|
tray = new Tray(icon)
|
||||||
|
|
||||||
|
const contextMenu = Menu.buildFromTemplate([
|
||||||
|
{
|
||||||
|
label: '표시',
|
||||||
|
click: () => {
|
||||||
|
const mainWindow = getMainWindow()
|
||||||
|
if (mainWindow) {
|
||||||
|
mainWindow.show()
|
||||||
|
mainWindow.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: '종료',
|
||||||
|
click: () => {
|
||||||
|
setIsQuitting(true)
|
||||||
|
app.quit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
tray.setToolTip('D3RO Voice')
|
||||||
|
tray.setContextMenu(contextMenu)
|
||||||
|
|
||||||
|
tray.on('double-click', () => {
|
||||||
|
const mainWindow = getMainWindow()
|
||||||
|
if (mainWindow) {
|
||||||
|
mainWindow.show()
|
||||||
|
mainWindow.focus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info('Tray created')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTray(): Tray | null {
|
||||||
|
return tray
|
||||||
|
}
|
||||||
66
src/main/windows/WindowManager.ts
Normal file
66
src/main/windows/WindowManager.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
// src/main/windows/WindowManager.ts
|
||||||
|
|
||||||
|
import { BrowserWindow, shell } from 'electron'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { is } from '@electron-toolkit/utils'
|
||||||
|
import { WINDOW_SIZE } from '@shared/constants'
|
||||||
|
import { getLogger } from '../services/LoggerService'
|
||||||
|
import { getIsQuitting, setIsQuitting } from '../lifecycle'
|
||||||
|
import { configGet } from '../services/ConfigService'
|
||||||
|
|
||||||
|
const logger = getLogger('WindowManager')
|
||||||
|
|
||||||
|
let mainWindow: BrowserWindow | null = null
|
||||||
|
|
||||||
|
export function getMainWindow(): BrowserWindow | null {
|
||||||
|
return mainWindow
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMainWindow(): BrowserWindow {
|
||||||
|
mainWindow = new BrowserWindow({
|
||||||
|
width: WINDOW_SIZE.MAIN.width,
|
||||||
|
height: WINDOW_SIZE.MAIN.height,
|
||||||
|
minWidth: 800,
|
||||||
|
minHeight: 600,
|
||||||
|
show: false,
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
webPreferences: {
|
||||||
|
preload: join(__dirname, '../preload/index.js'),
|
||||||
|
sandbox: false,
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
mainWindow.on('ready-to-show', () => {
|
||||||
|
mainWindow?.show()
|
||||||
|
logger.info('Main window shown')
|
||||||
|
})
|
||||||
|
|
||||||
|
mainWindow.on('close', (event) => {
|
||||||
|
if (!getIsQuitting() && configGet('closeToTray')) {
|
||||||
|
event.preventDefault()
|
||||||
|
mainWindow?.hide()
|
||||||
|
logger.info('Main window hidden to tray')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
mainWindow.on('closed', () => {
|
||||||
|
mainWindow = null
|
||||||
|
})
|
||||||
|
|
||||||
|
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||||
|
shell.openExternal(details.url)
|
||||||
|
return { action: 'deny' }
|
||||||
|
})
|
||||||
|
|
||||||
|
// 개발/프로덕션 URL 로드
|
||||||
|
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||||
|
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
||||||
|
} else {
|
||||||
|
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Main window created')
|
||||||
|
return mainWindow
|
||||||
|
}
|
||||||
164
src/preload/index.ts
Normal file
164
src/preload/index.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
// src/preload/index.ts
|
||||||
|
// contextBridge로 렌더러에 노출할 API 정의
|
||||||
|
|
||||||
|
import { contextBridge, ipcRenderer } from 'electron'
|
||||||
|
import { IPC_CHANNELS } from '@shared/ipc-channels'
|
||||||
|
import type {
|
||||||
|
AudioDevice,
|
||||||
|
SetDeviceParams,
|
||||||
|
TestDeviceParams,
|
||||||
|
TestDeviceResult,
|
||||||
|
AudioDeviceChangedEvent,
|
||||||
|
AppConfig,
|
||||||
|
ConfigGetParams,
|
||||||
|
ConfigSetParams,
|
||||||
|
ConfigResetParams,
|
||||||
|
SetThemeParams,
|
||||||
|
SetLanguageParams,
|
||||||
|
ThemeMode,
|
||||||
|
ConfigChangedEvent,
|
||||||
|
VoiceState,
|
||||||
|
VoiceMode,
|
||||||
|
AudioLevelEvent,
|
||||||
|
VoiceStateChangedEvent,
|
||||||
|
TranscriptionDeltaEvent,
|
||||||
|
TranscriptionCompleteEvent,
|
||||||
|
VoiceErrorEvent,
|
||||||
|
StartRecordingParams,
|
||||||
|
StartRecordingResult,
|
||||||
|
StopRecordingParams,
|
||||||
|
StopRecordingResult,
|
||||||
|
CancelRecordingParams,
|
||||||
|
SetVoiceModeParams,
|
||||||
|
STTStatus,
|
||||||
|
STTModel,
|
||||||
|
SetSTTModelParams,
|
||||||
|
SetSTTLanguageParams,
|
||||||
|
STTStatusChangedEvent,
|
||||||
|
HotkeyBinding,
|
||||||
|
SetHotkeyParams,
|
||||||
|
SetEnabledParams,
|
||||||
|
HotkeyTriggeredEvent,
|
||||||
|
PermissionStatus
|
||||||
|
} from '@shared/types'
|
||||||
|
import type { IPCResult } from '@shared/errors'
|
||||||
|
|
||||||
|
type Unsubscribe = () => void
|
||||||
|
|
||||||
|
function invoke<TResult>(channel: string, ...args: unknown[]): Promise<IPCResult<TResult>> {
|
||||||
|
return ipcRenderer.invoke(channel, ...args)
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(channel: string, ...args: unknown[]): void {
|
||||||
|
ipcRenderer.send(channel, ...args)
|
||||||
|
}
|
||||||
|
|
||||||
|
function on<T>(channel: string, callback: (data: T) => void): Unsubscribe {
|
||||||
|
const listener = (_event: Electron.IpcRendererEvent, data: T) => callback(data)
|
||||||
|
ipcRenderer.on(channel, listener)
|
||||||
|
return () => ipcRenderer.removeListener(channel, listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
const electronAPI = {
|
||||||
|
// ── Audio ──────────────────────────────────────────────
|
||||||
|
audio: {
|
||||||
|
getDevices: () => invoke<AudioDevice[]>(IPC_CHANNELS.AUDIO.GET_DEVICES),
|
||||||
|
getSelectedDevice: () => invoke<string | null>(IPC_CHANNELS.AUDIO.GET_SELECTED_DEVICE),
|
||||||
|
setSelectedDevice: (params: SetDeviceParams) =>
|
||||||
|
invoke<void>(IPC_CHANNELS.AUDIO.SET_SELECTED_DEVICE, params),
|
||||||
|
testDevice: (params: TestDeviceParams) =>
|
||||||
|
invoke<TestDeviceResult>(IPC_CHANNELS.AUDIO.TEST_DEVICE, params),
|
||||||
|
onDeviceChanged: (cb: (e: AudioDeviceChangedEvent) => void): Unsubscribe =>
|
||||||
|
on(IPC_CHANNELS.AUDIO.DEVICE_CHANGED, cb)
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Config ─────────────────────────────────────────────
|
||||||
|
config: {
|
||||||
|
get: (params: ConfigGetParams) => invoke<unknown>(IPC_CHANNELS.CONFIG.GET, params),
|
||||||
|
set: (params: ConfigSetParams) => invoke<void>(IPC_CHANNELS.CONFIG.SET, params),
|
||||||
|
getAll: () => invoke<AppConfig>(IPC_CHANNELS.CONFIG.GET_ALL),
|
||||||
|
reset: (params: ConfigResetParams) => invoke<void>(IPC_CHANNELS.CONFIG.RESET, params),
|
||||||
|
getTheme: () => invoke<ThemeMode>(IPC_CHANNELS.CONFIG.GET_THEME),
|
||||||
|
setTheme: (params: SetThemeParams) => invoke<void>(IPC_CHANNELS.CONFIG.SET_THEME, params),
|
||||||
|
getLanguage: () => invoke<string>(IPC_CHANNELS.CONFIG.GET_LANGUAGE),
|
||||||
|
setLanguage: (params: SetLanguageParams) =>
|
||||||
|
invoke<void>(IPC_CHANNELS.CONFIG.SET_LANGUAGE, params),
|
||||||
|
onChanged: (cb: (e: ConfigChangedEvent) => void): Unsubscribe =>
|
||||||
|
on(IPC_CHANNELS.CONFIG.CHANGED, cb)
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Voice ──────────────────────────────────────────────
|
||||||
|
voice: {
|
||||||
|
startRecording: (params: StartRecordingParams) =>
|
||||||
|
invoke<StartRecordingResult>(IPC_CHANNELS.VOICE.START_RECORDING, params),
|
||||||
|
stopRecording: (params: StopRecordingParams) =>
|
||||||
|
invoke<StopRecordingResult>(IPC_CHANNELS.VOICE.STOP_RECORDING, params),
|
||||||
|
cancelRecording: (params: CancelRecordingParams) =>
|
||||||
|
invoke<void>(IPC_CHANNELS.VOICE.CANCEL_RECORDING, params),
|
||||||
|
getState: () => invoke<VoiceState>(IPC_CHANNELS.VOICE.GET_STATE),
|
||||||
|
setMode: (params: SetVoiceModeParams) =>
|
||||||
|
invoke<void>(IPC_CHANNELS.VOICE.SET_MODE, params),
|
||||||
|
getMode: () => invoke<VoiceMode>(IPC_CHANNELS.VOICE.GET_MODE),
|
||||||
|
onStateChanged: (cb: (e: VoiceStateChangedEvent) => void): Unsubscribe =>
|
||||||
|
on(IPC_CHANNELS.VOICE.STATE_CHANGED, cb),
|
||||||
|
onTranscriptionDelta: (cb: (e: TranscriptionDeltaEvent) => void): Unsubscribe =>
|
||||||
|
on(IPC_CHANNELS.VOICE.TRANSCRIPTION_DELTA, cb),
|
||||||
|
onTranscriptionComplete: (cb: (e: TranscriptionCompleteEvent) => void): Unsubscribe =>
|
||||||
|
on(IPC_CHANNELS.VOICE.TRANSCRIPTION_COMPLETE, cb),
|
||||||
|
onError: (cb: (e: VoiceErrorEvent) => void): Unsubscribe =>
|
||||||
|
on(IPC_CHANNELS.VOICE.ERROR, cb),
|
||||||
|
onAudioLevel: (cb: (e: AudioLevelEvent) => void): Unsubscribe =>
|
||||||
|
on(IPC_CHANNELS.VOICE.AUDIO_LEVEL, cb)
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── STT ────────────────────────────────────────────────
|
||||||
|
stt: {
|
||||||
|
getStatus: () => invoke<STTStatus>(IPC_CHANNELS.STT.GET_STATUS),
|
||||||
|
getModels: () => invoke<STTModel[]>(IPC_CHANNELS.STT.GET_MODELS),
|
||||||
|
getActiveModel: () => invoke<string | null>(IPC_CHANNELS.STT.GET_ACTIVE_MODEL),
|
||||||
|
setModel: (params: SetSTTModelParams) =>
|
||||||
|
invoke<void>(IPC_CHANNELS.STT.SET_MODEL, params),
|
||||||
|
getLanguage: () => invoke<string>(IPC_CHANNELS.STT.GET_LANGUAGE),
|
||||||
|
setLanguage: (params: SetSTTLanguageParams) =>
|
||||||
|
invoke<void>(IPC_CHANNELS.STT.SET_LANGUAGE, params),
|
||||||
|
onStatusChanged: (cb: (e: STTStatusChangedEvent) => void): Unsubscribe =>
|
||||||
|
on(IPC_CHANNELS.STT.STATUS_CHANGED, cb)
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Hotkey ─────────────────────────────────────────────
|
||||||
|
hotkey: {
|
||||||
|
getDictationShortcut: () =>
|
||||||
|
invoke<HotkeyBinding>(IPC_CHANNELS.HOTKEY.GET_DICTATION_SHORTCUT),
|
||||||
|
setDictationShortcut: (params: SetHotkeyParams) =>
|
||||||
|
invoke<void>(IPC_CHANNELS.HOTKEY.SET_DICTATION_SHORTCUT, params),
|
||||||
|
getHandsFreeShortcut: () =>
|
||||||
|
invoke<HotkeyBinding>(IPC_CHANNELS.HOTKEY.GET_HANDS_FREE_SHORTCUT),
|
||||||
|
setHandsFreeShortcut: (params: SetHotkeyParams) =>
|
||||||
|
invoke<void>(IPC_CHANNELS.HOTKEY.SET_HANDS_FREE_SHORTCUT, params),
|
||||||
|
isEnabled: () => invoke<boolean>(IPC_CHANNELS.HOTKEY.IS_ENABLED),
|
||||||
|
setEnabled: (params: SetEnabledParams) =>
|
||||||
|
invoke<void>(IPC_CHANNELS.HOTKEY.SET_ENABLED, params),
|
||||||
|
onTriggered: (cb: (e: HotkeyTriggeredEvent) => void): Unsubscribe =>
|
||||||
|
on(IPC_CHANNELS.HOTKEY.TRIGGERED, cb)
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Window ─────────────────────────────────────────────
|
||||||
|
window: {
|
||||||
|
minimize: () => send(IPC_CHANNELS.WINDOW.MINIMIZE),
|
||||||
|
maximize: () => send(IPC_CHANNELS.WINDOW.MAXIMIZE),
|
||||||
|
close: () => send(IPC_CHANNELS.WINDOW.CLOSE),
|
||||||
|
isMaximized: () => invoke<boolean>(IPC_CHANNELS.WINDOW.IS_MAXIMIZED)
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── System ─────────────────────────────────────────────
|
||||||
|
system: {
|
||||||
|
getPlatform: () => invoke<NodeJS.Platform>(IPC_CHANNELS.SYSTEM.GET_PLATFORM),
|
||||||
|
getVersion: () => invoke<string>(IPC_CHANNELS.SYSTEM.GET_VERSION),
|
||||||
|
checkMicPermission: () =>
|
||||||
|
invoke<PermissionStatus>(IPC_CHANNELS.SYSTEM.CHECK_MIC_PERMISSION)
|
||||||
|
}
|
||||||
|
} as const
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('electronAPI', electronAPI)
|
||||||
|
|
||||||
|
export type ElectronAPI = typeof electronAPI
|
||||||
26
src/renderer/App.tsx
Normal file
26
src/renderer/App.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
// src/renderer/App.tsx — 루트 컴포넌트
|
||||||
|
|
||||||
|
import { useState, useMemo } from 'react'
|
||||||
|
import { ThemeProvider, CssBaseline, useMediaQuery } from '@mui/material'
|
||||||
|
import { lightTheme, darkTheme } from './theme'
|
||||||
|
import { AppLayout } from './components/AppLayout'
|
||||||
|
import type { ThemeMode } from '@shared/types'
|
||||||
|
|
||||||
|
export function App(): React.ReactElement {
|
||||||
|
const [themeMode] = useState<ThemeMode>('auto')
|
||||||
|
const prefersDark = useMediaQuery('(prefers-color-scheme: dark)')
|
||||||
|
|
||||||
|
const theme = useMemo(() => {
|
||||||
|
if (themeMode === 'auto') {
|
||||||
|
return prefersDark ? darkTheme : lightTheme
|
||||||
|
}
|
||||||
|
return themeMode === 'dark' ? darkTheme : lightTheme
|
||||||
|
}, [themeMode, prefersDark])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
|
<CssBaseline />
|
||||||
|
<AppLayout />
|
||||||
|
</ThemeProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
110
src/renderer/components/AppLayout.tsx
Normal file
110
src/renderer/components/AppLayout.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
// src/renderer/components/AppLayout.tsx
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Drawer,
|
||||||
|
List,
|
||||||
|
ListItemButton,
|
||||||
|
ListItemIcon,
|
||||||
|
ListItemText,
|
||||||
|
Divider,
|
||||||
|
Typography,
|
||||||
|
Chip
|
||||||
|
} from '@mui/material'
|
||||||
|
import DashboardIcon from '@mui/icons-material/Dashboard'
|
||||||
|
import HistoryIcon from '@mui/icons-material/History'
|
||||||
|
import MenuBookIcon from '@mui/icons-material/MenuBook'
|
||||||
|
import SettingsIcon from '@mui/icons-material/Settings'
|
||||||
|
import { DashboardPage } from '../pages/DashboardPage'
|
||||||
|
|
||||||
|
type Route = 'dashboard' | 'history' | 'dictionary'
|
||||||
|
|
||||||
|
const DRAWER_WIDTH = 240
|
||||||
|
|
||||||
|
const NAV_ITEMS: Array<{ route: Route; label: string; icon: React.ReactElement }> = [
|
||||||
|
{ route: 'dashboard', label: 'Dashboard', icon: <DashboardIcon /> },
|
||||||
|
{ route: 'history', label: 'History', icon: <HistoryIcon /> },
|
||||||
|
{ route: 'dictionary', label: 'Dictionary', icon: <MenuBookIcon /> }
|
||||||
|
]
|
||||||
|
|
||||||
|
export function AppLayout(): React.ReactElement {
|
||||||
|
const [currentRoute, setCurrentRoute] = useState<Route>('dashboard')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', height: '100vh' }}>
|
||||||
|
{/* Sidebar Drawer */}
|
||||||
|
<Drawer
|
||||||
|
variant="permanent"
|
||||||
|
sx={{
|
||||||
|
width: DRAWER_WIDTH,
|
||||||
|
flexShrink: 0,
|
||||||
|
'& .MuiDrawer-paper': {
|
||||||
|
width: DRAWER_WIDTH,
|
||||||
|
boxSizing: 'border-box'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<Box sx={{ p: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Typography variant="h6" noWrap sx={{ fontWeight: 700 }}>
|
||||||
|
D3RO Voice
|
||||||
|
</Typography>
|
||||||
|
<Chip label="v1.0" size="small" variant="outlined" />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<List sx={{ flex: 1, pt: 1 }}>
|
||||||
|
{NAV_ITEMS.map((item) => (
|
||||||
|
<ListItemButton
|
||||||
|
key={item.route}
|
||||||
|
selected={currentRoute === item.route}
|
||||||
|
onClick={() => setCurrentRoute(item.route)}
|
||||||
|
sx={{ my: 0.5 }}
|
||||||
|
>
|
||||||
|
<ListItemIcon sx={{ minWidth: 40 }}>{item.icon}</ListItemIcon>
|
||||||
|
<ListItemText primary={item.label} />
|
||||||
|
</ListItemButton>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* Bottom */}
|
||||||
|
<List>
|
||||||
|
<ListItemButton sx={{ my: 0.5 }}>
|
||||||
|
<ListItemIcon sx={{ minWidth: 40 }}>
|
||||||
|
<SettingsIcon />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary="Settings" />
|
||||||
|
</ListItemButton>
|
||||||
|
</List>
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
{/* Content Area */}
|
||||||
|
<Box
|
||||||
|
component="main"
|
||||||
|
sx={{
|
||||||
|
flexGrow: 1,
|
||||||
|
p: 3,
|
||||||
|
overflow: 'auto',
|
||||||
|
bgcolor: 'background.default'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{currentRoute === 'dashboard' && <DashboardPage />}
|
||||||
|
{currentRoute === 'history' && (
|
||||||
|
<Typography variant="h5" color="text.secondary">
|
||||||
|
History (Phase 5)
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{currentRoute === 'dictionary' && (
|
||||||
|
<Typography variant="h5" color="text.secondary">
|
||||||
|
Dictionary (Phase 5)
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
8
src/renderer/electron.d.ts
vendored
Normal file
8
src/renderer/electron.d.ts
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
// src/renderer/electron.d.ts
|
||||||
|
import type { ElectronAPI } from '../preload/index'
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
electronAPI: ElectronAPI
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/renderer/index.html
Normal file
12
src/renderer/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>D3RO Voice</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="./main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
14
src/renderer/main.tsx
Normal file
14
src/renderer/main.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
// src/renderer/main.tsx — 렌더러 진입점
|
||||||
|
|
||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { App } from './App'
|
||||||
|
|
||||||
|
const root = document.getElementById('root')
|
||||||
|
if (root) {
|
||||||
|
createRoot(root).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>
|
||||||
|
)
|
||||||
|
}
|
||||||
67
src/renderer/pages/DashboardPage.tsx
Normal file
67
src/renderer/pages/DashboardPage.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
// src/renderer/pages/DashboardPage.tsx
|
||||||
|
|
||||||
|
import { Box, Card, CardContent, Typography, Grid } from '@mui/material'
|
||||||
|
import MicIcon from '@mui/icons-material/Mic'
|
||||||
|
import TimerIcon from '@mui/icons-material/Timer'
|
||||||
|
import TextFieldsIcon from '@mui/icons-material/TextFields'
|
||||||
|
import TodayIcon from '@mui/icons-material/Today'
|
||||||
|
|
||||||
|
interface StatCardProps {
|
||||||
|
title: string
|
||||||
|
value: string
|
||||||
|
icon: React.ReactElement
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ title, value, icon }: StatCardProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||||
|
<Box sx={{ color: 'primary.main' }}>{icon}</Box>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="h4">{value}</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardPage(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5" sx={{ mb: 3, fontWeight: 600 }}>
|
||||||
|
Dashboard
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||||
|
<StatCard title="Total Sessions" value="0" icon={<MicIcon />} />
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||||
|
<StatCard title="Total Time" value="0:00" icon={<TimerIcon />} />
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||||
|
<StatCard title="Total Words" value="0" icon={<TextFieldsIcon />} />
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||||
|
<StatCard title="Streak" value="0 days" icon={<TodayIcon />} />
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Box sx={{ mt: 4 }}>
|
||||||
|
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||||
|
Recent Sessions
|
||||||
|
</Typography>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', py: 4 }}>
|
||||||
|
No sessions yet. Press the hotkey to start recording.
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
148
src/renderer/theme.ts
Normal file
148
src/renderer/theme.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
// src/renderer/theme.ts — MUI 7 테마 정의 (설계서 03 기반)
|
||||||
|
|
||||||
|
import { createTheme, type ThemeOptions } from '@mui/material/styles'
|
||||||
|
|
||||||
|
const commonOptions: ThemeOptions = {
|
||||||
|
typography: {
|
||||||
|
fontFamily: [
|
||||||
|
'-apple-system',
|
||||||
|
'BlinkMacSystemFont',
|
||||||
|
'"Segoe UI"',
|
||||||
|
'Roboto',
|
||||||
|
'"Helvetica Neue"',
|
||||||
|
'Arial',
|
||||||
|
'sans-serif'
|
||||||
|
].join(','),
|
||||||
|
h4: { fontWeight: 600, fontSize: '1.5rem' },
|
||||||
|
h5: { fontWeight: 600, fontSize: '1.25rem' },
|
||||||
|
h6: { fontWeight: 600, fontSize: '1rem' },
|
||||||
|
subtitle1: { fontWeight: 500 },
|
||||||
|
body1: { fontSize: '0.9375rem' },
|
||||||
|
body2: { fontSize: '0.8125rem' },
|
||||||
|
button: { textTransform: 'none' as const, fontWeight: 500 }
|
||||||
|
},
|
||||||
|
shape: {
|
||||||
|
borderRadius: 12
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
MuiButton: {
|
||||||
|
defaultProps: {
|
||||||
|
disableElevation: true
|
||||||
|
},
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
textTransform: 'none',
|
||||||
|
fontWeight: 500,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '8px 16px'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
MuiCard: {
|
||||||
|
defaultProps: {
|
||||||
|
elevation: 0
|
||||||
|
},
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
borderRadius: 12,
|
||||||
|
border: '1px solid'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
MuiDrawer: {
|
||||||
|
styleOverrides: {
|
||||||
|
paper: {
|
||||||
|
width: 240,
|
||||||
|
borderRight: 'none'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
MuiListItemButton: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
borderRadius: 8,
|
||||||
|
marginLeft: 8,
|
||||||
|
marginRight: 8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
MuiTextField: {
|
||||||
|
defaultProps: {
|
||||||
|
size: 'small',
|
||||||
|
variant: 'outlined'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
MuiChip: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
borderRadius: 6,
|
||||||
|
fontWeight: 500
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const lightTheme = createTheme({
|
||||||
|
...commonOptions,
|
||||||
|
palette: {
|
||||||
|
mode: 'light',
|
||||||
|
primary: {
|
||||||
|
main: 'rgb(31, 93, 242)',
|
||||||
|
light: 'rgb(71, 133, 255)',
|
||||||
|
dark: 'rgb(20, 65, 180)',
|
||||||
|
contrastText: '#FFFFFF'
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
main: 'rgb(108, 117, 125)',
|
||||||
|
light: 'rgb(173, 181, 189)',
|
||||||
|
dark: 'rgb(73, 80, 87)'
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
default: '#F9F9F9',
|
||||||
|
paper: '#FFFFFF'
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
primary: 'rgba(0, 0, 0, 0.87)',
|
||||||
|
secondary: 'rgba(0, 0, 0, 0.6)'
|
||||||
|
},
|
||||||
|
divider: 'rgba(0, 0, 0, 0.08)',
|
||||||
|
error: { main: '#D32F2F' },
|
||||||
|
success: { main: '#2E7D32' },
|
||||||
|
warning: { main: '#ED6C02' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const darkTheme = createTheme({
|
||||||
|
...commonOptions,
|
||||||
|
palette: {
|
||||||
|
mode: 'dark',
|
||||||
|
primary: {
|
||||||
|
main: 'rgb(71, 133, 255)',
|
||||||
|
light: 'rgb(120, 170, 255)',
|
||||||
|
dark: 'rgb(31, 93, 242)',
|
||||||
|
contrastText: '#FFFFFF'
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
main: 'rgb(173, 181, 189)',
|
||||||
|
light: 'rgb(206, 212, 218)',
|
||||||
|
dark: 'rgb(108, 117, 125)'
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
default: '#121212',
|
||||||
|
paper: '#1E1E1E'
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
primary: 'rgba(255, 255, 255, 0.87)',
|
||||||
|
secondary: 'rgba(255, 255, 255, 0.6)'
|
||||||
|
},
|
||||||
|
divider: 'rgba(255, 255, 255, 0.08)',
|
||||||
|
error: { main: '#EF5350' },
|
||||||
|
success: { main: '#4CAF50' },
|
||||||
|
warning: { main: '#FFA726' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export function getTheme(mode: 'light' | 'dark') {
|
||||||
|
return mode === 'dark' ? darkTheme : lightTheme
|
||||||
|
}
|
||||||
58
src/shared/constants.ts
Normal file
58
src/shared/constants.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
// src/shared/constants.ts
|
||||||
|
|
||||||
|
/** 타이밍 상수 (Speakly 리버스엔지니어링 기반) */
|
||||||
|
export const TIMING = {
|
||||||
|
/** 더블프레스 감지 간격 (ms) */
|
||||||
|
DOUBLE_PRESS_DURATION: 300,
|
||||||
|
|
||||||
|
/** 최소 녹음 시간 (ms) — 이하 자동 취소 */
|
||||||
|
MIN_AUDIO_DURATION: 700,
|
||||||
|
|
||||||
|
/** 녹음 후 STT 대기 시간 (ms) */
|
||||||
|
POST_RECORDING_WAIT: 4000,
|
||||||
|
|
||||||
|
/** 녹음 후 STT 대기 (버퍼 있을 때) (ms) */
|
||||||
|
POST_RECORDING_WAIT_BUFFERED: 6000,
|
||||||
|
|
||||||
|
/** STT 아이들 타임아웃 (ms) */
|
||||||
|
STT_IDLE_TIMEOUT: 30000,
|
||||||
|
|
||||||
|
/** 절대 최대 대기 시간 (ms) */
|
||||||
|
ABSOLUTE_MAX_WAIT: 120000,
|
||||||
|
|
||||||
|
/** 오디오 레벨 전송 간격 (ms) */
|
||||||
|
AUDIO_LEVEL_INTERVAL: 100,
|
||||||
|
|
||||||
|
/** 서비스 종료 타임아웃 (ms) */
|
||||||
|
SERVICE_DESTROY_TIMEOUT: 3000,
|
||||||
|
|
||||||
|
/** 사이드카 헬스체크 지연 (ms) */
|
||||||
|
SIDECAR_HEALTH_DELAY: 3000,
|
||||||
|
|
||||||
|
/** LLM 요청 타임아웃 (ms) */
|
||||||
|
LLM_REQUEST_TIMEOUT: 30000
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/** 웨이브 바 상수 (RecordingTip) */
|
||||||
|
export const WAVE_BAR = {
|
||||||
|
COUNT: 9,
|
||||||
|
ANIMATION_INTERVAL: 100,
|
||||||
|
|
||||||
|
/** 코사인 분포 가중치 (Speakly 패턴) */
|
||||||
|
COS_WEIGHTS: Array.from({ length: 9 }, (_, i) => Math.cos((i - 4) * (Math.PI / 9)))
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/** 오디오 포맷 */
|
||||||
|
export const AUDIO_FORMAT = {
|
||||||
|
SAMPLE_RATE: 16000,
|
||||||
|
CHANNELS: 1,
|
||||||
|
BIT_DEPTH: 16,
|
||||||
|
BYTES_PER_SAMPLE: 2
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/** 윈도우 크기 */
|
||||||
|
export const WINDOW_SIZE = {
|
||||||
|
MAIN: { width: 1104, height: 816 },
|
||||||
|
RECORDING_TIP: { width: 280, height: 80 },
|
||||||
|
RESULT_POPUP: { width: 400, height: 200 }
|
||||||
|
} as const
|
||||||
162
src/shared/errors.ts
Normal file
162
src/shared/errors.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
// src/shared/errors.ts
|
||||||
|
|
||||||
|
export enum ErrorCode {
|
||||||
|
// === Success ===
|
||||||
|
Success = 0,
|
||||||
|
|
||||||
|
// === STT (100-199) ===
|
||||||
|
STTEngineNotInstalled = 100,
|
||||||
|
STTModelNotFound = 101,
|
||||||
|
STTModelNotLoaded = 102,
|
||||||
|
STTModelLoadFailed = 103,
|
||||||
|
STTModelDownloadFailed = 104,
|
||||||
|
STTModelDownloadCancelled = 105,
|
||||||
|
STTTranscriptionFailed = 110,
|
||||||
|
STTTranscriptionTimeout = 111,
|
||||||
|
STTTranscriptionCancelled = 112,
|
||||||
|
STTNoAudioData = 113,
|
||||||
|
STTAudioTooShort = 114,
|
||||||
|
STTLanguageNotSupported = 120,
|
||||||
|
STTSidecarSpawnFailed = 130,
|
||||||
|
STTSidecarCrashed = 131,
|
||||||
|
STTSidecarCommunicationFailed = 132,
|
||||||
|
STTGPUNotAvailable = 140,
|
||||||
|
|
||||||
|
// === TTS (200-299) ===
|
||||||
|
TTSEngineNotInstalled = 200,
|
||||||
|
TTSVoiceNotFound = 201,
|
||||||
|
TTSVoiceNotLoaded = 202,
|
||||||
|
TTSVoiceLoadFailed = 203,
|
||||||
|
TTSVoiceDownloadFailed = 204,
|
||||||
|
TTSSynthesisFailed = 210,
|
||||||
|
TTSPlaybackFailed = 211,
|
||||||
|
TTSPlaybackInterrupted = 212,
|
||||||
|
TTSTextTooLong = 220,
|
||||||
|
TTSTextEmpty = 221,
|
||||||
|
|
||||||
|
// === LLM / Ollama (300-399) ===
|
||||||
|
LLMServerUnreachable = 300,
|
||||||
|
LLMServerConnectionFailed = 301,
|
||||||
|
LLMServerTimeout = 302,
|
||||||
|
LLMModelNotFound = 310,
|
||||||
|
LLMModelNotLoaded = 311,
|
||||||
|
LLMModelLoadFailed = 312,
|
||||||
|
LLMModelPullFailed = 313,
|
||||||
|
LLMModelPullCancelled = 314,
|
||||||
|
LLMProcessingFailed = 320,
|
||||||
|
LLMProcessingTimeout = 321,
|
||||||
|
LLMProcessingCancelled = 322,
|
||||||
|
LLMResponseParseFailed = 323,
|
||||||
|
LLMInvalidAction = 330,
|
||||||
|
LLMPromptTooLong = 331,
|
||||||
|
|
||||||
|
// === Audio (400-499) ===
|
||||||
|
AudioDeviceNotFound = 400,
|
||||||
|
AudioDeviceAccessDenied = 401,
|
||||||
|
AudioDeviceBusy = 402,
|
||||||
|
AudioCaptureStartFailed = 410,
|
||||||
|
AudioCaptureStopFailed = 411,
|
||||||
|
AudioCaptureFailed = 412,
|
||||||
|
AudioNoPermission = 420,
|
||||||
|
AudioStreamError = 430,
|
||||||
|
AudioBufferOverflow = 431,
|
||||||
|
|
||||||
|
// === Hotkey (500-599) ===
|
||||||
|
HotkeyRegistrationFailed = 500,
|
||||||
|
HotkeyConflict = 501,
|
||||||
|
HotkeySystemReserved = 502,
|
||||||
|
HotkeyHookInitFailed = 510,
|
||||||
|
HotkeyHookCrashed = 511,
|
||||||
|
|
||||||
|
// === TextInsert (600-699) ===
|
||||||
|
TextInsertFailed = 600,
|
||||||
|
TextInsertClipboardSaveFailed = 601,
|
||||||
|
TextInsertClipboardRestoreFailed = 602,
|
||||||
|
TextInsertKeySimulationFailed = 603,
|
||||||
|
TextInsertNoActiveWindow = 610,
|
||||||
|
TextInsertTargetAppNotResponding = 611,
|
||||||
|
|
||||||
|
// === History / Dictionary / DB (700-799) ===
|
||||||
|
DBOpenFailed = 700,
|
||||||
|
DBMigrationFailed = 701,
|
||||||
|
DBQueryFailed = 702,
|
||||||
|
DBWriteFailed = 703,
|
||||||
|
HistoryNotFound = 710,
|
||||||
|
HistoryExportFailed = 711,
|
||||||
|
DictionaryNotFound = 720,
|
||||||
|
DictionaryDuplicate = 721,
|
||||||
|
DictionaryImportFailed = 722,
|
||||||
|
DictionaryExportFailed = 723,
|
||||||
|
DictionaryImportInvalidFormat = 724,
|
||||||
|
|
||||||
|
// === Config (800-899) ===
|
||||||
|
ConfigReadFailed = 800,
|
||||||
|
ConfigWriteFailed = 801,
|
||||||
|
ConfigInvalidValue = 802,
|
||||||
|
ConfigKeyNotFound = 803,
|
||||||
|
ConfigResetFailed = 804,
|
||||||
|
ConfigMigrationFailed = 810,
|
||||||
|
|
||||||
|
// === System / Window (900-999) ===
|
||||||
|
WindowCreationFailed = 900,
|
||||||
|
WindowNotFound = 901,
|
||||||
|
TrayCreationFailed = 910,
|
||||||
|
NotificationFailed = 920,
|
||||||
|
PermissionDenied = 930,
|
||||||
|
ExternalOpenFailed = 940,
|
||||||
|
SoundPlayFailed = 950,
|
||||||
|
AppAlreadyRunning = 960,
|
||||||
|
UnknownError = 999
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* D3RO-VOICE 표준 에러 객체 (Speakly NXError 패턴)
|
||||||
|
*/
|
||||||
|
export class D3ROError extends Error {
|
||||||
|
readonly code: ErrorCode
|
||||||
|
readonly details?: Record<string, unknown>
|
||||||
|
|
||||||
|
constructor(code: ErrorCode, message: string, details?: Record<string, unknown>) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'D3ROError'
|
||||||
|
this.code = code
|
||||||
|
this.details = details
|
||||||
|
}
|
||||||
|
|
||||||
|
toJSON(): D3ROErrorJSON {
|
||||||
|
return {
|
||||||
|
code: this.code,
|
||||||
|
message: this.message,
|
||||||
|
details: this.details
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromJSON(json: D3ROErrorJSON): D3ROError {
|
||||||
|
return new D3ROError(json.code, json.message, json.details)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface D3ROErrorJSON {
|
||||||
|
code: ErrorCode
|
||||||
|
message: string
|
||||||
|
details?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IPC 핸들러에서 사용하는 표준 응답 래퍼.
|
||||||
|
*/
|
||||||
|
export type IPCResult<T> =
|
||||||
|
| { success: true; data: T }
|
||||||
|
| { success: false; error: D3ROErrorJSON }
|
||||||
|
|
||||||
|
export function ipcSuccess<T>(data: T): IPCResult<T> {
|
||||||
|
return { success: true, data }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ipcError<T>(
|
||||||
|
code: ErrorCode,
|
||||||
|
message: string,
|
||||||
|
details?: Record<string, unknown>
|
||||||
|
): IPCResult<T> {
|
||||||
|
return { success: false, error: { code, message, details } }
|
||||||
|
}
|
||||||
172
src/shared/ipc-channels.ts
Normal file
172
src/shared/ipc-channels.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
// src/shared/ipc-channels.ts
|
||||||
|
// IPC 채널명 중앙 정의 — 모든 채널명은 이 파일에서만 정의한다.
|
||||||
|
|
||||||
|
export const IPC_CHANNELS = {
|
||||||
|
VOICE: {
|
||||||
|
START_RECORDING: 'voice:startRecording',
|
||||||
|
STOP_RECORDING: 'voice:stopRecording',
|
||||||
|
CANCEL_RECORDING: 'voice:cancelRecording',
|
||||||
|
GET_STATE: 'voice:getState',
|
||||||
|
SET_MODE: 'voice:setMode',
|
||||||
|
GET_MODE: 'voice:getMode',
|
||||||
|
// Main → Renderer events
|
||||||
|
STATE_CHANGED: 'voice:stateChanged',
|
||||||
|
TRANSCRIPTION_DELTA: 'voice:transcriptionDelta',
|
||||||
|
TRANSCRIPTION_COMPLETE: 'voice:transcriptionComplete',
|
||||||
|
ERROR: 'voice:error',
|
||||||
|
AUDIO_LEVEL: 'voice:audioLevel'
|
||||||
|
},
|
||||||
|
|
||||||
|
AUDIO: {
|
||||||
|
GET_DEVICES: 'audio:getDevices',
|
||||||
|
GET_SELECTED_DEVICE: 'audio:getSelectedDevice',
|
||||||
|
SET_SELECTED_DEVICE: 'audio:setSelectedDevice',
|
||||||
|
TEST_DEVICE: 'audio:testDevice',
|
||||||
|
// Main → Renderer events
|
||||||
|
DEVICE_CHANGED: 'audio:deviceChanged'
|
||||||
|
},
|
||||||
|
|
||||||
|
STT: {
|
||||||
|
GET_STATUS: 'stt:getStatus',
|
||||||
|
GET_MODELS: 'stt:getModels',
|
||||||
|
GET_ACTIVE_MODEL: 'stt:getActiveModel',
|
||||||
|
SET_MODEL: 'stt:setModel',
|
||||||
|
DOWNLOAD_MODEL: 'stt:downloadModel',
|
||||||
|
CANCEL_DOWNLOAD: 'stt:cancelDownload',
|
||||||
|
GET_LANGUAGE: 'stt:getLanguage',
|
||||||
|
SET_LANGUAGE: 'stt:setLanguage',
|
||||||
|
// Main → Renderer events
|
||||||
|
STATUS_CHANGED: 'stt:statusChanged',
|
||||||
|
DOWNLOAD_PROGRESS: 'stt:downloadProgress'
|
||||||
|
},
|
||||||
|
|
||||||
|
TTS: {
|
||||||
|
SPEAK: 'tts:speak',
|
||||||
|
STOP: 'tts:stop',
|
||||||
|
GET_VOICES: 'tts:getVoices',
|
||||||
|
GET_ACTIVE_VOICE: 'tts:getActiveVoice',
|
||||||
|
SET_VOICE: 'tts:setVoice',
|
||||||
|
GET_STATUS: 'tts:getStatus',
|
||||||
|
DOWNLOAD_VOICE: 'tts:downloadVoice',
|
||||||
|
// Main → Renderer events
|
||||||
|
STATUS_CHANGED: 'tts:statusChanged',
|
||||||
|
SPEAKING_STATE_CHANGED: 'tts:speakingStateChanged'
|
||||||
|
},
|
||||||
|
|
||||||
|
LLM: {
|
||||||
|
GET_STATUS: 'llm:getStatus',
|
||||||
|
GET_MODELS: 'llm:getModels',
|
||||||
|
GET_ACTIVE_MODEL: 'llm:getActiveModel',
|
||||||
|
SET_MODEL: 'llm:setModel',
|
||||||
|
PROCESS: 'llm:process',
|
||||||
|
CANCEL_PROCESS: 'llm:cancelProcess',
|
||||||
|
GET_SERVER_URL: 'llm:getServerUrl',
|
||||||
|
SET_SERVER_URL: 'llm:setServerUrl',
|
||||||
|
PULL_MODEL: 'llm:pullModel',
|
||||||
|
// Main → Renderer events
|
||||||
|
STATUS_CHANGED: 'llm:statusChanged',
|
||||||
|
PROCESS_PROGRESS: 'llm:processProgress',
|
||||||
|
PULL_PROGRESS: 'llm:pullProgress'
|
||||||
|
},
|
||||||
|
|
||||||
|
HOTKEY: {
|
||||||
|
GET_DICTATION_SHORTCUT: 'hotkey:getDictationShortcut',
|
||||||
|
SET_DICTATION_SHORTCUT: 'hotkey:setDictationShortcut',
|
||||||
|
GET_HANDS_FREE_SHORTCUT: 'hotkey:getHandsFreeShortcut',
|
||||||
|
SET_HANDS_FREE_SHORTCUT: 'hotkey:setHandsFreeShortcut',
|
||||||
|
GET_COMMAND_SHORTCUT: 'hotkey:getCommandShortcut',
|
||||||
|
SET_COMMAND_SHORTCUT: 'hotkey:setCommandShortcut',
|
||||||
|
IS_ENABLED: 'hotkey:isEnabled',
|
||||||
|
SET_ENABLED: 'hotkey:setEnabled',
|
||||||
|
START_RECORDING: 'hotkey:startRecording',
|
||||||
|
STOP_RECORDING: 'hotkey:stopRecording',
|
||||||
|
// Main → Renderer events
|
||||||
|
TRIGGERED: 'hotkey:triggered',
|
||||||
|
RECORDING_RESULT: 'hotkey:recordingResult'
|
||||||
|
},
|
||||||
|
|
||||||
|
CONFIG: {
|
||||||
|
GET: 'config:get',
|
||||||
|
SET: 'config:set',
|
||||||
|
GET_ALL: 'config:getAll',
|
||||||
|
RESET: 'config:reset',
|
||||||
|
GET_THEME: 'config:getTheme',
|
||||||
|
SET_THEME: 'config:setTheme',
|
||||||
|
GET_LANGUAGE: 'config:getLanguage',
|
||||||
|
SET_LANGUAGE: 'config:setLanguage',
|
||||||
|
GET_AUTO_LAUNCH: 'config:getAutoLaunch',
|
||||||
|
SET_AUTO_LAUNCH: 'config:setAutoLaunch',
|
||||||
|
GET_CLOSE_TO_TRAY: 'config:getCloseToTray',
|
||||||
|
SET_CLOSE_TO_TRAY: 'config:setCloseToTray',
|
||||||
|
// Main → Renderer events
|
||||||
|
CHANGED: 'config:changed'
|
||||||
|
},
|
||||||
|
|
||||||
|
HISTORY: {
|
||||||
|
GET_ALL: 'history:getAll',
|
||||||
|
GET_BY_ID: 'history:getById',
|
||||||
|
DELETE: 'history:delete',
|
||||||
|
DELETE_ALL: 'history:deleteAll',
|
||||||
|
SEARCH: 'history:search',
|
||||||
|
EXPORT: 'history:export',
|
||||||
|
// Main → Renderer events
|
||||||
|
ADDED: 'history:added'
|
||||||
|
},
|
||||||
|
|
||||||
|
DICTIONARY: {
|
||||||
|
GET_ALL: 'dictionary:getAll',
|
||||||
|
ADD: 'dictionary:add',
|
||||||
|
UPDATE: 'dictionary:update',
|
||||||
|
DELETE: 'dictionary:delete',
|
||||||
|
IMPORT: 'dictionary:import',
|
||||||
|
EXPORT: 'dictionary:export',
|
||||||
|
SEARCH: 'dictionary:search'
|
||||||
|
},
|
||||||
|
|
||||||
|
WINDOW: {
|
||||||
|
MINIMIZE: 'window:minimize',
|
||||||
|
MAXIMIZE: 'window:maximize',
|
||||||
|
CLOSE: 'window:close',
|
||||||
|
IS_MAXIMIZED: 'window:isMaximized',
|
||||||
|
SHOW_RECORDING_TIP: 'window:showRecordingTip',
|
||||||
|
HIDE_RECORDING_TIP: 'window:hideRecordingTip',
|
||||||
|
SHOW_RESULT_POPUP: 'window:showResultPopup',
|
||||||
|
HIDE_RESULT_POPUP: 'window:hideResultPopup',
|
||||||
|
TIP_MEASURED: 'window:tipMeasured',
|
||||||
|
// Main → Renderer events
|
||||||
|
TIP_STATE_CHANGED: 'window:tipStateChanged',
|
||||||
|
TIP_PREPARE: 'window:tipPrepare',
|
||||||
|
TIP_SHOW: 'window:tipShow'
|
||||||
|
},
|
||||||
|
|
||||||
|
SYSTEM: {
|
||||||
|
GET_PLATFORM: 'system:getPlatform',
|
||||||
|
GET_VERSION: 'system:getVersion',
|
||||||
|
CHECK_MIC_PERMISSION: 'system:checkMicPermission',
|
||||||
|
REQUEST_MIC_PERMISSION: 'system:requestMicPermission',
|
||||||
|
SHOW_NOTIFICATION: 'system:showNotification',
|
||||||
|
OPEN_EXTERNAL: 'system:openExternal',
|
||||||
|
GET_ACTIVE_APP: 'system:getActiveApp',
|
||||||
|
INSERT_TEXT: 'system:insertText',
|
||||||
|
PLAY_SOUND: 'system:playSound',
|
||||||
|
SET_SOUND_ENABLED: 'system:setSoundEnabled',
|
||||||
|
IS_SOUND_ENABLED: 'system:isSoundEnabled'
|
||||||
|
},
|
||||||
|
|
||||||
|
STATS: {
|
||||||
|
GET_SUMMARY: 'stats:getSummary',
|
||||||
|
GET_DAILY: 'stats:getDaily',
|
||||||
|
GET_WEEKLY: 'stats:getWeekly',
|
||||||
|
// Main → Renderer events
|
||||||
|
UPDATED: 'stats:updated'
|
||||||
|
}
|
||||||
|
} as const
|
||||||
|
|
||||||
|
// 타입 유틸리티: 채널명 유니온 추출
|
||||||
|
type NestedValues<T> = T extends Record<string, infer V>
|
||||||
|
? V extends string
|
||||||
|
? V
|
||||||
|
: NestedValues<V>
|
||||||
|
: never
|
||||||
|
|
||||||
|
export type IPCChannel = NestedValues<typeof IPC_CHANNELS>
|
||||||
658
src/shared/types.ts
Normal file
658
src/shared/types.ts
Normal file
|
|
@ -0,0 +1,658 @@
|
||||||
|
// src/shared/types.ts
|
||||||
|
// 모든 IPC 파라미터/반환 타입 정의
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Common
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export type ThemeMode = 'light' | 'dark' | 'auto'
|
||||||
|
|
||||||
|
export type VoiceMode = 'dictation' | 'hands-free'
|
||||||
|
|
||||||
|
export type PermissionStatus = 'granted' | 'denied' | 'unknown'
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Voice (음성 오케스트레이션)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export enum RecognitionState {
|
||||||
|
IDLE = 'idle',
|
||||||
|
PREPARING = 'preparing',
|
||||||
|
CONNECTING = 'connecting',
|
||||||
|
READY = 'ready',
|
||||||
|
RECOGNIZING = 'recognizing',
|
||||||
|
COMPLETED = 'completed',
|
||||||
|
CANCELLED = 'cancelled',
|
||||||
|
ERROR = 'error',
|
||||||
|
DESTROYED = 'destroyed'
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum AudioState {
|
||||||
|
IDLE = 'idle',
|
||||||
|
INITIALIZING = 'initializing',
|
||||||
|
STREAMING = 'streaming',
|
||||||
|
STOPPED = 'stopped'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoiceState {
|
||||||
|
recognitionState: RecognitionState
|
||||||
|
audioState: AudioState
|
||||||
|
mode: VoiceMode
|
||||||
|
sessionId: string | null
|
||||||
|
recordingStartedAt: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StartRecordingParams {
|
||||||
|
sessionId?: string
|
||||||
|
deviceId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StartRecordingResult {
|
||||||
|
sessionId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StopRecordingParams {
|
||||||
|
sessionId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StopRecordingResult {
|
||||||
|
sessionId: string
|
||||||
|
text: string
|
||||||
|
durationMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CancelRecordingParams {
|
||||||
|
sessionId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetVoiceModeParams {
|
||||||
|
mode: VoiceMode
|
||||||
|
}
|
||||||
|
|
||||||
|
// Voice events (Main → Renderer)
|
||||||
|
|
||||||
|
export interface VoiceStateChangedEvent {
|
||||||
|
previousState: RecognitionState
|
||||||
|
currentState: RecognitionState
|
||||||
|
audioState: AudioState
|
||||||
|
sessionId: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TranscriptionDeltaEvent {
|
||||||
|
sessionId: string
|
||||||
|
text: string
|
||||||
|
delta: string
|
||||||
|
isFinal: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TranscriptionCompleteEvent {
|
||||||
|
sessionId: string
|
||||||
|
text: string
|
||||||
|
durationMs: number
|
||||||
|
language: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoiceErrorEvent {
|
||||||
|
sessionId: string | null
|
||||||
|
errorCode: number
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AudioLevelEvent {
|
||||||
|
level: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Audio (디바이스 & 캡처)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export interface AudioDevice {
|
||||||
|
deviceId: string
|
||||||
|
label: string
|
||||||
|
isDefault: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetDeviceParams {
|
||||||
|
deviceId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestDeviceParams {
|
||||||
|
deviceId: string
|
||||||
|
durationMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestDeviceResult {
|
||||||
|
averageLevel: number
|
||||||
|
peakLevel: number
|
||||||
|
hasAudio: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AudioDeviceChangedEvent {
|
||||||
|
devices: AudioDevice[]
|
||||||
|
type: 'added' | 'removed' | 'default-changed'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// STT (로컬 Whisper)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export enum STTEngineState {
|
||||||
|
NOT_INSTALLED = 'not-installed',
|
||||||
|
DOWNLOADING = 'downloading',
|
||||||
|
LOADING = 'loading',
|
||||||
|
READY = 'ready',
|
||||||
|
PROCESSING = 'processing',
|
||||||
|
ERROR = 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface STTStatus {
|
||||||
|
engineState: STTEngineState
|
||||||
|
activeModel: string | null
|
||||||
|
engineVersion: string | null
|
||||||
|
gpuAccelerated: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface STTModel {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
sizeBytes: number
|
||||||
|
downloaded: boolean
|
||||||
|
languages: string[]
|
||||||
|
accuracy: number
|
||||||
|
speed: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetSTTModelParams {
|
||||||
|
modelId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DownloadModelParams {
|
||||||
|
modelId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetSTTLanguageParams {
|
||||||
|
language: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface STTStatusChangedEvent {
|
||||||
|
status: STTStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DownloadProgressEvent {
|
||||||
|
modelId: string
|
||||||
|
percent: number
|
||||||
|
downloadedBytes: number
|
||||||
|
totalBytes: number
|
||||||
|
bytesPerSecond: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// TTS (로컬 TTS)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export enum TTSEngineState {
|
||||||
|
NOT_INSTALLED = 'not-installed',
|
||||||
|
LOADING = 'loading',
|
||||||
|
READY = 'ready',
|
||||||
|
SPEAKING = 'speaking',
|
||||||
|
ERROR = 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TTSStatus {
|
||||||
|
engineState: TTSEngineState
|
||||||
|
activeVoice: string | null
|
||||||
|
engineVersion: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TTSVoice {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
language: string
|
||||||
|
gender: 'male' | 'female' | 'neutral'
|
||||||
|
downloaded: boolean
|
||||||
|
sizeBytes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TTSSpeakParams {
|
||||||
|
text: string
|
||||||
|
voiceId?: string
|
||||||
|
speed?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TTSSpeakResult {
|
||||||
|
durationMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetTTSVoiceParams {
|
||||||
|
voiceId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DownloadVoiceParams {
|
||||||
|
voiceId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TTSStatusChangedEvent {
|
||||||
|
status: TTSStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpeakingStateChangedEvent {
|
||||||
|
isSpeaking: boolean
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// LLM (Ollama)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export enum LLMConnectionState {
|
||||||
|
DISCONNECTED = 'disconnected',
|
||||||
|
CONNECTING = 'connecting',
|
||||||
|
CONNECTED = 'connected',
|
||||||
|
ERROR = 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LLMStatus {
|
||||||
|
connectionState: LLMConnectionState
|
||||||
|
serverUrl: string
|
||||||
|
activeModel: string | null
|
||||||
|
serverVersion: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LLMModel {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
sizeBytes: number
|
||||||
|
parameterSize: string
|
||||||
|
quantization: string
|
||||||
|
modifiedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LLMAction = 'refine' | 'translate' | 'summarize' | 'expand' | 'grammar' | 'custom'
|
||||||
|
|
||||||
|
export interface LLMProcessParams {
|
||||||
|
text: string
|
||||||
|
action: LLMAction
|
||||||
|
targetLanguage?: string
|
||||||
|
customPrompt?: string
|
||||||
|
modelId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LLMProcessResult {
|
||||||
|
originalText: string
|
||||||
|
processedText: string
|
||||||
|
action: LLMAction
|
||||||
|
processingTimeMs: number
|
||||||
|
tokenCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetLLMModelParams {
|
||||||
|
modelId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetServerUrlParams {
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PullModelParams {
|
||||||
|
modelName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LLMStatusChangedEvent {
|
||||||
|
status: LLMStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LLMProcessProgressEvent {
|
||||||
|
text: string
|
||||||
|
token: string
|
||||||
|
done: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LLMPullProgressEvent {
|
||||||
|
modelName: string
|
||||||
|
status: string
|
||||||
|
percent: number
|
||||||
|
downloadedBytes: number
|
||||||
|
totalBytes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Hotkey (핫키)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export interface HotkeyBinding {
|
||||||
|
keyCode: number
|
||||||
|
ctrl: boolean
|
||||||
|
alt: boolean
|
||||||
|
shift: boolean
|
||||||
|
meta: boolean
|
||||||
|
displayLabel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetHotkeyParams {
|
||||||
|
binding: HotkeyBinding
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetEnabledParams {
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HotkeyAction = 'dictation' | 'hands-free' | 'command'
|
||||||
|
|
||||||
|
export interface HotkeyTriggeredEvent {
|
||||||
|
action: HotkeyAction
|
||||||
|
type: 'pressed' | 'released'
|
||||||
|
isDoublePress: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HotkeyRecordingResultEvent {
|
||||||
|
binding: HotkeyBinding | null
|
||||||
|
conflictReason: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Config (설정)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export interface AppConfig {
|
||||||
|
theme: ThemeMode
|
||||||
|
language: string
|
||||||
|
closeToTray: boolean
|
||||||
|
autoLaunch: boolean
|
||||||
|
soundEnabled: boolean
|
||||||
|
selectedDeviceId: string | null
|
||||||
|
sttModelId: string
|
||||||
|
sttLanguage: string
|
||||||
|
ttsVoiceId: string | null
|
||||||
|
ttsSpeed: number
|
||||||
|
ollamaServerUrl: string
|
||||||
|
llmModelId: string | null
|
||||||
|
defaultLLMAction: LLMAction
|
||||||
|
dictationShortcut: HotkeyBinding
|
||||||
|
handsFreeShortcut: HotkeyBinding
|
||||||
|
commandShortcut: HotkeyBinding
|
||||||
|
hotkeyEnabled: boolean
|
||||||
|
insertMethod: 'clipboard' | 'keyboard'
|
||||||
|
autoInsert: boolean
|
||||||
|
maxHistoryEntries: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigGetParams {
|
||||||
|
key: keyof AppConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigSetParams {
|
||||||
|
key: keyof AppConfig
|
||||||
|
value: AppConfig[keyof AppConfig]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigResetParams {
|
||||||
|
key?: keyof AppConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetThemeParams {
|
||||||
|
theme: ThemeMode
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetLanguageParams {
|
||||||
|
language: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetAutoLaunchParams {
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetCloseToTrayParams {
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigChangedEvent {
|
||||||
|
key: keyof AppConfig
|
||||||
|
value: AppConfig[keyof AppConfig]
|
||||||
|
previousValue: AppConfig[keyof AppConfig]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// History (히스토리)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export interface HistoryEntry {
|
||||||
|
id: string
|
||||||
|
originalText: string
|
||||||
|
polishedText: string | null
|
||||||
|
focusedApp: string | null
|
||||||
|
focusedAppName: string | null
|
||||||
|
focusedAppWindowTitle: string | null
|
||||||
|
mode: 'dictation' | 'translate' | 'command'
|
||||||
|
status: 'completed' | 'cancelled' | 'error'
|
||||||
|
errorCode: string | null
|
||||||
|
audioLocalPath: string | null
|
||||||
|
duration: number
|
||||||
|
detectedLanguage: string | null
|
||||||
|
micDevice: string | null
|
||||||
|
wordCount: number
|
||||||
|
sttModel: string | null
|
||||||
|
llmModel: string | null
|
||||||
|
sttLatencyMs: number | null
|
||||||
|
llmLatencyMs: number | null
|
||||||
|
createdAt: number
|
||||||
|
updatedAt: number
|
||||||
|
appVersion: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoryQueryParams {
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
sortBy?: 'createdAt' | 'durationMs' | 'wordCount'
|
||||||
|
sortOrder?: 'asc' | 'desc'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoryPage {
|
||||||
|
entries: HistoryEntry[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
totalPages: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoryGetByIdParams {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoryDeleteParams {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistorySearchParams {
|
||||||
|
query: string
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoryExportParams {
|
||||||
|
format: 'json' | 'csv'
|
||||||
|
from?: string
|
||||||
|
to?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Dictionary (사전)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export interface DictionaryEntry {
|
||||||
|
id: string
|
||||||
|
word: string
|
||||||
|
pronunciation: string | null
|
||||||
|
category: 'user' | 'auto' | 'technical'
|
||||||
|
usageCount: number
|
||||||
|
lastUsedAt: number | null
|
||||||
|
createdAt: number
|
||||||
|
updatedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DictionaryQueryParams {
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
sortBy?: 'word' | 'category' | 'usageCount' | 'createdAt'
|
||||||
|
sortOrder?: 'asc' | 'desc'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DictionaryPage {
|
||||||
|
entries: DictionaryEntry[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
totalPages: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DictionaryAddParams {
|
||||||
|
word: string
|
||||||
|
pronunciation?: string
|
||||||
|
category?: 'user' | 'auto' | 'technical'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DictionaryUpdateParams {
|
||||||
|
id: string
|
||||||
|
word?: string
|
||||||
|
pronunciation?: string
|
||||||
|
category?: 'user' | 'auto' | 'technical'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DictionaryDeleteParams {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DictionaryImportParams {
|
||||||
|
filePath: string
|
||||||
|
format: 'json' | 'csv'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DictionaryImportResult {
|
||||||
|
imported: number
|
||||||
|
skipped: number
|
||||||
|
errors: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DictionaryExportParams {
|
||||||
|
format: 'json' | 'csv'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DictionarySearchParams {
|
||||||
|
query: string
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Window (윈도우 제어)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export type RecordingTipState = 'opening' | 'recording' | 'thinking' | 'result' | 'error'
|
||||||
|
|
||||||
|
export interface ShowRecordingTipParams {
|
||||||
|
state: RecordingTipState
|
||||||
|
text?: string
|
||||||
|
errorMessage?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShowResultPopupParams {
|
||||||
|
text: string
|
||||||
|
autoHideMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TipMeasuredParams {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TipStateChangedEvent {
|
||||||
|
state: RecordingTipState
|
||||||
|
text?: string
|
||||||
|
errorMessage?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TipPrepareEvent {
|
||||||
|
state: RecordingTipState
|
||||||
|
text?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TipShowEvent {
|
||||||
|
state: RecordingTipState
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// System (시스템)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export interface ActiveAppInfo {
|
||||||
|
name: string
|
||||||
|
title: string
|
||||||
|
pid: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShowNotificationParams {
|
||||||
|
title: string
|
||||||
|
body: string
|
||||||
|
type?: 'info' | 'warning' | 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenExternalParams {
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InsertTextParams {
|
||||||
|
text: string
|
||||||
|
method?: 'clipboard' | 'keyboard'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InsertTextResult {
|
||||||
|
success: boolean
|
||||||
|
insertedLength: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SoundEffect =
|
||||||
|
| 'recording-start'
|
||||||
|
| 'recording-stop'
|
||||||
|
| 'transcription-complete'
|
||||||
|
| 'error'
|
||||||
|
| 'notification'
|
||||||
|
|
||||||
|
export interface PlaySoundParams {
|
||||||
|
sound: SoundEffect
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetSoundEnabledParams {
|
||||||
|
enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Stats (통계)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export interface StatsSummary {
|
||||||
|
totalRecordingTimeMs: number
|
||||||
|
totalWordCount: number
|
||||||
|
totalSessionCount: number
|
||||||
|
todayRecordingTimeMs: number
|
||||||
|
todayWordCount: number
|
||||||
|
todaySessionCount: number
|
||||||
|
streakDays: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatsQueryParams {
|
||||||
|
from: string
|
||||||
|
to: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyStats {
|
||||||
|
date: string
|
||||||
|
recordingTimeMs: number
|
||||||
|
wordCount: number
|
||||||
|
sessionCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeeklyStats {
|
||||||
|
weekStart: string
|
||||||
|
recordingTimeMs: number
|
||||||
|
wordCount: number
|
||||||
|
sessionCount: number
|
||||||
|
}
|
||||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.node.json" },
|
||||||
|
{ "path": "./tsconfig.web.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
25
tsconfig.node.json
Normal file
25
tsconfig.node.json
Normal 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
24
tsconfig.web.json
Normal 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/**/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue