# D3RO-VOICE 서비스 상세 명세서 ## 목차 1. [공통 타입](#공통-타입) 2. [AudioCaptureService](#1-audiocaptureservice) 3. [LocalSTTService](#2-localsttservice) 4. [LocalTTSService](#3-localttsservice) 5. [LocalLLMService](#4-localllmservice) 6. [VoiceModeService](#5-voicemodeservice) 7. [TextInsertService](#6-textinsertservice) 8. [HotkeyService](#7-hotkeyservice) 9. [ConfigService](#8-configservice) 10. [HistoryService](#9-historyservice) 11. [WindowManagerService](#10-windowmanagerservice) --- ## 공통 타입 ```typescript /** 모든 서비스 에러에 사용되는 에러 코드 */ type ErrorCode = | 'AUDIO_DEVICE_NOT_FOUND' | 'AUDIO_DEVICE_ACCESS_DENIED' | 'AUDIO_CAPTURE_FAILED' | 'STT_MODEL_NOT_FOUND' | 'STT_MODEL_LOAD_FAILED' | 'STT_TRANSCRIPTION_FAILED' | 'STT_SIDECAR_CRASH' | 'TTS_ENGINE_NOT_FOUND' | 'TTS_SYNTHESIS_FAILED' | 'LLM_CONNECTION_FAILED' | 'LLM_MODEL_NOT_FOUND' | 'LLM_GENERATION_FAILED' | 'HOTKEY_REGISTER_FAILED' | 'TEXT_INSERT_FAILED' | 'CLIPBOARD_ACCESS_FAILED' | 'CONFIG_READ_FAILED' | 'CONFIG_WRITE_FAILED' | 'DB_QUERY_FAILED' | 'WINDOW_CREATE_FAILED'; /** 서비스 에러 */ interface ServiceError { readonly code: ErrorCode; readonly message: string; readonly cause?: unknown; } /** Disposable 패턴 — 모든 서비스가 구현 */ interface Disposable { dispose(): void; } ``` --- ## 1. AudioCaptureService 마이크 입력을 캡처하여 PCM16 오디오 데이터를 스트리밍한다. 싱글톤 + EventEmitter 패턴. Speakly MicNativeService의 구독/레퍼런스 카운팅 참조. ### 타입 정의 ```typescript /** 오디오 포맷 상수 */ const AUDIO_FORMAT = { sampleRate: 16_000, // 16kHz (Whisper 기본) channels: 1, // mono bitDepth: 16, // PCM16 frameSizeMs: 60, // 60ms 프레임 bytesPerFrame: 1920, // 16000 * 2 * 0.06 } as const; type AudioFormat = typeof AUDIO_FORMAT; /** 마이크 디바이스 정보 */ interface AudioDevice { readonly id: string; readonly name: string; readonly isDefault: boolean; } /** 캡처 상태 */ const enum CaptureState { Idle = 'idle', Starting = 'starting', Capturing = 'capturing', Stopping = 'stopping', Error = 'error', } /** 이벤트 페이로드 */ interface AudioCaptureEvents { /** PCM16 오디오 프레임 (60ms 단위) */ 'audio-data': { buffer: Buffer; timestamp: number }; /** RMS 기반 레벨 (0.0 ~ 1.0) */ 'audio-level': { level: number; timestamp: number }; /** 디바이스 변경 (핫플러그 또는 수동) */ 'device-changed': { previous: AudioDevice | null; current: AudioDevice }; 'started': { deviceId: string }; 'stopped': { reason: 'manual' | 'device-lost' | 'error' }; 'error': { error: ServiceError }; } ``` ### 인터페이스 ```typescript interface IAudioCaptureService extends Disposable { /** 현재 캡처 상태 */ readonly state: CaptureState; /** 현재 사용 중인 디바이스 */ readonly currentDevice: AudioDevice | null; /** * 마이크 캡처 시작. * deviceId 미지정 시 시스템 기본 디바이스 사용. * 이미 캡처 중이면 무시 (레퍼런스 카운팅). */ start(deviceId?: string): Promise; /** 캡처 중지. 레퍼런스 카운트가 0이 되면 실제 중지. */ stop(): Promise; /** 사용 가능한 입력 디바이스 목록 */ getDevices(): Promise; /** 현재 디바이스 조회 */ getCurrentDevice(): AudioDevice | null; /** 이벤트 등록 */ on( event: K, listener: (payload: AudioCaptureEvents[K]) => void, ): this; off( event: K, listener: (payload: AudioCaptureEvents[K]) => void, ): this; } ``` ### 상태 전이 ``` ┌──────────────────────────────────────┐ │ │ ▼ │ IDLE ──start()──► STARTING ──success──► CAPTURING ▲ │ │ │ │ fail │ stop() / device-lost │ ▼ ▼ │ ERROR STOPPING │ │ │ │ └────dispose()───────┘ │ │ └───────────────────────────────────────┘ ``` ### 디바이스 관리 - 시스템 디바이스 목록 변경 감지 (폴링 2초 간격) - 현재 디바이스 분리 시: 기본 디바이스로 자동 폴백, `device-changed` 이벤트 발생 - 레퍼런스 카운팅: 여러 소비자가 `start()` 호출 가능, 모두 `stop()` 해야 실제 중지 --- ## 2. LocalSTTService faster-whisper sidecar를 관리하고 오디오 버퍼를 텍스트로 변환한다. 싱글톤 + EventEmitter 패턴. Speakly VoiceRecognitionService의 상태 머신 및 이중 조건 플러시 참조. ### 타입 정의 ```typescript /** STT 모델 정보 */ interface STTModel { readonly id: string; // 예: 'base', 'small', 'medium', 'large-v3', 'large-v3-turbo' readonly name: string; readonly size: number; // 바이트 단위 readonly language: string; // 'auto' | 'ko' | 'en' | ... readonly downloaded: boolean; } /** 전사 결과 세그먼트 */ interface TranscriptionSegment { readonly text: string; readonly start: number; // 초 단위 readonly end: number; readonly confidence: number; // 0.0 ~ 1.0 } /** 전사 결과 */ interface TranscriptionResult { readonly text: string; readonly segments: TranscriptionSegment[]; readonly language: string; readonly duration: number; // 오디오 길이 (초) readonly processingTime: number; // 처리 시간 (ms) } /** STT 엔진 상태 */ const enum STTState { Uninitialized = 'uninitialized', Loading = 'loading', Ready = 'ready', Transcribing = 'transcribing', Error = 'error', } /** 이벤트 페이로드 */ interface LocalSTTEvents { /** 실시간 부분 전사 결과 (스트리밍 모드) */ 'transcription-delta': { text: string; isFinal: boolean }; /** 최종 전사 완료 */ 'transcription-complete': { result: TranscriptionResult }; /** 모델 로딩 완료 */ 'model-loaded': { model: STTModel; loadTimeMs: number }; 'error': { error: ServiceError }; } ``` ### 인터페이스 ```typescript interface TranscribeOptions { /** 언어 힌트 ('auto'이면 자동 감지) */ language?: string; /** 초기 프롬프트 (컨텍스트 힌트) */ initialPrompt?: string; /** VAD 필터 활성화 */ vadFilter?: boolean; } interface ILocalSTTService extends Disposable { readonly state: STTState; readonly currentModel: STTModel | null; /** * Whisper sidecar 프로세스 시작 + 모델 로딩. * 이미 로딩된 모델과 같으면 무시. */ initialize(modelId: string): Promise; /** * 오디오 버퍼를 전사. * PCM16 16kHz mono 포맷이어야 한다. * 이중 조건 플러시: 모델 로딩과 오디오 버퍼링이 모두 완료되면 실행. */ transcribe(audioBuffer: Buffer, options?: TranscribeOptions): Promise; /** 다운로드된 모델 목록 조회 */ getModels(): Promise; /** 현재 상태 조회 */ getStatus(): { state: STTState; modelId: string | null; uptime: number }; on( event: K, listener: (payload: LocalSTTEvents[K]) => void, ): this; off( event: K, listener: (payload: LocalSTTEvents[K]) => void, ): this; } ``` ### 상태 전이 ``` UNINITIALIZED ──initialize()──► LOADING ──success──► READY ▲ │ │ │ │ fail │ transcribe() │ ▼ ▼ │ ERROR TRANSCRIBING │ │ │ │ │ complete │ │ │ └───────dispose()───────────┴────────────────────┘ │ ──► READY ``` ### Sidecar 통신 프로토콜 (FastAPI HTTP 서버) ``` Main Process STT+TTS Python Sidecar (FastAPI) │ │ │── spawn (port 인자) ──────────────►│ │── GET /health (폴링) ─────────────►│ │◄── {"status":"ready"} ──────────────│ │ │ │── POST /load {"model_id":"base"} ─►│ │◄── {"status":"loaded"} ────────────│ │ │ │── POST /transcribe │ │ (multipart: audio.pcm) ─────────►│ │◄── {"text":"안녕하세요",...} ────────│ │ │ │── POST /tts/speak │ │ {"text":"...", "voice":"..."} ──►│ │◄── audio/wav (바이너리) ────────────│ │ │ │── POST /shutdown ─────────────────►│ │ exit│ ``` STT(faster-whisper)와 TTS(Kokoro)를 단일 Python sidecar로 통합 운영한다. Electron에서 `fetch()` API로 HTTP 통신하며, health check는 `GET /health`로 수행한다. ### 이중 조건 플러시 패턴 ```typescript // Speakly에서 차용한 핵심 패턴 // 모델 로딩과 오디오 버퍼링을 동시에 진행, 둘 다 준비되면 플러시 private modelReady = false; private audioBuffer: Buffer[] = []; private pendingResolve: ((result: TranscriptionResult) => void) | null = null; private tryFlushAll(): void { if (this.modelReady && this.audioBuffer.length > 0 && this.pendingResolve) { const merged = Buffer.concat(this.audioBuffer); this.audioBuffer = []; this.sendToSidecar(merged).then(this.pendingResolve); this.pendingResolve = null; } } ``` --- ## 3. LocalTTSService Kokoro TTS를 기본 엔진으로 사용하고, edge-tts를 온라인 폴백으로 제공하여 텍스트를 음성으로 변환한다. STT(faster-whisper)와 동일한 Python sidecar 프로세스에서 호스팅된다. ### 타입 정의 ```typescript /** TTS 음성 정보 */ interface TTSVoice { readonly id: string; // 예: 'kf_default' (Kokoro), 'ko-KR-SunHiNeural' (edge-tts) readonly name: string; readonly language: string; readonly gender: 'male' | 'female' | 'neutral'; readonly sampleRate: number; readonly downloaded: boolean; readonly engine: 'kokoro' | 'edge-tts'; } /** TTS 옵션 */ interface TTSOptions { /** 음성 속도 (0.5 ~ 2.0, 기본 1.0) */ speed?: number; /** 출력 포맷 */ format?: 'pcm' | 'wav'; } /** TTS 상태 */ const enum TTSState { Idle = 'idle', Loading = 'loading', Ready = 'ready', Speaking = 'speaking', Error = 'error', } /** 이벤트 페이로드 */ interface LocalTTSEvents { /** 오디오 출력 청크 (스트리밍) */ 'audio-output': { buffer: Buffer; sampleRate: number; isFinal: boolean }; 'started': { text: string; voiceId: string }; 'finished': { durationMs: number }; 'error': { error: ServiceError }; } ``` ### 인터페이스 ```typescript interface ILocalTTSService extends Disposable { readonly state: TTSState; /** * 텍스트를 음성으로 합성하여 재생. * Kokoro (오프라인) → edge-tts (온라인 폴백) 순으로 시도. */ speak(text: string, voiceId?: string, options?: TTSOptions): Promise; /** 사용 가능한 음성 목록 */ getVoices(): Promise; /** 현재 재생 중지 */ stop(): void; on( event: K, listener: (payload: LocalTTSEvents[K]) => void, ): this; off( event: K, listener: (payload: LocalTTSEvents[K]) => void, ): this; } ``` ### 상태 전이 ``` IDLE ──speak()──► LOADING ──voice ready──► SPEAKING ──done──► IDLE ▲ │ │ │ │ fail │ stop() │ ▼ │ │ ERROR ◄────────error────────┘ │ │ └──────────────────┘ ``` --- ## 4. LocalLLMService Ollama REST API를 통해 로컬 LLM과 상호작용한다. ### 타입 정의 ```typescript /** Ollama 모델 정보 */ interface LLMModel { readonly name: string; // 예: 'llama3.2:3b', 'mistral:7b' readonly size: number; // 바이트 readonly quantization: string;// 예: 'Q4_K_M' readonly modifiedAt: string; // ISO 8601 } /** 생성 옵션 */ interface GenerateOptions { model?: string; temperature?: number; // 0.0 ~ 2.0, 기본 0.7 maxTokens?: number; // 기본 2048 topP?: number; // 0.0 ~ 1.0 topK?: number; systemPrompt?: string; /** 스트리밍 여부 */ stream?: boolean; } /** 생성 결과 */ interface GenerateResult { readonly text: string; readonly model: string; readonly promptTokens: number; readonly completionTokens: number; readonly totalDuration: number; // ms } /** LLM 상태 */ const enum LLMState { Unavailable = 'unavailable', // Ollama 미실행 Available = 'available', // Ollama 실행 중 Generating = 'generating', Error = 'error', } /** Ollama API 기본 설정 */ const OLLAMA_CONFIG = { baseUrl: 'http://localhost:11434', healthEndpoint: '/api/tags', generateEndpoint: '/api/generate', chatEndpoint: '/api/chat', pollIntervalMs: 5_000, // 가용성 폴링 간격 } as const; /** 이벤트 페이로드 */ interface LocalLLMEvents { /** 스트리밍 토큰 */ 'token': { token: string; done: boolean }; /** 생성 완료 */ 'complete': { result: GenerateResult }; /** Ollama 연결 상태 변경 */ 'availability-changed': { available: boolean }; 'error': { error: ServiceError }; } ``` ### 인터페이스 ```typescript interface ILocalLLMService extends Disposable { readonly state: LLMState; /** * 텍스트 생성 (비스트리밍). * Ollama /api/generate 엔드포인트 사용. */ generate(prompt: string, options?: GenerateOptions): Promise; /** * 스트리밍 텍스트 생성. * 'token' 이벤트를 통해 점진적으로 토큰 전달. * AbortController로 취소 가능. */ stream(prompt: string, options?: Omit): AbortController; /** 사용 가능한 모델 목록 (Ollama에서 조회) */ getModels(): Promise; /** Ollama 가용 여부 */ isAvailable(): boolean; on( event: K, listener: (payload: LocalLLMEvents[K]) => void, ): this; off( event: K, listener: (payload: LocalLLMEvents[K]) => void, ): this; } ``` ### 스트리밍 응답 처리 ```typescript // Ollama /api/generate 스트리밍 응답 (NDJSON) // 각 줄이 하나의 JSON 객체: // {"model":"llama3.2","response":" 안녕","done":false} // {"model":"llama3.2","response":"하세요","done":false} // {"model":"llama3.2","response":"","done":true,"total_duration":1234567890} // fetch + ReadableStream으로 처리: async function* streamGenerate(prompt: string): AsyncGenerator { const response = await fetch(`${OLLAMA_CONFIG.baseUrl}/api/generate`, { method: 'POST', body: JSON.stringify({ model, prompt, stream: true }), }); const reader = response.body!.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop()!; for (const line of lines) { if (!line.trim()) continue; const chunk = JSON.parse(line); yield chunk.response; if (chunk.done) return; } } } ``` --- ## 5. VoiceModeService (오케스트레이터) AudioCapture → STT → LLM → TextInsert 파이프라인을 오케스트레이션한다. Speakly VoiceModeService의 전체 오케스트레이션 패턴 참조. ### 타입 정의 ```typescript /** 음성 인식 상태 (Speakly RecognitionState 기반, 9개 상태) */ const enum RecognitionState { Idle = 'idle', Preparing = 'preparing', // STT 모델 로딩 Connecting = 'connecting', // sidecar/WebSocket 연결 중 Ready = 'ready', // 모델 준비 완료, 대기 Recognizing = 'recognizing', // 실시간 전사 중 (LLM 후처리 포함) Completed = 'completed', Cancelled = 'cancelled', Error = 'error', Destroyed = 'destroyed', // 리소스 정리 완료 (dispose 전용 최종 상태) } /** 오디오 상태 (Speakly AudioState 기반) */ const enum AudioState { Idle = 'idle', Initializing = 'initializing', Streaming = 'streaming', Stopped = 'stopped', } /** 음성 모드 */ const enum VoiceMode { /** 누르고 있는 동안 녹음 */ Dictation = 'dictation', /** 토글 (한 번 누르면 시작, 다시 누르면 종료) */ HandsFree = 'hands-free', /** 커스텀 명령어 모드 */ CustomInstruction = 'custom-instruction', /** 핸즈프리 + 웨이크워드 없이 (별도 핫키) */ HandsFreeNoWake = 'hands-free-no-wake', } /** 세션 정보 */ interface VoiceSession { readonly id: string; readonly mode: VoiceMode; readonly startedAt: number; readonly recognitionState: RecognitionState; readonly audioState: AudioState; readonly audioBufferDurationMs: number; readonly transcription: string; readonly processedText: string | null; } /** LLM 후처리 명령 */ type PostProcessCommand = | { type: 'none' } // 원본 그대로 삽입 | { type: 'polish'; style?: 'formal' | 'casual' } // 문체 다듬기 | { type: 'translate'; targetLanguage: string } // 번역 | { type: 'summarize' } // 요약 | { type: 'custom'; prompt: string }; // 사용자 정의 /** 이벤트 페이로드 */ interface VoiceModeEvents { 'session-started': { session: VoiceSession }; 'recognition-state-changed': { previous: RecognitionState; current: RecognitionState }; 'audio-state-changed': { previous: AudioState; current: AudioState }; 'transcription-update': { text: string; isFinal: boolean }; 'processing-update': { text: string; done: boolean }; 'session-completed': { session: VoiceSession; finalText: string }; 'session-cancelled': { session: VoiceSession; reason: 'user' | 'timeout' | 'too-short' }; 'audio-level': { level: number }; 'error': { error: ServiceError; session: VoiceSession | null }; } ``` ### 인터페이스 ```typescript interface VoiceModeOptions { mode: VoiceMode; postProcess?: PostProcessCommand; language?: string; insertAfterComplete?: boolean; // 기본 true } interface IVoiceModeService extends Disposable { readonly currentSession: VoiceSession | null; readonly isActive: boolean; /** 음성 세션 시작 (핫키 누를 때) */ startSession(options: VoiceModeOptions): Promise; /** 음성 세션 종료 (핫키 놓을 때 / 다시 누를 때) */ stopSession(): Promise; /** 세션 취소 */ cancelSession(): void; on( event: K, listener: (payload: VoiceModeEvents[K]) => void, ): this; off( event: K, listener: (payload: VoiceModeEvents[K]) => void, ): this; } ``` ### 상태 전이 다이어그램 — RecognitionState ``` startSession() │ ▼ ┌──────── IDLE ──► PREPARING ──► CONNECTING ──connected──► READY │ ▲ │ │ │ │ │ │ fail │ fail │ audio started │ │ ▼ ▼ ▼ │ │ ERROR ◄────────────────────── RECOGNIZING ◄──┐ │ │ │ │ │ │ │ │ │ done/insert cancel │ │ │ │ │ │ │ │ │ ▼ ▼ │ │ │ │ COMPLETED CANCELLED│ │ │ │ │ │ └──────────┴──────────┴──────────────────────────────┘ │ (모든 최종 상태에서 IDLE로 복귀) │ │ * cancel은 PREPARING, CONNECTING, READY, RECOGNIZING에서 가능 * destroy() 호출 시 → DESTROYED (최종 상태, 복귀 없음) ``` ### 상태 전이 다이어그램 — AudioState ``` IDLE ──start──► INITIALIZING ──device ready──► STREAMING │ │ │ fail stop / done ▼ ▼ (error → STOPPED RecognitionState.Error) │ └──► IDLE ``` ### 오케스트레이션 흐름 (_tryFlushAll 패턴) ```typescript // 이중 조건 플러시: STT 모델 로딩과 오디오 캡처를 동시에 진행 // // startSession() 호출 시: // 1. sttService.initialize(modelId) ← 비동기 // 2. audioService.start(deviceId) ← 비동기 // 3. 오디오 도착 → audioBuffer에 적재 // 4. 각 비동기 작업 완료 시 → _tryFlushAll() 호출 // // _tryFlushAll(): // if (sttReady && audioBuffer.length > 0) { // recognitionState = RecognitionState.Recognizing; // sttService.transcribe(Buffer.concat(audioBuffer)); // audioBuffer = []; // } private audioBuffer: Buffer[] = []; private sttReady = false; private audioStarted = false; private tryFlushAll(): void { if (!this.sttReady || !this.audioStarted) return; if (this.audioBuffer.length === 0) return; const merged = Buffer.concat(this.audioBuffer); this.audioBuffer = []; this.transitionRecognition(RecognitionState.Recognizing); this.sttService.transcribe(merged); } ``` ### 타이밍 상수 ```typescript const VOICE_TIMING = { /** 최소 오디오 길이 — 이하 자동 취소 (Speakly: 700ms) */ minAudioDurationMs: 700, /** 더블프레스 감지 간격 */ doublePressMs: 300, /** 완료 후 아이들 타임아웃 */ completionIdleTimeoutMs: 30_000, /** 절대 최대 대기 */ absoluteMaxWaitMs: 120_000, /** 녹음 종료 후 후처리 대기 (버퍼 없으면 4초, 있으면 6초) */ postRecordingWaitMs: 4_000, postRecordingWaitWithBufferMs: 6_000, /** 키 릴리스 후 정지 지연 */ stopDelayMs: 200, /** 음소거 지연 (사운드 이펙트 재생 후) */ muteDelayMs: 500, /** 언뮤트 후 효과음 재생 지연 */ unmuteSoundDelayMs: 100, } as const; ``` ### accidentalPress 감지 키 누름 시간이 짧아 의도치 않은 입력으로 판정되면 세션을 즉시 취소한다. ```typescript // 키 릴리스 시점에 호출 private checkAndMarkAccidentalPress(): boolean { const duration = Date.now() - this.currentSession.startedAt; if (duration < VOICE_TIMING.minAudioDurationMs) { // 700ms this.currentSession.accidentalPress = true; this.cancelSession(); return true; } return false; } ``` - 판정 기준: (1) key press duration < 700ms, (2) session lifetime < 700ms - 세션에 `accidentalPress = true` 마킹 후 즉시 취소 - 취소 사유: `'too-short'` ### Audio Mute 연동 녹음 중 시스템 오디오를 음소거하여 마이크 피드백을 방지한다. ```typescript // 녹음 시작 시 시퀀스: // 1. 시작 사운드 이펙트 재생 // 2. MUTE_DELAY_MS(500ms) 후 시스템 오디오 음소거 // 3. wasMutedBeforeRecording = 이전 음소거 상태 저장 // // 녹음 종료 시 시퀀스: // 1. wasMutedBeforeRecording이 false일 때만 언뮤트 // 2. UNMUTE_SOUND_DELAY_MS(100ms) 후 종료 효과음 재생 // // 설정: config:getMuteAudioWhenDictating으로 on/off 가능 // 루프백 마이크 사용 시 음소거 스킵 ``` ### Action Queue (이벤트 직렬화) 핫키 press/release 이벤트 간 race condition을 방지하기 위해 이벤트를 큐에 넣고 순차 처리한다. ```typescript interface NXAction { type: 'press' | 'release' | 'escape'; timestamp: number; hotkeyId: string; hotkeyTimestamp: number; } private actionQueue: NXAction[] = []; private isProcessingActionQueue = false; // 핫키 이벤트 수신 시 큐에 추가 후 processQueue() 호출 // processQueue(): while (queue.length > 0) { ... await processAction(queue.shift()) } // isProcessingActionQueue 플래그로 동시 실행 방지 // ESC 시 clearActionQueue()로 전체 큐 클리어 ``` --- ## 6. TextInsertService 전사된 텍스트를 현재 활성 앱에 삽입한다. Speakly ClipboardPaste + TextOperationStrategy 참조. ### 타입 정의 ```typescript /** 텍스트 삽입 전략 */ const enum InsertMethod { /** 클립보드 저장 → 텍스트 설정 → Ctrl+V → 클립보드 복원 */ Clipboard = 'clipboard', /** 키보드 타이핑 시뮬레이션 (느리지만 클립보드 비파괴) */ Keyboard = 'keyboard', } /** 클립보드 스냅샷 */ interface ClipboardSnapshot { readonly text: string | null; readonly html: string | null; readonly image: Buffer | null; readonly rtf: string | null; readonly hasContent: boolean; } /** 삽입 결과 */ interface InsertResult { readonly success: boolean; readonly method: InsertMethod; readonly textLength: number; readonly durationMs: number; } /** 이벤트 페이로드 */ interface TextInsertEvents { 'insert-started': { text: string; method: InsertMethod }; 'insert-completed': { result: InsertResult }; 'insert-failed': { error: ServiceError; method: InsertMethod }; 'clipboard-saved': Record; 'clipboard-restored': Record; } ``` ### 인터페이스 ```typescript interface InsertTextOptions { method?: InsertMethod; // 기본: Clipboard /** Ctrl+V 후 클립보드 복원까지 대기 시간 */ restoreDelayMs?: number; // 기본: 100 } interface ITextInsertService extends Disposable { /** * 텍스트를 현재 활성 앱에 삽입. * 기본 전략: clipboard save → set → Ctrl+V → restore */ insertText(text: string, options?: InsertTextOptions): Promise; /** 현재 클립보드 상태 캡처 */ getClipboardState(): ClipboardSnapshot; /** 클립보드 내용 저장 (수동) */ saveClipboard(): ClipboardSnapshot; /** 저장된 클립보드 내용 복원 (수동) */ restoreClipboard(snapshot: ClipboardSnapshot): void; on( event: K, listener: (payload: TextInsertEvents[K]) => void, ): this; off( event: K, listener: (payload: TextInsertEvents[K]) => void, ): this; } ``` ### 삽입 흐름 의사코드 ```typescript async function insertText(text: string, options: InsertTextOptions): Promise { const method = options.method ?? InsertMethod.Clipboard; const start = performance.now(); if (method === InsertMethod.Clipboard) { // 1. 기존 클립보드 저장 const snapshot = this.saveClipboard(); this.emit('clipboard-saved', {}); try { // 2. 클립보드에 텍스트 설정 clipboard.writeText(text); // 3. Ctrl+V 시뮬레이션 (@nut-tree/nut-js) await keyboard.pressKey(Key.LeftControl, Key.V); await keyboard.releaseKey(Key.LeftControl, Key.V); // 4. 붙여넣기 완료 대기 await sleep(options.restoreDelayMs ?? 100); // 5. 클립보드 복원 this.restoreClipboard(snapshot); this.emit('clipboard-restored', {}); } catch (err) { // 실패 시에도 클립보드 복원 시도 this.restoreClipboard(snapshot); throw err; } } else { // Keyboard 전략: 한 글자씩 타이핑 await keyboard.type(text); } return { success: true, method, textLength: text.length, durationMs: performance.now() - start, }; } ``` --- ## 7. HotkeyService 글로벌 키보드 후킹으로 핫키를 감지한다. uiohook-napi 기반. Speakly HotkeyConfig 및 더블프레스 감지 참조. ### 타입 정의 ```typescript /** 키 코드 (uiohook-napi UiohookKey 기반) */ type KeyCode = number; /** 수정자 키 */ const enum Modifier { Ctrl = 'ctrl', Alt = 'alt', Shift = 'shift', Meta = 'meta', } /** 핫키 설정 */ interface HotkeyConfig { readonly id: string; // 예: 'voice-dictation', 'voice-handsfree' readonly keyCode: KeyCode; readonly modifiers: Modifier[]; /** true이면 누르고 있는 동안 활성, false이면 토글 */ readonly holdMode: boolean; /** 더블프레스 활성화 여부 */ readonly doublePressEnabled: boolean; /** 활성화 여부 */ readonly enabled: boolean; } /** 핫키 이벤트 타입 */ const enum HotkeyAction { Pressed = 'pressed', Released = 'released', DoublePress = 'double-press', } /** 이벤트 페이로드 */ interface HotkeyEvents { 'hotkey-pressed': { config: HotkeyConfig; timestamp: number }; 'hotkey-released': { config: HotkeyConfig; durationMs: number; timestamp: number }; 'double-press': { config: HotkeyConfig; intervalMs: number; timestamp: number }; 'error': { error: ServiceError }; } ``` ### 인터페이스 ```typescript interface IHotkeyService extends Disposable { readonly isRunning: boolean; readonly registeredHotkeys: ReadonlyMap; /** uiohook 시작 (글로벌 키보드 후킹) */ start(): void; /** uiohook 중지 */ stop(): void; /** 핫키 등록/갱신 */ registerHotkey(config: HotkeyConfig): void; /** 핫키 해제 */ unregisterHotkey(id: string): void; /** 사용 가능한 키 목록 (UI 바인딩용) */ getAvailableKeys(): Array<{ code: KeyCode; label: string }>; on( event: K, listener: (payload: HotkeyEvents[K]) => void, ): this; off( event: K, listener: (payload: HotkeyEvents[K]) => void, ): this; } ``` ### 더블프레스 감지 로직 ```typescript // Speakly 패턴: 300ms 이내 연속 두 번 press → 더블프레스 // LLKHF_INJECTED 바이패스: 자체 시뮬레이션 키는 무시 private lastPressTime: Map = new Map(); private onKeyDown(hotkeyId: string, config: HotkeyConfig): void { const now = Date.now(); const lastPress = this.lastPressTime.get(hotkeyId) ?? 0; if (config.doublePressEnabled && (now - lastPress) < VOICE_TIMING.doublePressMs) { this.emit('double-press', { config, intervalMs: now - lastPress, timestamp: now, }); this.lastPressTime.delete(hotkeyId); return; } this.lastPressTime.set(hotkeyId, now); this.emit('hotkey-pressed', { config, timestamp: now }); } ``` ### uiohook-napi 연동 ```typescript import { uIOhook, UiohookKey } from 'uiohook-napi'; // start(): uIOhook.on('keydown', (e) => this.handleKeyEvent(e, 'down')); uIOhook.on('keyup', (e) => this.handleKeyEvent(e, 'up')); uIOhook.start(); // stop(): uIOhook.stop(); // handleKeyEvent: 등록된 핫키 목록과 매칭 // modifiers 체크: e.ctrlKey, e.altKey, e.shiftKey, e.metaKey ``` --- ## 8. ConfigService electron-store 기반 설정 관리. 섹션별 키/값/기본값 전체 정의. ### 타입 정의 ```typescript /** 오디오 설정 */ interface AudioConfig { /** 입력 디바이스 ID (null이면 시스템 기본) */ inputDeviceId: string | null; /** 입력 게인 (0.0 ~ 2.0) */ inputGain: number; /** 무음 감지 임계값 */ silenceThreshold: number; } /** 핫키 설정 */ interface HotkeyConfigSection { /** dictation 모드 핫키 */ dictation: HotkeyConfig; /** hands-free 모드 핫키 */ handsFree: HotkeyConfig; } /** UI 설정 */ interface UIConfig { /** 테마 */ theme: 'light' | 'dark' | 'system'; /** UI 언어 */ language: string; /** 트레이 아이콘 표시 */ showTrayIcon: boolean; /** 시작 시 최소화 */ startMinimized: boolean; /** 녹음 팁 표시 */ showRecordingTip: boolean; /** 결과 팝업 자동 닫기 (ms, 0이면 수동) */ resultPopupAutoCloseMs: number; } /** STT 설정 */ interface STTConfig { /** Whisper 모델 ID */ modelId: string; /** 기본 언어 ('auto' | 언어 코드) */ language: string; /** VAD 필터 활성화 */ vadFilter: boolean; /** 초기 프롬프트 */ initialPrompt: string; } /** TTS 설정 */ interface TTSConfig { /** 기본 음성 ID */ voiceId: string; /** 말하기 속도 (0.5 ~ 2.0) */ speed: number; /** TTS 활성화 여부 */ enabled: boolean; } /** LLM 설정 */ interface LLMConfig { /** Ollama 기본 모델 */ defaultModel: string; /** 기본 온도 */ temperature: number; /** 최대 토큰 */ maxTokens: number; /** 기본 후처리 명령 */ defaultPostProcess: PostProcessCommand; /** Ollama 서버 URL */ serverUrl: string; } /** 윈도우 설정 */ interface WindowConfig { /** 메인 윈도우 위치/크기 (null이면 자동) */ mainBounds: { x: number; y: number; width: number; height: number } | null; /** 녹음 팁 위치 */ recordingTipPosition: 'cursor' | 'center' | 'bottom-right'; /** 결과 팝업 위치 */ resultPopupPosition: 'cursor' | 'center' | 'bottom-right'; } /** 전체 설정 스키마 */ interface AppConfig { audio: AudioConfig; hotkey: HotkeyConfigSection; ui: UIConfig; stt: STTConfig; tts: TTSConfig; llm: LLMConfig; window: WindowConfig; } /** 설정 기본값 */ const DEFAULT_CONFIG: AppConfig = { audio: { inputDeviceId: null, inputGain: 1.0, silenceThreshold: 0.01, }, hotkey: { dictation: { id: 'voice-dictation', keyCode: 162, // Left Ctrl modifiers: [], holdMode: true, doublePressEnabled: false, enabled: true, }, handsFree: { id: 'voice-handsfree', keyCode: 162, // Left Ctrl modifiers: [], holdMode: false, doublePressEnabled: true, enabled: true, }, }, ui: { theme: 'system', language: 'ko', showTrayIcon: true, startMinimized: false, showRecordingTip: true, resultPopupAutoCloseMs: 5_000, }, stt: { modelId: 'base', language: 'auto', vadFilter: true, initialPrompt: '', }, tts: { voiceId: 'ko-KR-default', speed: 1.0, enabled: false, }, llm: { defaultModel: 'llama3.2:3b', temperature: 0.7, maxTokens: 2048, defaultPostProcess: { type: 'none' }, serverUrl: 'http://localhost:11434', }, window: { mainBounds: null, recordingTipPosition: 'cursor', resultPopupPosition: 'center', }, }; /** 설정 변경 이벤트 */ interface ConfigEvents { 'config-changed': { /** 점 표기법 경로 (예: 'audio.inputGain') */ key: string; oldValue: unknown; newValue: unknown; }; } ``` ### 인터페이스 ```typescript interface IConfigService extends Disposable { /** * 설정값 읽기. * 점 표기법 지원: get('audio.inputGain') */ get(section: K): AppConfig[K]; get(key: string): T; /** * 설정값 쓰기. * 점 표기법 지원: set('audio.inputGain', 1.5) */ set(section: K, value: AppConfig[K]): void; set(key: string, value: unknown): void; /** 특정 섹션을 기본값으로 리셋 */ reset(section: K): void; /** 전체 설정을 기본값으로 리셋 */ resetAll(): void; /** 전체 설정 조회 (읽기 전용 복사본) */ getAll(): Readonly; on( event: K, listener: (payload: ConfigEvents[K]) => void, ): this; off( event: K, listener: (payload: ConfigEvents[K]) => void, ): this; } ``` --- ## 9. HistoryService 음성 인식 히스토리를 SQLite에 저장하고 관리한다. better-sqlite3 + drizzle-orm. Speakly HistoryService 스키마 참조. ### 타입 정의 ```typescript /** 히스토리 항목 */ interface HistoryEntry { readonly id: number; readonly createdAt: string; // ISO 8601 readonly originalText: string; // STT 원본 readonly processedText: string | null; // LLM 후처리 결과 readonly finalText: string; // 실제 삽입된 텍스트 readonly language: string; readonly audioDurationMs: number; readonly processingTimeMs: number; readonly postProcessType: PostProcessCommand['type']; readonly modelId: string; // 사용된 STT 모델 readonly llmModelId: string | null;// 사용된 LLM 모델 readonly targetApp: string | null; // 삽입된 대상 앱 readonly deleted: boolean; // 소프트 삭제 } /** 히스토리 생성 입력 */ interface CreateHistoryInput { originalText: string; processedText?: string | null; finalText: string; language: string; audioDurationMs: number; processingTimeMs: number; postProcessType: PostProcessCommand['type']; modelId: string; llmModelId?: string | null; targetApp?: string | null; } /** 검색 필터 */ interface HistoryFilter { query?: string; // 텍스트 검색 (LIKE) language?: string; postProcessType?: PostProcessCommand['type']; startDate?: string; // ISO 8601 endDate?: string; // ISO 8601 limit?: number; // 기본 50 offset?: number; // 기본 0 } /** 통계 */ interface HistoryStats { readonly totalEntries: number; readonly totalAudioDurationMs: number; readonly totalProcessingTimeMs: number; readonly averageProcessingTimeMs: number; readonly entriesByLanguage: Record; readonly entriesByPostProcess: Record; readonly entriesLast7Days: number; readonly entriesLast30Days: number; } /** 보존 정책 */ const RETENTION_POLICY = { /** 보존 기간 (일) */ retentionDays: 30, /** 정리 실행 주기 (시간) */ cleanupIntervalHours: 24, /** 최대 항목 수 (0이면 무제한) */ maxEntries: 0, } as const; /** drizzle-orm 스키마 */ // schema.ts: // export const historyTable = sqliteTable('history', { // id: integer('id').primaryKey({ autoIncrement: true }), // createdAt: text('created_at').notNull().default(sql`CURRENT_TIMESTAMP`), // originalText: text('original_text').notNull(), // processedText: text('processed_text'), // finalText: text('final_text').notNull(), // language: text('language').notNull(), // audioDurationMs: integer('audio_duration_ms').notNull(), // processingTimeMs: integer('processing_time_ms').notNull(), // postProcessType: text('post_process_type').notNull(), // modelId: text('model_id').notNull(), // llmModelId: text('llm_model_id'), // targetApp: text('target_app'), // deleted: integer('deleted', { mode: 'boolean' }).notNull().default(false), // }); ``` ### 인터페이스 ```typescript interface IHistoryService extends Disposable { /** 히스토리 항목 생성 */ create(input: CreateHistoryInput): HistoryEntry; /** ID로 조회 */ getById(id: number): HistoryEntry | null; /** 필터 기반 목록 조회 */ list(filter?: HistoryFilter): { entries: HistoryEntry[]; total: number }; /** 텍스트 검색 (LIKE '%query%') */ search(query: string, limit?: number): HistoryEntry[]; /** 소프트 삭제 */ delete(id: number): boolean; /** 영구 삭제 (보존 정책 정리용) */ purge(olderThanDays: number): number; /** 통계 조회 */ getStats(): HistoryStats; /** 보존 정책에 따라 오래된 항목 정리 (앱 시작 시 + 주기적 실행) */ runRetentionCleanup(): number; } ``` --- ## 10. WindowManagerService Electron 윈도우의 생명주기, 프리로딩, 리사이즈를 관리한다. Speakly의 8개 윈도우 관리 패턴 참조. ### 타입 정의 ```typescript /** 윈도우 식별자 */ const enum WindowId { Main = 'main', RecordingTip = 'recording-tip', ResultPopup = 'result-popup', Settings = 'settings', } /** 윈도우 상태 */ const enum WindowState { /** 아직 생성 안 됨 */ NotCreated = 'not-created', /** 프리로드 중 (hidden) */ Preloading = 'preloading', /** 프리로드 완료, 숨김 상태 */ Preloaded = 'preloaded', /** 리사이징 중 (2-phase: 측정 → resize → show) */ Resizing = 'resizing', /** 표시 중 */ Visible = 'visible', /** 숨김 */ Hidden = 'hidden', /** 파괴됨 */ Destroyed = 'destroyed', } /** 윈도우 설정 */ interface WindowOptions { readonly id: WindowId; /** HTML 파일 경로 (renderer 엔트리) */ readonly htmlPath: string; /** preload 스크립트 경로 */ readonly preloadPath: string; /** 초기 크기 */ readonly width: number; readonly height: number; /** 프레임 표시 여부 */ readonly frame: boolean; /** 리사이즈 가능 여부 */ readonly resizable: boolean; /** 항상 위 (팝업용) */ readonly alwaysOnTop: boolean; /** 태스크바 표시 여부 */ readonly skipTaskbar: boolean; /** 프리로드 활성화 */ readonly preload: boolean; /** 투명 배경 */ readonly transparent: boolean; } /** 윈도우별 기본 옵션 */ const WINDOW_DEFAULTS: Record = { [WindowId.Main]: { id: WindowId.Main, htmlPath: 'renderer/index.html', preloadPath: 'preload/index.js', width: 800, height: 600, frame: true, resizable: true, alwaysOnTop: false, skipTaskbar: false, preload: false, transparent: false, }, [WindowId.RecordingTip]: { id: WindowId.RecordingTip, htmlPath: 'renderer/popups/recording-tip.html', preloadPath: 'preload/popup.js', width: 200, height: 80, frame: false, resizable: false, alwaysOnTop: true, skipTaskbar: true, preload: true, transparent: true, }, [WindowId.ResultPopup]: { id: WindowId.ResultPopup, htmlPath: 'renderer/popups/result-popup.html', preloadPath: 'preload/popup.js', width: 400, height: 200, frame: false, resizable: false, alwaysOnTop: true, skipTaskbar: true, preload: true, transparent: true, }, [WindowId.Settings]: { id: WindowId.Settings, htmlPath: 'renderer/settings.html', preloadPath: 'preload/index.js', width: 700, height: 500, frame: true, resizable: true, alwaysOnTop: false, skipTaskbar: false, preload: false, transparent: false, }, }; /** 윈도우 정보 (외부 노출용) */ interface WindowInfo { readonly id: WindowId; readonly state: WindowState; readonly bounds: { x: number; y: number; width: number; height: number } | null; readonly isVisible: boolean; readonly isFocused: boolean; } /** 이벤트 페이로드 */ interface WindowManagerEvents { 'window-created': { id: WindowId }; 'window-shown': { id: WindowId; bounds: WindowInfo['bounds'] }; 'window-hidden': { id: WindowId }; 'window-closed': { id: WindowId }; 'window-resized': { id: WindowId; bounds: WindowInfo['bounds'] }; 'window-focused': { id: WindowId }; 'window-blurred': { id: WindowId }; 'all-windows-closed': Record; } ``` ### 인터페이스 ```typescript /** 표시 위치 옵션 */ interface ShowOptions { /** 표시 위치 (기본: 마지막 위치 또는 화면 중앙) */ position?: { x: number; y: number } | 'cursor' | 'center'; /** 크기 재조정 */ size?: { width: number; height: number }; /** 포커스 여부 */ focus?: boolean; } interface IWindowManagerService extends Disposable { /** * 윈도우 생성 (또는 프리로드된 윈도우 획득). * 이미 존재하면 기존 인스턴스 반환. */ getOrCreate(id: WindowId): Promise; /** * 윈도우 표시. * 2-phase 리사이즈: 콘텐츠 측정 → resize → show * 프리로드된 윈도우는 즉시 show. */ show(id: WindowId, options?: ShowOptions): Promise; /** 윈도우 숨기기 (파괴하지 않음) */ hide(id: WindowId): void; /** 윈도우 닫기 (파괴) */ close(id: WindowId): void; /** 윈도우 정보 조회 */ getInfo(id: WindowId): WindowInfo | null; /** 모든 윈도우 정보 */ getAllWindows(): WindowInfo[]; /** * 프리로드 시작. * 앱 시작 시 RecordingTip, ResultPopup을 미리 생성(hidden). */ preloadWindows(ids: WindowId[]): Promise; /** * 윈도우에 IPC 메시지 전송. * 팝업 윈도우(Vanilla JS)에 데이터 전달용. */ sendToWindow(id: WindowId, channel: string, ...args: unknown[]): void; on( event: K, listener: (payload: WindowManagerEvents[K]) => void, ): this; off( event: K, listener: (payload: WindowManagerEvents[K]) => void, ): this; } ``` ### 2-Phase 리사이즈 패턴 ```typescript // Speakly 패턴: 팝업 콘텐츠 크기에 맞춰 윈도우를 조정 // Phase 1 — 측정: IPC로 렌더러에 콘텐츠 크기 요청 // Phase 2 — 리사이즈 + 표시 async function show(id: WindowId, options?: ShowOptions): Promise { const win = await this.getOrCreate(id); // Phase 1: 콘텐츠 크기 측정 (팝업 윈도우만) if (id !== WindowId.Main && id !== WindowId.Settings) { const contentSize = await this.measureContent(win); win.setContentSize(contentSize.width, contentSize.height); } // 위치 결정 const position = this.resolvePosition(id, options?.position); win.setPosition(position.x, position.y); // Phase 2: 표시 win.show(); if (options?.focus !== false) { win.focus(); } this.windowStates.set(id, WindowState.Visible); this.emit('window-shown', { id, bounds: this.getBounds(win) }); } ``` ### 멀티모니터 지원 ```typescript // 윈도우가 화면 밖으로 나가지 않도록 보정 function clampToScreen(bounds: Rectangle): Rectangle { const displays = screen.getAllDisplays(); const display = screen.getDisplayNearestPoint({ x: bounds.x, y: bounds.y }); const { workArea } = display; return { x: Math.max(workArea.x, Math.min(bounds.x, workArea.x + workArea.width - bounds.width)), y: Math.max(workArea.y, Math.min(bounds.y, workArea.y + workArea.height - bounds.height)), width: Math.min(bounds.width, workArea.width), height: Math.min(bounds.height, workArea.height), }; } ``` --- ## 11. I18nService 다국어 지원을 담당한다. 시스템 언어를 감지하고 사용자 설정에 따라 UI 문자열을 제공한다. ```typescript interface II18nService extends Disposable { readonly currentLocale: 'ko' | 'en'; /** 번역 문자열 조회 */ t(key: string, params?: Record): string; /** 로케일 변경 */ setLocale(locale: 'ko' | 'en'): void; /** 지원 로케일 목록 */ getSupportedLocales(): Array<{ code: string; name: string }>; } ``` - 의존: ConfigService (로케일 설정 저장/읽기) - 번역 파일: `src/renderer/locales/{ko,en}.json` (JSON 키-값) - fallback: 키가 없으면 'ko' 기본값 반환 --- ## 12. LoggerService electron-log 래퍼. 카테고리별 로깅을 지원한다. ```typescript interface ILoggerService { /** 카테고리별 로거 생성 */ create(category: string): CategoryLogger; /** 로그 플러시 (종료 시) */ flush(): Promise; } 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; } ``` - 로그 파일: `{userData}/logs/d3ro-voice.log` - 로테이션: 파일 크기 5MB 초과 시 자동 교체, 최대 3개 보관 - 민감 정보 마스킹: 오디오 데이터, 전사 텍스트 본문은 로깅하지 않음 --- ## 13. DictionaryService 사용자 커스텀 단어 사전. STT 후처리에서 자동 교정에 사용한다. ```typescript interface IDictionaryService extends Disposable { /** 사전 항목 목록 조회 */ getAll(params?: { page: number; pageSize: number }): Promise; /** 항목 추가 */ add(word: string, pronunciation?: string, category?: string): Promise; /** 항목 삭제 */ delete(id: string): Promise; /** 항목 수정 */ update(id: string, data: Partial): Promise; /** 텍스트에 사전 교정 적용 */ applyCorrections(text: string): string; /** 가져오기/내보내기 */ importFromFile(filePath: string, format: 'json' | 'csv'): Promise<{ imported: number; skipped: number }>; exportToFile(format: 'json' | 'csv'): Promise; } ``` - 의존: LoggerService, DB (better-sqlite3) - DB 테이블: `dictionary` (03-db-and-ui.md 참조) --- ## 14. SoundEffectService 녹음 시작/종료/에러 효과음을 재생한다. ```typescript interface ISoundEffectService extends Disposable { /** 효과음 재생 (fire-and-forget) */ play(sound: 'recording-start' | 'recording-stop' | 'error' | 'cancel'): void; /** 효과음 활성화/비활성화 */ setEnabled(enabled: boolean): void; isEnabled(): boolean; } ``` - 의존: ConfigService (`soundEnabled` 설정) - 오디오 파일: `resources/sounds/*.wav` - 앱 시작 시 프리로드 (메모리 캐싱) --- ## 15. CustomInstructionService 사용자 정의 LLM 명령어를 관리한다. CRUD API를 제공한다. ```typescript interface CustomInstruction { id: string; name: string; prompt: string; icon?: string; createdAt: number; updatedAt: number; } interface ICustomInstructionService extends Disposable { getAll(): Promise; getById(id: string): Promise; save(instruction: Omit): Promise; update(id: string, data: Partial): Promise; delete(id: string): Promise; } ``` - 의존: ConfigService, LoggerService - 저장: electron-store 또는 별도 JSON 파일 (`{userData}/custom-instructions.json`) --- ## 16. AutoLaunchService 시스템 시작 시 자동 실행을 관리한다. ```typescript interface IAutoLaunchService extends Disposable { /** 자동 실행 상태 조회 */ isEnabled(): Promise; /** 자동 실행 활성화/비활성화 */ setEnabled(enabled: boolean): Promise; } ``` - 의존: ConfigService - Windows: 레지스트리 `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` 또는 `app.setLoginItemSettings()` - Electron API: `app.setLoginItemSettings({ openAtLogin: true })` --- ## 서비스 간 의존 관계 ``` ConfigService (독립) │ ├──► AudioCaptureService ├──► HotkeyService ├──► LocalSTTService ├──► LocalTTSService ├──► LocalLLMService ├──► TextInsertService ├──► WindowManagerService └──► HistoryService VoiceModeService (오케스트레이터) ├──► AudioCaptureService ├──► LocalSTTService ├──► LocalLLMService ├──► TextInsertService ├──► HistoryService └──► WindowManagerService (RecordingTip, ResultPopup) ``` 모든 서비스는 ConfigService에 의존하여 설정을 읽는다. VoiceModeService는 파이프라인 서비스들을 오케스트레이션하는 유일한 조합점이다.