d3ro-voice/docs/design/01-service-specifications.md
Yun Chan e24bb8378c 초기 프로젝트 설정: 하네스 시스템 + 설계서 + RE 노하우
- CLAUDE.md: 프로젝트 규칙, 기술 스택, 코딩 규칙, 페이즈 로드맵
- .claude/settings.json: 권한, 강제 훅 (매 프롬프트 설계서 규칙 주입)
- .claude/skills/: implement-phase, review-phase, scaffold, test-commit, debug
- .claude/agents/: electron-architect, voice-pipeline-expert, ui-specialist
- docs/design/00-09: 마스터 아키텍처, 서비스 명세(16개), IPC(113채널),
  DB스키마, UI컴포넌트, 검증리포트, 외부엔진연동, 갭분석,
  VoiceMode패턴, 디자인시스템, 히스토리팝업
- docs/phases/1-7+3.5: 전체 구현 페이즈 문서
- docs/re-findings/: Speakly RE 노하우 5개 문서
2026-04-05 01:03:03 +09:00

52 KiB

D3RO-VOICE 서비스 상세 명세서

목차

  1. 공통 타입
  2. AudioCaptureService
  3. LocalSTTService
  4. LocalTTSService
  5. LocalLLMService
  6. VoiceModeService
  7. TextInsertService
  8. HotkeyService
  9. ConfigService
  10. HistoryService
  11. WindowManagerService

공통 타입

/** 모든 서비스 에러에 사용되는 에러 코드 */
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의 구독/레퍼런스 카운팅 참조.

타입 정의

/** 오디오 포맷 상수 */
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 };
}

인터페이스

interface IAudioCaptureService extends Disposable {
  /** 현재 캡처 상태 */
  readonly state: CaptureState;
  /** 현재 사용 중인 디바이스 */
  readonly currentDevice: AudioDevice | null;

  /**
   * 마이크 캡처 시작.
   * deviceId 미지정 시 시스템 기본 디바이스 사용.
   * 이미 캡처 중이면 무시 (레퍼런스 카운팅).
   */
  start(deviceId?: string): Promise<void>;

  /** 캡처 중지. 레퍼런스 카운트가 0이 되면 실제 중지. */
  stop(): Promise<void>;

  /** 사용 가능한 입력 디바이스 목록 */
  getDevices(): Promise<AudioDevice[]>;

  /** 현재 디바이스 조회 */
  getCurrentDevice(): AudioDevice | null;

  /** 이벤트 등록 */
  on<K extends keyof AudioCaptureEvents>(
    event: K,
    listener: (payload: AudioCaptureEvents[K]) => void,
  ): this;
  off<K extends keyof AudioCaptureEvents>(
    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의 상태 머신 및 이중 조건 플러시 참조.

타입 정의

/** STT 모델 정보 */
interface STTModel {
  readonly id: string;           // 예: 'base', 'small', 'medium', 'large-v3'
  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 };
}

인터페이스

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<void>;

  /**
   * 오디오 버퍼를 전사.
   * PCM16 16kHz mono 포맷이어야 한다.
   * 이중 조건 플러시: 모델 로딩과 오디오 버퍼링이 모두 완료되면 실행.
   */
  transcribe(audioBuffer: Buffer, options?: TranscribeOptions): Promise<TranscriptionResult>;

  /** 다운로드된 모델 목록 조회 */
  getModels(): Promise<STTModel[]>;

  /** 현재 상태 조회 */
  getStatus(): { state: STTState; modelId: string | null; uptime: number };

