- 기본 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 통과
52 KiB
D3RO-VOICE 서비스 상세 명세서
목차
- 공통 타입
- AudioCaptureService
- LocalSTTService
- LocalTTSService
- LocalLLMService
- VoiceModeService
- TextInsertService
- HotkeyService
- ConfigService
- HistoryService
- 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', '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 };
}
인터페이스
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는 파이프라인 서비스들을 오케스트레이션하는 유일한 조합점이다.