- 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개 문서
1834 lines
52 KiB
Markdown
1834 lines
52 KiB
Markdown
# 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<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의 상태 머신 및 이중 조건 플러시 참조.
|
|
|
|
### 타입 정의
|
|
|
|
```typescript
|
|
/** 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 };
|
|
}
|
|
```
|
|
|
|
### 인터페이스
|
|
|
|
```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<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`로 수행한다.
|
|
|
|
### 이중 조건 플러시 패턴
|
|
|
|
```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<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과 상호작용한다.
|
|
|
|
### 타입 정의
|
|
|
|
```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<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;
|
|
}
|
|
```
|
|
|
|
### 스트리밍 응답 처리
|
|
|
|
```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<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의 전체 오케스트레이션 패턴 참조.
|
|
|
|
### 타입 정의
|
|
|
|
```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<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 패턴)
|
|
|
|
```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<string, never>;
|
|
'clipboard-restored': Record<string, never>;
|
|
}
|
|
```
|
|
|
|
### 인터페이스
|
|
|
|
```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<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;
|
|
}
|
|
```
|
|
|
|
### 삽입 흐름 의사코드
|
|
|
|
```typescript
|
|
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 및 더블프레스 감지 참조.
|
|
|
|
### 타입 정의
|
|
|
|
```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<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;
|
|
}
|
|
```
|
|
|
|
### 더블프레스 감지 로직
|
|
|
|
```typescript
|
|
// 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 연동
|
|
|
|
```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<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 스키마 참조.
|
|
|
|
### 타입 정의
|
|
|
|
```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<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),
|
|
// });
|
|
```
|
|
|
|
### 인터페이스
|
|
|
|
```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, 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>;
|
|
}
|
|
```
|
|
|
|
### 인터페이스
|
|
|
|
```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<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 리사이즈 패턴
|
|
|
|
```typescript
|
|
// 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) });
|
|
}
|
|
```
|
|
|
|
### 멀티모니터 지원
|
|
|
|
```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, 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 래퍼. 카테고리별 로깅을 지원한다.
|
|
|
|
```typescript
|
|
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 후처리에서 자동 교정에 사용한다.
|
|
|
|
```typescript
|
|
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
|
|
|
|
녹음 시작/종료/에러 효과음을 재생한다.
|
|
|
|
```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<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
|
|
|
|
시스템 시작 시 자동 실행을 관리한다.
|
|
|
|
```typescript
|
|
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는 파이프라인 서비스들을 오케스트레이션하는 유일한 조합점이다.
|