  on<K extends keyof LocalSTTEvents>(
    event: K,
    listener: (payload: LocalSTTEvents[K]) => void,
  ): this;
  off<K extends keyof LocalSTTEvents>(
    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로 수행한다.

이중 조건 플러시 패턴

// 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 프로세스에서 호스팅된다.

타입 정의

/** 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 };
}

인터페이스

interface ILocalTTSService extends Disposable {
  readonly state: TTSState;

  /**
   * 텍스트를 음성으로 합성하여 재생.
   * Kokoro (오프라인) → edge-tts (온라인 폴백) 순으로 시도.
   */
  speak(text: string, voiceId?: string, options?: TTSOptions): Promise<void>;

  /** 사용 가능한 음성 목록 */
  getVoices(): Promise<TTSVoice[]>;

  /** 현재 재생 중지 */
  stop(): void;

  on<K extends keyof LocalTTSEvents>(
    event: K,
    listener: (payload: LocalTTSEvents[K]) => void,
  ): this;
  off<K extends keyof LocalTTSEvents>(
    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과 상호작용한다.

타입 정의

/** 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 };
}

인터페이스

interface ILocalLLMService extends Disposable {
  readonly state: LLMState;

  /**
   * 텍스트 생성 (비스트리밍).
   * Ollama /api/generate 엔드포인트 사용.
   */
  generate(prompt: string, options?: GenerateOptions): Promise<GenerateResult>;

  /**
   * 스트리밍 텍스트 생성.
   * 'token' 이벤트를 통해 점진적으로 토큰 전달.
   * AbortController로 취소 가능.
   */
  stream(prompt: string, options?: Omit<GenerateOptions, 'stream'>): AbortController;

  /** 사용 가능한 모델 목록 (Ollama에서 조회) */
  getModels(): Promise<LLMModel[]>;

  /** Ollama 가용 여부 */
  isAvailable(): boolean;

  on<K extends keyof LocalLLMEvents>(
    event: K,
    listener: (payload: LocalLLMEvents[K]) => void,
  ): this;
  off<K extends keyof LocalLLMEvents>(
    event: K,
    listener: (payload: LocalLLMEvents[K]) => void,
  ): this;
}

스트리밍 응답 처리

// 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<string> {
  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의 전체 오케스트레이션 패턴 참조.

타입 정의

/** 음성 인식 상태 (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 };
}

인터페이스

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<void>;

  /** 음성 세션 종료 (핫키 놓을 때 / 다시 누를 때) */
  stopSession(): Promise<void>;

  /** 세션 취소 */
  cancelSession(): void;

  on<K extends keyof VoiceModeEvents>(
    event: K,
    listener: (payload: VoiceModeEvents[K]) => void,
  ): this;
  off<K extends keyof VoiceModeEvents>(
    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 패턴)

// 이중 조건 플러시: 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);
}

타이밍 상수

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 감지

키 누름 시간이 짧아 의도치 않은 입력으로 판정되면 세션을 즉시 취소한다.

// 키 릴리스 시점에 호출
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 연동

녹음 중 시스템 오디오를 음소거하여 마이크 피드백을 방지한다.

// 녹음 시작 시 시퀀스:
// 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을 방지하기 위해 이벤트를 큐에 넣고 순차 처리한다.

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 참조.

타입 정의

/** 텍스트 삽입 전략 */
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<string, never>;
  'clipboard-restored': Record<string, never>;
}

인터페이스

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<InsertResult>;

  /** 현재 클립보드 상태 캡처 */
  getClipboardState(): ClipboardSnapshot;

  /** 클립보드 내용 저장 (수동) */
  saveClipboard(): ClipboardSnapshot;

  /** 저장된 클립보드 내용 복원 (수동) */
  restoreClipboard(snapshot: ClipboardSnapshot): void;

  on<K extends keyof TextInsertEvents>(
    event: K,
    listener: (payload: TextInsertEvents[K]) => void,
  ): this;
  off<K extends keyof TextInsertEvents>(
    event: K,
    listener: (payload: TextInsertEvents[K]) => void,
  ): this;
}

삽입 흐름 의사코드

