- 기본 STT 모델 base → large-v3-turbo (6배 빠름, 1.6GB) - 사이드카: /download, /download/status, /download/cancel + --models-dir - LocalSTTService: downloadModel/cancelDownload + download-progress 이벤트 - IPC: 설계서 02의 stt:downloadModel/cancelDownload/downloadProgress 구현 - OnboardingModal: LLM(gemma4:e4b) → STT(turbo) 2단계 순차 다운로드 UI - SettingsModal turbo 선택지 + settings.model.largeTurbo 12 locale - 테스트: 모노레포 잔재 import 수정 (src/shared → @d3ro/core), 41/41 통과
60 KiB
60 KiB
D3RO-VOICE 마스터 아키텍처 설계서
버전: 1.0
기반: Genspark Speakly 리버스엔지니어링 결과
목표: 완전 로컬 AI 음성 어시스턴트 (클라우드 의존성 제로)
1. 시스템 아키텍처 다이어그램
┌─────────────────────────────────────────────────────────────────────────┐
│ D3RO-VOICE Application │
│ │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │ MAIN PROCESS (Node.js) │ │
│ │ │ │
│ │ ┌─────────────┐ ┌──────────────┐ ┌────────────────────────┐ │ │
│ │ │ App Lifecycle│ │ WindowManager│ │ ServiceRegistry │ │ │
│ │ │ (index.ts) │ │ │ │ │ │ │
│ │ │ - whenReady │ │ - MainWindow │ │ ┌──────────────────┐ │ │ │
│ │ │ - single- │ │ - TipWindow │ │ │ VoiceModeService │ │ │ │
│ │ │ instance │ │ - PopupWindow│ │ │ (오케스트레이터) │ │ │ │
│ │ │ - before-quit│ │ │ │ └────────┬─────────┘ │ │ │
│ │ │ - will-quit │ └──────┬───────┘ │ │ │ │ │
│ │ └──────────────┘ │ │ ┌────────┴─────────┐ │ │ │
│ │ │ │ │ AudioCapture │ │ │ │
│ │ │ │ │ Service │ │ │ │
│ │ ┌─────────────────┐ │ │ └────────┬─────────┘ │ │ │
│ │ │ IPC Handlers │ │ │ │ │ │ │
│ │ │ (ipc/*.ts) │◄────┤ │ ┌────────┴─────────┐ │ │ │
│ │ │ │ │ │ │ LocalSTTService │ │ │ │
│ │ │ - handle (req/ │ │ │ │ (Whisper sidecar) │ │ │ │
│ │ │ res) │ │ │ └────────┬─────────┘ │ │ │
│ │ │ - on (fire & │ │ │ │ │ │ │
│ │ │ forget) │ │ │ ┌────────┴─────────┐ │ │ │
│ │ └─────────────────┘ │ │ │ LocalLLMService │ │ │ │
│ │ ▲ │ │ │ (Ollama REST) │ │ │ │
│ │ │ IPC │ │ └────────┬─────────┘ │ │ │
│ │ │ │ │ │ │ │ │
│ │ │ │ │ ┌────────┴─────────┐ │ │ │
│ │ │ │ │ │ LocalTTSService │ │ │ │
│ │ │ │ │ │ (Kokoro/edge-tts)│ │ │ │
│ │ │ │ │ └──────────────────┘ │ │ │
│ │ │ │ │ │ │ │
│ │ │ │ │ ┌──────────────────┐ │ │ │
│ │ │ │ │ │ HotkeyService │ │ │ │
│ │ │ │ │ │ (uiohook-napi) │ │ │ │
│ │ │ │ │ └──────────────────┘ │ │ │
│ │ │ │ │ │ │ │
│ │ │ │ │ ┌──────────────────┐ │ │ │
│ │ │ │ │ │ TextInsertService│ │ │ │
│ │ │ │ │ │ (@nut-tree/nut) │ │ │ │
│ │ │ │ │ └──────────────────┘ │ │ │
│ │ │ │ │ │ │ │
│ │ │ │ │ ┌──────────────────┐ │ │ │
│ │ │ │ │ │ ConfigService │ │ │ │
│ │ │ │ │ │ (electron-store) │ │ │ │
│ │ │ │ │ └──────────────────┘ │ │ │
│ │ │ │ │ │ │ │
│ │ │ │ │ ┌──────────────────┐ │ │ │
│ │ │ │ │ │ HistoryService │ │ │ │
│ │ │ │ │ │ (better-sqlite3) │ │ │ │
│ │ │ │ │ └──────────────────┘ │ │ │
│ │ │ │ └────────────────────────┘ │ │
│ └─────────┼────────────────────────────────────────────────────────┘ │
│ │ │ │
│ ┌─────────┼────────────┐ │ │
│ │ PRELOAD (Bridge) │ │ │
│ │ contextBridge. │ │ │
│ │ exposeInMainWorld │ │ │
│ │ → window.d3ro │ │ │
│ └─────────┼────────────┘ │ │
│ │ │ webContents.send │
│ ┌─────────┴────────────┐ │ (main→renderer) │
│ │ RENDERER (React 19) │◄─┘ │
│ │ │ │
│ │ ┌─────────────────┐ │ ┌──────────────────────────────────────┐ │
│ │ │ Dashboard │ │ │ POPUP WINDOWS (Vanilla JS) │ │
│ │ │ Settings │ │ │ │ │
│ │ │ History │ │ │ ┌────────────┐ ┌────────────────┐ │ │
│ │ │ (MUI 7 + React) │ │ │ │RecordingTip│ │ ResultPopup │ │ │
│ │ └─────────────────┘ │ │ │(wave bars) │ │ (전사 결과) │ │ │
│ └───────────────────────┘ │ └────────────┘ └────────────────┘ │ │
│ └──────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
외부 프로세스 (사이드카)
┌─────────────────────────────────────────────────────────────────────────┐
│ │
│ ┌───────────────────┐ ┌──────────────────┐ ┌─────────────────────┐ │
│ ┌──────────────────────────────────────┐ ┌──────────────────┐ │
│ │ STT+TTS 통합 Python Sidecar │ │ Ollama │ │
│ │ (FastAPI HTTP 서버) │ │ (localhost:11434)│ │
│ │ │ │ │ │
│ │ ┌─ faster-whisper ──┐ ┌─ Kokoro ──┐ │ │ REST API │ │
│ │ │ PCM 16kHz mono │ │ 텍스트 │ │ │ 스트리밍 응답 │ │
│ │ │ → 텍스트 │ │ → PCM │ │ │ │ │
│ │ └──────────────────┘ └───────────┘ │ │ │ │
│ │ + edge-tts 폴백 (온라인) │ │ │ │
│ └──────────────────────────────────────┘ └──────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
데이터 흐름 (녹음 → 텍스트 삽입)
[마이크] ──PCM 16kHz──► AudioCaptureService
│
┌─────────┴──────────┐
│ VoiceModeService │ (오케스트레이터)
│ 상태: RECOGNIZING │
└─────────┬──────────┘
│ PCM 버퍼
▼
┌─────────────────────┐
│ LocalSTTService │ ──HTTP──► faster-whisper
│ (이중 조건 플러시) │ ◄─JSON── (FastAPI sidecar)
└─────────┬──────────┘
│ 전사 텍스트
▼
┌─────────────────────┐
│ LocalLLMService │ ──HTTP──► Ollama
│ 다듬기/번역/명령어 │ ◄─stream── localhost:11434
└─────────┬──────────┘
│ 최종 텍스트
▼
┌─────────────────────┐
│ TextInsertService │
│ clipboard save │
│ → set → Ctrl+V │
│ → clipboard restore │
└─────────────────────┘
2. 서비스 목록 & 의존관계 그래프
2.1 서비스 전체 목록
D3RO-VOICE는 Speakly의 25개 서비스를 15개 핵심 서비스로 재설계한다. 클라우드 전용 서비스(Auth, Genspark, Report, UserInfo 등)를 제거하고, 로컬 전용 서비스(LocalSTT, LLM, TTS)를 추가한다.
| # | 서비스명 | 파일 경로 | 역할 | Speakly 원본 매핑 |
|---|---|---|---|---|
| 1 | ConfigService | services/ConfigService.ts |
electron-store 기반 전역 설정 관리 | UserConfigService + DeviceConfigService |
| 2 | I18nService | services/I18nService.ts |
다국어 지원 (ko, en) | I18nService |
| 3 | LoggerService | services/LoggerService.ts |
electron-log 래퍼, 카테고리별 로깅 | Logger (built-in) |
| 4 | HistoryService | services/HistoryService.ts |
SQLite 기반 전사/명령 이력 저장 | HistoryService |
| 5 | DictionaryService | services/DictionaryService.ts |
사용자 사전 (커스텀 단어/약어) | DictionaryService |
| 6 | SoundEffectService | services/SoundEffectService.ts |
녹음 시작/종료/에러 효과음 재생 | SoundEffectService |
| 7 | HotkeyService | services/HotkeyService.ts |
uiohook-napi 글로벌 핫키 등록/해제 | HotkeyService + HotkeyConfig + NativeService(키보드) |
| 8 | AudioCaptureService | services/AudioCaptureService.ts |
마이크 PCM 캡처 (16kHz mono) | AudioService + MicNativeService |
| 9 | LocalSTTService | services/LocalSTTService.ts |
faster-whisper/whisper.cpp 사이드카 관리 | VoiceRecognitionService (WebSocket→로컬) |
| 10 | LocalLLMService | services/LocalLLMService.ts |
Ollama REST API 통신 (다듬기/번역/명령) | GensparkService (클라우드→로컬) |
| 11 | LocalTTSService | services/LocalTTSService.ts |
Kokoro TTS (+edge-tts 폴백) 사이드카 관리 | 신규 (Speakly에 없음) |
| 12 | TextInsertService | services/TextInsertService.ts |
클립보드+Ctrl+V 텍스트 삽입 | TextOperationStrategy + NativeService(클립보드) |
| 13 | VoiceModeService | services/VoiceModeService.ts |
전체 음성 파이프라인 오케스트레이션 | VoiceModeService |
| 14 | CustomInstructionService | services/CustomInstructionService.ts |
사용자 정의 LLM 명령어 관리 | CustomInstructionConfigService |
| 15 | AutoLaunchService | services/AutoLaunchService.ts |
시스템 시작 시 자동 실행 | AutoLaunchService |
2.2 Speakly → D3RO-VOICE 서비스 매핑 (제거 항목)
| Speakly 서비스 | D3RO-VOICE 처리 | 이유 |
|---|---|---|
| AuthService | 제거 | 로컬 전용, 인증 불필요 |
| GensparkService | LocalLLMService로 대체 | Ollama REST API |
| VoiceRecognitionService | LocalSTTService로 대체 | WebSocket→로컬 Whisper |
| NativeService (FFI) | 제거 | koffi+DLL 대신 npm 패키지 |
| MicNativeService | AudioCaptureService에 흡수 | WASAPI→Web Audio API |
| ContextService | 제거 | 활성 앱 컨텍스트 (Phase 6+) |
| UserInfoService | 제거 | 클라우드 사용자 정보 |
| FeedbackService | 제거 | 클라우드 피드백 제출 |
| ReportService | 제거 | 클라우드 오류 보고 |
| UpdateService | 제거 (Phase 7) | 자동 업데이트 추후 |
| PermissionService | 제거 | OS 권한 (DLL 전용) |
| DebugProvider | 제거 | 디버그 모드는 환경변수로 |
| RecordStatsService | HistoryService에 흡수 | 통계 쿼리로 대체 |
2.3 의존관계 그래프
Level 0 (의존 없음):
LoggerService
ConfigService
Level 1 (Level 0에 의존):
I18nService → [ConfigService]
SoundEffectService → [ConfigService]
AutoLaunchService → [ConfigService]
HotkeyService → [ConfigService]
Level 2 (Level 0~1에 의존):
HistoryService → [LoggerService]
DictionaryService → [LoggerService]
AudioCaptureService → [ConfigService, LoggerService]
LocalSTTService → [ConfigService, LoggerService]
LocalLLMService → [ConfigService, LoggerService]
LocalTTSService → [ConfigService, LoggerService]
TextInsertService → [ConfigService, LoggerService]
CustomInstructionService → [ConfigService, LoggerService]
Level 3 (오케스트레이터):
VoiceModeService → [AudioCaptureService, LocalSTTService, LocalLLMService,
LocalTTSService, TextInsertService, HotkeyService,
HistoryService, SoundEffectService, ConfigService,
CustomInstructionService, LoggerService]
2.4 서비스별 이벤트 목록
// LoggerService — 이벤트 없음 (동기 API)
// ConfigService
interface ConfigServiceEvents {
'config:changed': (key: string, value: unknown, oldValue: unknown) => void;
}
// I18nService
interface I18nServiceEvents {
'locale:changed': (locale: 'ko' | 'en') => void;
}
// AudioCaptureService
interface AudioCaptureServiceEvents {
'audio:data': (buffer: Buffer, sampleRate: number) => void;
'audio:level': (level: number) => void; // 0.0~1.0 RMS
'audio:started': (deviceId: string) => void;
'audio:stopped': () => void;
'audio:error': (error: NXError) => void;
'audio:device-changed': (devices: AudioDevice[]) => void;
}
// LocalSTTService
interface LocalSTTServiceEvents {
'stt:ready': () => void;
'stt:transcription-delta': (text: string) => void;
'stt:transcription-complete': (result: TranscriptionResult) => void;
'stt:error': (error: NXError) => void;
'stt:model-loading': (progress: number) => void;
}
// LocalLLMService
interface LocalLLMServiceEvents {
'llm:response-delta': (text: string) => void;
'llm:response-complete': (result: LLMResult) => void;
'llm:error': (error: NXError) => void;
}
// LocalTTSService
interface LocalTTSServiceEvents {
'tts:audio-data': (buffer: Buffer) => void;
'tts:started': () => void;
'tts:complete': () => void;
'tts:error': (error: NXError) => void;
}
// HotkeyService
interface HotkeyServiceEvents {
'hotkey:dictation-pressed': () => void;
'hotkey:dictation-released': () => void;
'hotkey:dictation-double-press': () => void;
'hotkey:command-pressed': () => void;
}
// VoiceModeService
interface VoiceModeServiceEvents {
'voice:state-changed': (state: RecognitionState, prev: RecognitionState) => void;
'voice:audio-state-changed': (state: AudioState) => void;
'voice:transcription-delta': (text: string) => void;
'voice:result': (result: VoiceResult) => void;
'voice:error': (error: NXError) => void;
'voice:cancelled': () => void;
}
// TextInsertService
interface TextInsertServiceEvents {
'text-insert:success': (text: string) => void;
'text-insert:error': (error: NXError) => void;
}
// SoundEffectService — 이벤트 없음 (fire-and-forget 재생)
// HistoryService — 이벤트 없음 (동기/async DB 쿼리)
// DictionaryService — 이벤트 없음 (동기/async DB 쿼리)
// CustomInstructionService — 이벤트 없음 (CRUD API)
// AutoLaunchService — 이벤트 없음 (설정 API)
3. 초기화 순서
Speakly의 22단계를 로컬 전용으로 재설계하여 14단계로 축소한다.
┌─────────────────────────────────────────────────────────────────┐
│ 앱 초기화 시퀀스 (14단계) │
├─────┬───────────────────────────────┬───────────────────────────┤
│ 단계 │ 작업 │ 실패 시 동작 │
├─────┼───────────────────────────────┼───────────────────────────┤
│ 1 │ 단일 인스턴스 잠금 │ 기존 인스턴스 활성화 후 │
│ │ app.requestSingleInstanceLock │ app.quit() │
├─────┼───────────────────────────────┼───────────────────────────┤
│ 2 │ LoggerService 초기화 │ console 폴백, 계속 진행 │
│ │ electron-log 설정 │ │
├─────┼───────────────────────────────┼───────────────────────────┤
│ 3 │ ConfigService 초기화 │ 기본값으로 폴백, 계속 │
│ │ electron-store 로드 │ │
├─────┼───────────────────────────────┼───────────────────────────┤
│ 4 │ I18nService 초기화 │ 'ko' 기본값, 계속 │
│ │ 시스템 언어 감지 + 설정 로드 │ │
├─────┼───────────────────────────────┼───────────────────────────┤
│ 5 │ DB 초기화 (better-sqlite3) │ 에러 다이얼로그 → 종료 │
│ │ 마이그레이션 실행 │ (데이터 손상 가능) │
├─────┼───────────────────────────────┼───────────────────────────┤
│ 6 │ HistoryService 초기화 │ 로그 경고, 계속 │
│ │ DictionaryService 초기화 │ (기능 제한 모드) │
├─────┼───────────────────────────────┼───────────────────────────┤
│ 7 │ SoundEffectService 초기화 │ 무음 모드, 계속 │
│ │ 효과음 파일 프리로드 │ │
├─────┼───────────────────────────────┼───────────────────────────┤
│ 8 │ CustomInstructionService │ 빈 목록, 계속 │
│ │ 저장된 명령어 로드 │ │
├─────┼───────────────────────────────┼───────────────────────────┤
│ 9 │ AutoLaunchService 초기화 │ 로그 경고, 계속 │
├─────┼───────────────────────────────┼───────────────────────────┤
│ 10 │ createWindow (메인 윈도우) │ 치명 에러 → 종료 │
│ │ + 팝업 윈도우 프리로드 │ │
├─────┼───────────────────────────────┼───────────────────────────┤
│ 11 │ HotkeyService 초기화 │ 핫키 비활성, 계속 │
│ │ uiohook 워커 시작 │ (UI에서 수동 조작 가능) │
├─────┼───────────────────────────────┼───────────────────────────┤
│ 12 │ Tray 아이콘 생성 │ 트레이 없이 계속 │
│ │ 컨텍스트 메뉴 등록 │ │
├─────┼───────────────────────────────┼───────────────────────────┤
│ 13 │ IPC 핸들러 일괄 등록 │ 치명 에러 → 종료 │
│ │ handle + on 등록 │ │
├─────┼───────────────────────────────┼───────────────────────────┤
│ 14 │ 사이드카 헬스체크 (비동기) │ 상태 표시줄에 경고 │
│ │ Ollama ping, Whisper 확인 │ (사용 시점에 재시도) │
│ │ (3초 후 백그라운드 실행) │ │
└─────┴───────────────────────────────┴───────────────────────────┘
초기화 TypeScript 구현 인터페이스
// src/main/bootstrap.ts
interface BootstrapStep {
name: string;
critical: boolean; // true면 실패 시 앱 종료
fn: () => Promise<void>;
}
const BOOTSTRAP_SEQUENCE: BootstrapStep[] = [
{ name: 'single-instance-lock', critical: true, fn: acquireSingleInstanceLock },
{ name: 'logger', critical: false, fn: initLoggerService },
{ name: 'config', critical: false, fn: initConfigService },
{ name: 'i18n', critical: false, fn: initI18nService },
{ name: 'database', critical: true, fn: initDatabase },
{ name: 'history-dictionary', critical: false, fn: initDataServices },
{ name: 'sound-effects', critical: false, fn: initSoundEffectService },
{ name: 'custom-instructions', critical: false, fn: initCustomInstructionService },
{ name: 'auto-launch', critical: false, fn: initAutoLaunchService },
{ name: 'create-windows', critical: true, fn: createAllWindows },
{ name: 'hotkey', critical: false, fn: initHotkeyService },
{ name: 'tray', critical: false, fn: createTray },
{ name: 'ipc-handlers', critical: true, fn: registerAllIpcHandlers },
{ name: 'sidecar-health', critical: false, fn: checkSidecars },
];
async function bootstrap(): Promise<void> {
for (const step of BOOTSTRAP_SEQUENCE) {
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}`);
app.quit();
return;
}
}
}
}
4. 종료 순서
Speakly의 before-quit → will-quit 패턴을 채택한다.
┌──────────────────────────────────────────────────────────────┐
│ 종료 시퀀스 │
│ │
│ [사용자 종료 요청] (트레이 메뉴 or Ctrl+Q or app.quit()) │
│ │ │
│ ▼ │
│ ┌─── before-quit ───────────────────────────────────────┐ │
│ │ 1. VoiceModeService.destroy() │ │
│ │ - 진행 중 녹음 취소 │ │
│ │ - RecognitionState → DESTROYED │ │
│ │ 2. HotkeyService.destroy() │ │
│ │ - uiohook 워커 종료 │ │
│ │ 3. AudioCaptureService.destroy() │ │
│ │ - 마이크 스트림 해제 │ │
│ │ 4. isQuitting = true (윈도우 close 이벤트에서 참조) │ │
│ └───────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─── will-quit ─────────────────────────────────────────┐ │
│ │ 5. LocalSTTService.destroy() │ │
│ │ - Whisper 사이드카 프로세스 kill │ │
│ │ 6. LocalTTSService.destroy() │ │
│ │ - TTS 사이드카 프로세스 kill │ │
│ │ 7. HistoryService.close() │ │
│ │ - SQLite DB 정상 종료 │ │
│ │ 8. ConfigService.flush() │ │
│ │ - 미저장 설정 디스크 기록 │ │
│ │ 9. LoggerService.flush() │ │
│ │ - 로그 버퍼 플러시 │ │
│ └───────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ [프로세스 종료] │
└──────────────────────────────────────────────────────────────┘
종료 TypeScript 인터페이스
// src/main/lifecycle.ts
interface Destroyable {
destroy(): Promise<void>;
}
// 종료 순서 (before-quit)
const BEFORE_QUIT_SEQUENCE: Destroyable[] = [
voiceModeService,
hotkeyService,
audioCaptureService,
];
// 종료 순서 (will-quit)
const WILL_QUIT_SEQUENCE: Array<{ name: string; fn: () => Promise<void> }> = [
{ name: 'stt-sidecar', fn: () => localSTTService.destroy() },
{ name: 'tts-sidecar', fn: () => localTTSService.destroy() },
{ name: 'database', fn: () => historyService.close() },
{ name: 'config-flush', fn: () => configService.flush() },
{ name: 'logger-flush', fn: () => loggerService.flush() },
];
let isQuitting = false;
app.on('before-quit', async (event) => {
if (isQuitting) return;
event.preventDefault();
isQuitting = true;
for (const service of BEFORE_QUIT_SEQUENCE) {
try {
await Promise.race([service.destroy(), timeout(3000)]);
} catch (err) {
logger.warn(`[shutdown] ${service.constructor.name} destroy timeout`);
}
}
app.quit();
});
app.on('will-quit', async (event) => {
event.preventDefault();
for (const step of WILL_QUIT_SEQUENCE) {
try {
await Promise.race([step.fn(), timeout(2000)]);
} catch (err) {
logger.warn(`[shutdown] ${step.name} cleanup failed`);
}
}
app.exit(0);
});
5. 프로세스 간 통신 (IPC) 설계
5.1 IPC 채널 네임스페이스 규칙
${feature}:${action}
| 네임스페이스 | 예시 | 설명 |
|---|---|---|
voice: |
voice:startRecording |
음성 파이프라인 제어 |
audio: |
audio:getDevices |
오디오 디바이스/캡처 |
stt: |
stt:getStatus |
STT 엔진 상태 |
llm: |
llm:process |
LLM 처리 요청 |
tts: |
tts:speak |
TTS 재생 |
config: |
config:get |
설정 CRUD |
hotkey: |
hotkey:setDictation |
핫키 설정 |
history: |
history:getRecent |
이력 조회 |
dictionary: |
dictionary:addWord |
사전 관리 |
instruction: |
instruction:getAll |
커스텀 명령어 |
window: |
window:showTip |
윈도우 제어 |
app: |
app:getVersion |
앱 정보/상태 |
5.2 handle vs on 사용 기준
// ── handle (양방향, request/response) ──
// 사용 조건: 렌더러가 응답을 기다려야 할 때
// 패턴: renderer → ipcRenderer.invoke() → main → return value
ipcMain.handle('config:get', async (_event, key: string) => {
return configService.get(key);
});
ipcMain.handle('audio:getDevices', async () => {
return audioCaptureService.getDevices();
});
ipcMain.handle('history:getRecent', async (_event, limit: number) => {
return historyService.getRecent(limit);
});
// ── on (단방향, fire-and-forget) ──
// 사용 조건: 렌더러가 응답을 기다릴 필요 없을 때
// 패턴: renderer → ipcRenderer.send() → main (no return)
ipcMain.on('voice:startRecording', (_event) => {
voiceModeService.startRecording();
});
ipcMain.on('voice:stopRecording', (_event) => {
voiceModeService.stopRecording();
});
ipcMain.on('voice:cancelRecording', (_event) => {
voiceModeService.cancel();
});
// ── webContents.send (main → renderer, 단방향 푸시) ──
// 사용 조건: main에서 렌더러로 상태/데이터 푸시할 때
// 패턴: main → webContents.send(channel, data)
mainWindow.webContents.send('voice:state-changed', newState);
mainWindow.webContents.send('voice:transcription-delta', text);
mainWindow.webContents.send('audio:level', rmsLevel);
5.3 오디오 데이터 전송 방식
오디오 데이터는 main 프로세스 내부에서만 흐르며, IPC를 통해 렌더러로 전송하지 않는다. 렌더러에는 오디오 레벨(RMS)만 전송한다.
Main Process 내부:
AudioCaptureService ──Buffer──► VoiceModeService ──Buffer──► LocalSTTService
│
(HTTP multipart POST)
│
faster-whisper (FastAPI)
렌더러로 전송되는 것:
- audio:level (number, 0.0~1.0) — 100ms 간격, fire-and-forget
- voice:state-changed (RecognitionState) — 상태 변경 시
- voice:transcription-delta (string) — 전사 중간 결과
5.4 IPC 타입 안전성
// src/shared/ipc-channels.ts
// 채널 정의: 타입 안전한 IPC 인터페이스
export interface IpcChannelMap {
// handle (양방향)
'config:get': { args: [key: string]; return: unknown };
'config:set': { args: [key: string, value: unknown]; return: void };
'config:getAll': { args: []; return: Record<string, unknown> };
'audio:getDevices': { args: []; return: AudioDevice[] };
'audio:getSelectedDevice': { args: []; return: string | null };
'stt:getStatus': { args: []; return: STTStatus };
'stt:getModels': { args: []; return: STTModel[] };
'llm:getModels': { args: []; return: LLMModel[] };
'llm:getStatus': { args: []; return: LLMStatus };
'history:getRecent': { args: [limit: number]; return: HistoryEntry[] };
'history:getStats': { args: []; return: HistoryStats };
'dictionary:getAll': { args: []; return: DictionaryEntry[] };
'dictionary:addWord': { args: [word: string, replacement: string]; return: void };
'dictionary:removeWord': { args: [id: number]; return: void };
'instruction:getAll': { args: []; return: CustomInstruction[] };
'instruction:save': { args: [instruction: CustomInstruction]; return: void };
'instruction:delete': { args: [id: string]; return: void };
'hotkey:getDictationShortcut': { args: []; return: HotkeyBinding };
'hotkey:setDictationShortcut': { args: [binding: HotkeyBinding]; return: void };
'app:getVersion': { args: []; return: string };
'app:getPlatform': { args: []; return: NodeJS.Platform };
// on (단방향 renderer→main)
'voice:startRecording': { args: []; return: void };
'voice:stopRecording': { args: []; return: void };
'voice:cancelRecording': { args: []; return: void };
'audio:setDevice': { args: [deviceId: string]; return: void };
// send (단방향 main→renderer)
'voice:state-changed': { args: [state: RecognitionState]; return: void };
'voice:audio-state-changed': { args: [state: AudioState]; return: void };
'voice:transcription-delta': { args: [text: string]; return: void };
'voice:result': { args: [result: VoiceResult]; return: void };
'voice:error': { args: [error: SerializedNXError]; return: void };
'voice:cancelled': { args: []; return: void };
'audio:level': { args: [level: number]; return: void };
'audio:device-changed': { args: [devices: AudioDevice[]]; return: void };
'stt:model-loading': { args: [progress: number]; return: void };
}
5.5 Preload Bridge
// src/preload/index.ts
import { contextBridge, ipcRenderer } from 'electron';
export interface D3roAPI {
// invoke (handle 채널)
invoke<K extends keyof HandleChannels>(
channel: K,
...args: HandleChannels[K]['args']
): Promise<HandleChannels[K]['return']>;
// send (on 채널, fire-and-forget)
send<K extends keyof SendChannels>(
channel: K,
...args: SendChannels[K]['args']
): void;
// on (main→renderer 수신)
on<K extends keyof ReceiveChannels>(
channel: K,
callback: (...args: ReceiveChannels[K]['args']) => void
): () => void; // 반환: unsubscribe 함수
}
contextBridge.exposeInMainWorld('d3ro', {
invoke: (channel: string, ...args: unknown[]) =>
ipcRenderer.invoke(channel, ...args),
send: (channel: string, ...args: unknown[]) =>
ipcRenderer.send(channel, ...args),
on: (channel: string, callback: (...args: unknown[]) => void) => {
const handler = (_event: Electron.IpcRendererEvent, ...args: unknown[]) =>
callback(...args);
ipcRenderer.on(channel, handler);
return () => ipcRenderer.removeListener(channel, handler);
},
} satisfies D3roAPI);
6. 에러 처리 아키텍처
6.1 NXError 패턴 (Speakly ErrorCode.js 기반)
// src/shared/errors.ts
export class NXError extends Error {
constructor(
public readonly code: ErrorCode,
message: string,
public readonly cause?: Error,
) {
super(message);
this.name = 'NXError';
}
/** IPC 전송용 직렬화 */
serialize(): SerializedNXError {
return {
code: this.code,
message: this.message,
stack: this.stack,
};
}
/** IPC 수신 후 역직렬화 */
static deserialize(data: SerializedNXError): NXError {
const error = new NXError(data.code, data.message);
error.stack = data.stack;
return error;
}
}
export interface SerializedNXError {
code: ErrorCode;
message: string;
stack?: string;
}
6.2 에러 코드 범위 할당
// src/shared/errors.ts
export enum ErrorCode {
// ── 일반 (1000~1099) ──
UNKNOWN = 1000,
INITIALIZATION_FAILED = 1001,
SERVICE_UNAVAILABLE = 1002,
INVALID_ARGUMENT = 1003,
TIMEOUT = 1004,
PERMISSION_DENIED = 1005,
// ── 오디오 (1100~1199) ──
AUDIO_DEVICE_NOT_FOUND = 1100,
AUDIO_CAPTURE_FAILED = 1101,
AUDIO_PERMISSION_DENIED = 1102,
AUDIO_DEVICE_BUSY = 1103,
AUDIO_TOO_SHORT = 1104, // < 700ms
// ── STT (1200~1299) ──
STT_MODEL_NOT_FOUND = 1200,
STT_MODEL_LOAD_FAILED = 1201,
STT_SIDECAR_CRASH = 1202,
STT_SIDECAR_TIMEOUT = 1203,
STT_TRANSCRIPTION_FAILED = 1204,
STT_EMPTY_RESULT = 1205,
// ── LLM (1300~1399) ──
LLM_SERVER_UNREACHABLE = 1300,
LLM_MODEL_NOT_FOUND = 1301,
LLM_REQUEST_FAILED = 1302,
LLM_RESPONSE_TIMEOUT = 1303,
LLM_INVALID_RESPONSE = 1304,
// ── TTS (1400~1499) ──
TTS_ENGINE_NOT_FOUND = 1400,
TTS_SYNTHESIS_FAILED = 1401,
TTS_SIDECAR_CRASH = 1402,
// ── 텍스트 삽입 (1500~1599) ──
TEXT_INSERT_FAILED = 1500,
TEXT_INSERT_CLIPBOARD_ERROR = 1501,
TEXT_INSERT_SIMULATE_FAILED = 1502,
// ── 핫키 (1600~1699) ──
HOTKEY_REGISTER_FAILED = 1600,
HOTKEY_CONFLICT = 1601,
HOTKEY_UIOHOOK_INIT_FAILED = 1602,
// ── DB (1700~1799) ──
DB_OPEN_FAILED = 1700,
DB_MIGRATION_FAILED = 1701,
DB_QUERY_FAILED = 1702,
// ── 설정 (1800~1899) ──
CONFIG_READ_FAILED = 1800,
CONFIG_WRITE_FAILED = 1801,
CONFIG_INVALID_VALUE = 1802,
// ── 윈도우 (1900~1999) ──
WINDOW_CREATE_FAILED = 1900,
WINDOW_NOT_FOUND = 1901,
}
6.3 에러 전파 경로
서비스 내부:
1. NXError 생성 (코드 + 메시지 + 원인)
2. EventEmitter로 'error' 이벤트 발행
3. 로거에 기록
서비스 → IPC:
4. ipcMain.handle 내 try/catch
5. NXError.serialize() → IPC 응답 에러
IPC → 렌더러:
6-A. handle: invoke() Promise reject → renderer catch
6-B. send: webContents.send('voice:error', serialized)
7. 렌더러에서 NXError.deserialize() → UI 표시
예외 포착 안전망:
- process.on('uncaughtException') → 로거 + 재시작 시도
- process.on('unhandledRejection') → 로거 + 에러 표시
7. 설정 관리 아키텍처
7.1 electron-store 기반
// src/main/services/ConfigService.ts
import Store from 'electron-store';
export interface AppConfig {
audio: AudioConfig;
hotkey: HotkeyConfig;
ui: UIConfig;
stt: STTConfig;
tts: TTSConfig;
llm: LLMConfig;
}
export interface AudioConfig {
selectedDeviceId: string | null;
sampleRate: 16000; // Whisper 기본값, 변경 불가
noiseSuppression: boolean; // 기본: false
echoCancellation: boolean; // 기본: false
silenceThreshold: number; // 0.0~1.0, 기본: 0.01
minDurationMs: number; // 최소 녹음 시간, 기본: 700
}
export interface HotkeyConfig {
dictation: HotkeyBinding; // 기본: Right Alt
command: HotkeyBinding; // 기본: Right Alt 더블프레스
doublePressDurationMs: number; // 기본: 300
}
export interface HotkeyBinding {
keycode: number;
modifiers: number[];
label: string; // UI 표시용 (예: "Right Alt")
}
export interface UIConfig {
locale: 'ko' | 'en';
theme: 'light' | 'dark' | 'system';
closeToTray: boolean; // 기본: true
showTrayIcon: boolean; // 기본: true
recordingTipPosition: 'cursor' | 'center' | 'bottom-right';
}
export interface STTConfig {
engine: 'faster-whisper' | 'whisper-cpp';
modelSize: 'tiny' | 'base' | 'small' | 'medium' | 'large-v3' | 'large-v3-turbo';
language: string; // 기본: 'ko'
vadEnabled: boolean; // 기본: true
beamSize: number; // 기본: 5
}
export interface TTSConfig {
engine: 'kokoro' | 'edge-tts';
voiceId: string;
speed: number; // 0.5~2.0, 기본: 1.0
enabled: boolean; // 기본: false
}
export interface LLMConfig {
serverUrl: string; // 기본: 'http://localhost:11434'
model: string; // 기본: 'gemma4:e4b' (non-reasoning, 권장)
temperature: number; // 0.0~2.0, 기본: 0.3
maxTokens: number; // 기본: 2048
timeout: number; // ms, 기본: 30000
}
// electron-store 기본값
const CONFIG_DEFAULTS: AppConfig = {
audio: {
selectedDeviceId: null,
sampleRate: 16000,
noiseSuppression: false,
echoCancellation: false,
silenceThreshold: 0.01,
minDurationMs: 700,
},
hotkey: {
dictation: { keycode: 0xA5, modifiers: [], label: 'Right Alt' },
command: { keycode: 0xA5, modifiers: [], label: 'Right Alt (double)' },
doublePressDurationMs: 300,
},
ui: {
locale: 'ko',
theme: 'system',
closeToTray: true,
showTrayIcon: true,
recordingTipPosition: 'cursor',
},
stt: {
engine: 'faster-whisper',
// large-v3 대비 6배 빠르고 정확도 손실 1~2%, 다운로드 1.6GB
modelSize: 'large-v3-turbo',
language: 'ko',
vadEnabled: true,
beamSize: 5,
},
tts: {
engine: 'kokoro',
voiceId: '',
speed: 1.0,
enabled: false,
},
llm: {
serverUrl: 'http://localhost:11434',
model: 'gemma4:e4b',
temperature: 0.3,
maxTokens: 2048,
timeout: 30000,
},
};
8. 파일/디렉토리 완전한 트리
D3ROVoice/
├── package.json
├── tsconfig.json
├── tsconfig.node.json
├── vite.config.ts
├── electron.vite.config.ts
├── .eslintrc.cjs
├── .prettierrc
├── .gitignore
├── README.md
├── CLAUDE.md
│
├── docs/
│ ├── design/
│ │ ├── 00-master-architecture.md ← 본 문서
│ │ ├── 01-service-specifications.md
│ │ ├── 02-ipc-channel-spec.md
│ │ └── 03-db-ui-spec.md
│ ├── phases/
│ │ ├── phase-1.md
│ │ ├── phase-2.md
│ │ ├── phase-3.md
│ │ ├── phase-4.md
│ │ ├── phase-5.md
│ │ ├── phase-6.md
│ │ └── phase-7.md
│ └── re-findings/
│ ├── speakly-architecture.md
│ ├── voice-pipeline-patterns.md
│ └── native-dll-patterns.md
│
├── resources/
│ ├── icons/
│ │ ├── icon.ico # 앱 아이콘
│ │ ├── icon.png # 앱 아이콘 (PNG)
│ │ ├── tray-icon.ico # 트레이 아이콘
│ │ └── tray-icon-active.ico # 녹음 중 트레이 아이콘
│ └── sounds/
│ ├── recording-start.wav
│ ├── recording-stop.wav
│ └── error.wav
│
├── scripts/
│ ├── download-whisper-model.ts # Whisper 모델 다운로드 스크립트
│ └── download-kokoro-voice.ts # Kokoro 음성 다운로드 스크립트
│
├── src/
│ ├── main/
│ │ ├── index.ts # 앱 진입점 (app.whenReady)
│ │ ├── bootstrap.ts # 14단계 초기화 시퀀스
│ │ ├── lifecycle.ts # before-quit / will-quit 정리
│ │ │
│ │ ├── services/
│ │ │ ├── index.ts # 서비스 레지스트리 (싱글턴 export)
│ │ │ ├── LoggerService.ts # electron-log 래퍼
│ │ │ ├── ConfigService.ts # electron-store 설정 관리
│ │ │ ├── I18nService.ts # 다국어 지원
│ │ │ ├── AudioCaptureService.ts # 마이크 PCM 캡처
│ │ │ ├── LocalSTTService.ts # Whisper 사이드카 관리
│ │ │ ├── LocalLLMService.ts # Ollama REST API
│ │ │ ├── LocalTTSService.ts # Kokoro TTS (+edge-tts 폴백)
│ │ │ ├── VoiceModeService.ts # 음성 파이프라인 오케스트레이터
│ │ │ ├── HotkeyService.ts # uiohook-napi 글로벌 핫키
│ │ │ ├── TextInsertService.ts # 클립보드+Ctrl+V 삽입
│ │ │ ├── HistoryService.ts # SQLite 이력 관리
│ │ │ ├── DictionaryService.ts # 사용자 사전
│ │ │ ├── SoundEffectService.ts # 효과음 재생
│ │ │ ├── CustomInstructionService.ts # 사용자 정의 LLM 명령어
│ │ │ └── AutoLaunchService.ts # 시스템 시작 자동 실행
│ │ │
│ │ ├── windows/
│ │ │ ├── WindowManager.ts # 윈도우 생성/관리 (프리로딩)
│ │ │ ├── MainWindow.ts # 메인 앱 윈도우 (React)
│ │ │ ├── RecordingTipWindow.ts # 녹음 상태 팝업 (Vanilla JS)
│ │ │ └── ResultPopupWindow.ts # 전사 결과 팝업 (Vanilla JS)
│ │ │
│ │ ├── ipc/
│ │ │ ├── index.ts # IPC 핸들러 일괄 등록
│ │ │ ├── voice-handlers.ts # voice:* 핸들러
│ │ │ ├── audio-handlers.ts # audio:* 핸들러
│ │ │ ├── stt-handlers.ts # stt:* 핸들러
│ │ │ ├── llm-handlers.ts # llm:* 핸들러
│ │ │ ├── tts-handlers.ts # tts:* 핸들러
│ │ │ ├── config-handlers.ts # config:* 핸들러
│ │ │ ├── hotkey-handlers.ts # hotkey:* 핸들러
│ │ │ ├── history-handlers.ts # history:* 핸들러
│ │ │ ├── dictionary-handlers.ts # dictionary:* 핸들러
│ │ │ ├── instruction-handlers.ts # instruction:* 핸들러
│ │ │ ├── window-handlers.ts # window:* 핸들러
│ │ │ └── app-handlers.ts # app:* 핸들러
│ │ │
│ │ └── db/
│ │ ├── index.ts # better-sqlite3 초기화 + drizzle
│ │ ├── schema.ts # drizzle ORM 스키마 정의
│ │ └── migrations/ # drizzle 마이그레이션 파일
│ │ └── 0000_initial.sql
│ │
│ ├── renderer/
│ │ ├── index.html # React 앱 HTML 엔트리
│ │ ├── main.tsx # React 앱 진입점
│ │ ├── App.tsx # 루트 컴포넌트 (MUI Theme + Router)
│ │ ├── theme.ts # MUI 7 테마 정의
│ │ ├── hooks/
│ │ │ ├── useIpc.ts # window.d3ro IPC 래퍼 훅
│ │ │ ├── useVoiceState.ts # 음성 상태 구독 훅
│ │ │ └── useConfig.ts # 설정 읽기/쓰기 훅
│ │ ├── components/
│ │ │ ├── Layout.tsx # MUI Drawer(240px) + Content
│ │ │ ├── Sidebar.tsx # 네비게이션 사이드바
│ │ │ ├── StatusBar.tsx # 하단 상태 표시 (STT/LLM/Ollama)
│ │ │ └── VoiceButton.tsx # 녹음 시작 버튼
│ │ ├── pages/
│ │ │ ├── DashboardPage.tsx # 통계 + 최근 세션
│ │ │ ├── HistoryPage.tsx # 전사 이력 목록
│ │ │ ├── DictionaryPage.tsx # 사전 관리
│ │ │ └── InstructionsPage.tsx # 커스텀 명령어 관리
│ │ ├── modals/
│ │ │ ├── SettingsModal.tsx # 설정 다이얼로그
│ │ │ └── HotkeyRecordModal.tsx # 핫키 녹화 다이얼로그
│ │ └── popups/
│ │ ├── recording-tip/
│ │ │ ├── index.html # Vanilla JS HTML
│ │ │ ├── recording-tip.ts # 웨이브 바 + 상태 표시
│ │ │ └── recording-tip.css # 스타일
│ │ └── result-popup/
│ │ ├── index.html # Vanilla JS HTML
│ │ ├── result-popup.ts # 결과 표시 + 복사
│ │ └── result-popup.css # 스타일
│ │
│ ├── preload/
│ │ ├── index.ts # contextBridge (window.d3ro)
│ │ └── popup-preload.ts # 팝업 윈도우용 최소 preload
│ │
│ └── shared/
│ ├── ipc-channels.ts # IPC 채널 타입 정의 (IpcChannelMap)
│ ├── types.ts # 공유 타입 정의
│ ├── errors.ts # NXError + ErrorCode enum
│ └── constants.ts # 공유 상수 (타이밍 등)
│
├── tests/
│ ├── setup.ts # vitest 글로벌 셋업
│ ├── main/
│ │ ├── services/
│ │ │ ├── ConfigService.test.ts
│ │ │ ├── AudioCaptureService.test.ts
│ │ │ ├── LocalSTTService.test.ts
│ │ │ ├── VoiceModeService.test.ts
│ │ │ ├── HotkeyService.test.ts
│ │ │ ├── TextInsertService.test.ts
│ │ │ └── HistoryService.test.ts
│ │ └── ipc/
│ │ └── handlers.test.ts
│ └── renderer/
│ ├── components/
│ │ └── Layout.test.tsx
│ └── hooks/
│ └── useVoiceState.test.ts
│
└── sidecar/
└── whisper/
├── requirements.txt # faster-whisper 의존성
└── server.py # Whisper stdin/stdout 서버
부록 A: 공유 타입 정의
// src/shared/types.ts
/** 음성 인식 상태 머신 (Speakly 패턴) */
export enum RecognitionState {
IDLE = 'IDLE',
PREPARING = 'PREPARING', // 오디오 장치 초기화 중
READY = 'READY', // STT 모델 로딩 완료, 녹음 대기
RECOGNIZING = 'RECOGNIZING', // 녹음 + 전사 진행 중
PROCESSING = 'PROCESSING', // LLM 처리 중 (옵션)
COMPLETED = 'COMPLETED', // 전사/처리 완료
CANCELLED = 'CANCELLED', // 사용자 취소
ERROR = 'ERROR', // 에러 발생
DESTROYED = 'DESTROYED', // 서비스 종료됨
}
/** 오디오 캡처 상태 (RecognitionState와 별도 추적) */
export enum AudioState {
IDLE = 'IDLE',
INITIALIZING = 'INITIALIZING',
STREAMING = 'STREAMING',
STOPPED = 'STOPPED',
}
/** 음성 모드 */
export enum VoiceMode {
DICTATION = 'dictation', // hold-to-talk: 키 누르는 동안 녹음
HANDS_FREE = 'hands-free', // toggle: 키 한번 누르면 시작, 다시 누르면 종료
}
/** 오디오 디바이스 */
export interface AudioDevice {
deviceId: string;
label: string;
isDefault: boolean;
}
/** 전사 결과 */
export interface TranscriptionResult {
text: string;
language: string;
duration: number; // 오디오 길이 (ms)
segments?: TranscriptionSegment[];
}
export interface TranscriptionSegment {
start: number;
end: number;
text: string;
confidence: number;
}
/** 음성 파이프라인 최종 결과 */
export interface VoiceResult {
originalText: string; // STT 원본 전사
processedText: string; // LLM 처리 후 (또는 원본과 동일)
mode: VoiceMode;
duration: number;
timestamp: number;
inserted: boolean; // 텍스트 삽입 성공 여부
}
/** LLM 처리 결과 */
export interface LLMResult {
text: string;
model: string;
tokensUsed: number;
processingTime: number;
}
/** STT 엔진 상태 */
export interface STTStatus {
ready: boolean;
engine: 'faster-whisper' | 'whisper-cpp';
modelLoaded: string | null;
sidecarPid: number | null;
}
/** LLM 서버 상태 */
export interface LLMStatus {
reachable: boolean;
serverUrl: string;
models: string[];
selectedModel: string | null;
}
/** 이력 항목 */
export interface HistoryEntry {
id: number;
originalText: string;
processedText: string;
mode: VoiceMode;
duration: number;
createdAt: string; // ISO 8601
}
/** 이력 통계 */
export interface HistoryStats {
totalSessions: number;
totalDuration: number; // ms
totalWords: number;
todaySessions: number;
}
/** 사전 항목 */
export interface DictionaryEntry {
id: number;
word: string;
replacement: string;
createdAt: string;
}
/** 커스텀 명령어 */
export interface CustomInstruction {
id: string;
name: string;
prompt: string;
shortcut?: HotkeyBinding;
enabled: boolean;
}
/** STT 모델 정보 */
export interface STTModel {
id: string;
name: string;
size: string; // 예: "141 MB"
downloaded: boolean;
}
/** LLM 모델 정보 */
export interface LLMModel {
name: string;
size: string;
modifiedAt: string;
}
부록 B: 공유 상수
// 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, // Whisper 기본값
CHANNELS: 1, // mono
BIT_DEPTH: 16, // PCM16
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 }, // 초기, 2-phase resize
} as const;
부록 C: Speakly 채택 / 변경 / 제거 요약
| 패턴 | Speakly | D3RO-VOICE | 상태 |
|---|---|---|---|
| RecognitionState 상태 머신 | 8개 상태 | 9개 상태 (+PROCESSING) | 채택+확장 |
| AudioState 분리 추적 | O | O | 채택 |
| 이중 조건 플러시 | WebSocket+오디오 | 모델로딩+오디오 | 채택 (로컬 적용) |
| 재연결 3계층 | WebSocket 전용 | 불필요 (로컬 사이드카) | 제거 |
| 하트비트 (5s/15s) | WebSocket | 불필요 | 제거 |
| Opus 인코딩 | 24kHz Opus 60ms | PCM 16kHz 직접 전달 | 제거 |
| NXError 패턴 | ErrorCode.js | ErrorCode enum (TypeScript) | 채택 |
| 22단계 초기화 | 클라우드 포함 | 14단계 (로컬 전용) | 채택+축소 |
| 클립보드 삽입 | NativeHelper.dll | @nut-tree/nut-js | 채택 (구현 교체) |
| 텍스트 삽입 전략 | save→set→Ctrl+V→restore | 동일 | 채택 |
| 윈도우 프리로딩 | O | O | 채택 |
| 2-phase 리사이즈 | O | O | 채택 |
| 팝업 = Vanilla JS | O | O | 채택 |
| 메인 앱 = React+MUI | O | O (MUI 7로 업그레이드) | 채택 |
| 9개 웨이브 바 | cos 분포 가중치 | 동일 | 채택 |
| koffi FFI (DLL) | NativeHelper.dll | npm 패키지로 전체 교체 | 제거 |
| WASAPI 마이크 캡처 | C++ 코드 | Web Audio API | 제거 |
| WH_KEYBOARD_LL | DLL 후크 | uiohook-napi | 제거 |
| Win32 클립보드 | DLL API | electron clipboard | 제거 |
| Auth / Cloud 서비스 | 10+ 서비스 | 전부 제거 | 제거 |
| electron-store 설정 | 암호화 사용 | 암호화 불필요 (로컬) | 채택+간소화 |
| 단일 인스턴스 잠금 | O | O | 채택 |
| 시스템 트레이 | O | O | 채택 |
| closeToTray | O | O | 채택 |
| errorEmitted 플래그 | O | O | 채택 |
| settled boolean | O | O | 채택 |
| _isInTerminalState() 가드 | O | O | 채택 |
끝.