async function insertText(text: string, options: InsertTextOptions): Promise<InsertResult> {
  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 및 더블프레스 감지 참조.

타입 정의

/** 키 코드 (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 };
}

인터페이스

interface IHotkeyService extends Disposable {
  readonly isRunning: boolean;
  readonly registeredHotkeys: ReadonlyMap<string, HotkeyConfig>;

  /** uiohook 시작 (글로벌 키보드 후킹) */
  start(): void;

  /** uiohook 중지 */
  stop(): void;

  /** 핫키 등록/갱신 */
  registerHotkey(config: HotkeyConfig): void;

  /** 핫키 해제 */
  unregisterHotkey(id: string): void;

  /** 사용 가능한 키 목록 (UI 바인딩용) */
  getAvailableKeys(): Array<{ code: KeyCode; label: string }>;

  on<K extends keyof HotkeyEvents>(
    event: K,
    listener: (payload: HotkeyEvents[K]) => void,
  ): this;
  off<K extends keyof HotkeyEvents>(
    event: K,
    listener: (payload: HotkeyEvents[K]) => void,
  ): this;
}

더블프레스 감지 로직

// Speakly 패턴: 300ms 이내 연속 두 번 press → 더블프레스
// LLKHF_INJECTED 바이패스: 자체 시뮬레이션 키는 무시

private lastPressTime: Map<string, number> = 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 연동

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 기반 설정 관리. 섹션별 키/값/기본값 전체 정의.

타입 정의

/** 오디오 설정 */
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;
  };
}

인터페이스

interface IConfigService extends Disposable {
  /**
   * 설정값 읽기.
   * 점 표기법 지원: get('audio.inputGain')
   */
  get<K extends keyof AppConfig>(section: K): AppConfig[K];
  get<T = unknown>(key: string): T;

  /**
   * 설정값 쓰기.
   * 점 표기법 지원: set('audio.inputGain', 1.5)
   */
  set<K extends keyof AppConfig>(section: K, value: AppConfig[K]): void;
  set(key: string, value: unknown): void;

  /** 특정 섹션을 기본값으로 리셋 */
  reset<K extends keyof AppConfig>(section: K): void;

  /** 전체 설정을 기본값으로 리셋 */
  resetAll(): void;

  /** 전체 설정 조회 (읽기 전용 복사본) */
  getAll(): Readonly<AppConfig>;

  on<K extends keyof ConfigEvents>(
    event: K,
    listener: (payload: ConfigEvents[K]) => void,
  ): this;
  off<K extends keyof ConfigEvents>(
    event: K,
    listener: (payload: ConfigEvents[K]) => void,
  ): this;
}

9. HistoryService

음성 인식 히스토리를 SQLite에 저장하고 관리한다. better-sqlite3 + drizzle-orm. Speakly HistoryService 스키마 참조.

타입 정의

/** 히스토리 항목 */
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<string, number>;
  readonly entriesByPostProcess: Record<string, number>;
  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),
// });

인터페이스

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개 윈도우 관리 패턴 참조.

타입 정의

/** 윈도우 식별자 */
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, WindowOptions> = {
  [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<string, never>;
}

인터페이스

/** 표시 위치 옵션 */
interface ShowOptions {
  /** 표시 위치 (기본: 마지막 위치 또는 화면 중앙) */
  position?: { x: number; y: number } | 'cursor' | 'center';
  /** 크기 재조정 */
  size?: { width: number; height: number };
  /** 포커스 여부 */
  focus?: boolean;
}

interface IWindowManagerService extends Disposable {
  /**
   * 윈도우 생성 (또는 프리로드된 윈도우 획득).
   * 이미 존재하면 기존 인스턴스 반환.
   */
  getOrCreate(id: WindowId): Promise<Electron.BrowserWindow>;

  /**
   * 윈도우 표시.
   * 2-phase 리사이즈: 콘텐츠 측정 → resize → show
   * 프리로드된 윈도우는 즉시 show.
   */
  show(id: WindowId, options?: ShowOptions): Promise<void>;

  /** 윈도우 숨기기 (파괴하지 않음) */
  hide(id: WindowId): void;

  /** 윈도우 닫기 (파괴) */
  close(id: WindowId): void;

  /** 윈도우 정보 조회 */
  getInfo(id: WindowId): WindowInfo | null;

  /** 모든 윈도우 정보 */
  getAllWindows(): WindowInfo[];

  /**
   * 프리로드 시작.
   * 앱 시작 시 RecordingTip, ResultPopup을 미리 생성(hidden).
   */
  preloadWindows(ids: WindowId[]): Promise<void>;

  /**
   * 윈도우에 IPC 메시지 전송.
   * 팝업 윈도우(Vanilla JS)에 데이터 전달용.
   */
  sendToWindow(id: WindowId, channel: string, ...args: unknown[]): void;

  on<K extends keyof WindowManagerEvents>(
    event: K,
    listener: (payload: WindowManagerEvents[K]) => void,
  ): this;
  off<K extends keyof WindowManagerEvents>(
    event: K,
    listener: (payload: WindowManagerEvents[K]) => void,
  ): this;
}

2-Phase 리사이즈 패턴

// Speakly 패턴: 팝업 콘텐츠 크기에 맞춰 윈도우를 조정
// Phase 1 — 측정: IPC로 렌더러에 콘텐츠 크기 요청
// Phase 2 — 리사이즈 + 표시

async function show(id: WindowId, options?: ShowOptions): Promise<void> {
  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) });
}

멀티모니터 지원

// 윈도우가 화면 밖으로 나가지 않도록 보정
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 문자열을 제공한다.

interface II18nService extends Disposable {
  readonly currentLocale: 'ko' | 'en';

  /** 번역 문자열 조회 */
  t(key: string, params?: Record<string, string>): 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 래퍼. 카테고리별 로깅을 지원한다.

interface ILoggerService {
  /** 카테고리별 로거 생성 */
  create(category: string): CategoryLogger;

  /** 로그 플러시 (종료 시) */
  flush(): Promise<void>;
}

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 후처리에서 자동 교정에 사용한다.

interface IDictionaryService extends Disposable {
  /** 사전 항목 목록 조회 */
  getAll(params?: { page: number; pageSize: number }): Promise<DictionaryPage>;

  /** 항목 추가 */
  add(word: string, pronunciation?: string, category?: string): Promise<Dictionary>;

  /** 항목 삭제 */
  delete(id: string): Promise<void>;

  /** 항목 수정 */
  update(id: string, data: Partial<Dictionary>): Promise<Dictionary>;

  /** 텍스트에 사전 교정 적용 */
  applyCorrections(text: string): string;

  /** 가져오기/내보내기 */
  importFromFile(filePath: string, format: 'json' | 'csv'): Promise<{ imported: number; skipped: number }>;
  exportToFile(format: 'json' | 'csv'): Promise<string>;
}
  • 의존: LoggerService, DB (better-sqlite3)
  • DB 테이블: dictionary (03-db-and-ui.md 참조)

14. SoundEffectService

녹음 시작/종료/에러 효과음을 재생한다.

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를 제공한다.

interface CustomInstruction {
  id: string;
  name: string;
  prompt: string;
  icon?: string;
  createdAt: number;
  updatedAt: number;
}

interface ICustomInstructionService extends Disposable {
  getAll(): Promise<CustomInstruction[]>;
  getById(id: string): Promise<CustomInstruction | null>;
  save(instruction: Omit<CustomInstruction, 'id' | 'createdAt' | 'updatedAt'>): Promise<CustomInstruction>;
  update(id: string, data: Partial<CustomInstruction>): Promise<CustomInstruction>;
  delete(id: string): Promise<void>;
}
  • 의존: ConfigService, LoggerService
  • 저장: electron-store 또는 별도 JSON 파일 ({userData}/custom-instructions.json)

16. AutoLaunchService

시스템 시작 시 자동 실행을 관리한다.

interface IAutoLaunchService extends Disposable {
  /** 자동 실행 상태 조회 */
  isEnabled(): Promise<boolean>;

  /** 자동 실행 활성화/비활성화 */
  setEnabled(enabled: boolean): Promise<void>;
}
  • 의존: 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는 파이프라인 서비스들을 오케스트레이션하는 유일한 조합점이다.