d3ro-voice/docs/design/05-external-engine-integration.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

1158 lines
44 KiB
Markdown

# D3RO-VOICE 외부 AI 엔진 연동 상세 설계서
> 버전: 1.0
> 작성일: 2026-04-04
> 기반: 01-service-specifications.md의 LocalSTT, LocalLLM, LocalTTS 서비스 명세
---
## 목차
1. [faster-whisper STT Sidecar](#1-faster-whisper-stt-sidecar)
2. [Ollama LLM REST API](#2-ollama-llm-rest-api)
3. [TTS 엔진 (Kokoro + edge-tts)](#3-tts-엔진-kokoro--edge-tts)
4. [통합 시퀀스 다이어그램](#4-통합-시퀀스-다이어그램)
5. [에러 시나리오 & 복구 전략](#5-에러-시나리오--복구-전략)
6. [모델 추천 & 한국어 성능](#6-모델-추천--한국어-성능)
---
## 1. faster-whisper STT Sidecar
### 1.1 아키텍처 결정: Python HTTP Sidecar
**선택: 로컬 HTTP 서버 (FastAPI)**
| 방식 | 장점 | 단점 | 판정 |
|------|------|------|------|
| stdin/stdout JSON Lines | 단순, 의존성 없음 | 바이너리(오디오) 전송 비효율, 에러 핸들링 복잡 | △ |
| 로컬 HTTP 서버 | 표준 프로토콜, 멀티파트 오디오 전송, health check 용이 | 포트 관리 필요 | **◎ 채택** |
| WebSocket | 양방향 스트리밍 | 과도한 복잡성 | △ |
**채택 근거:**
- HTTP는 Electron의 `fetch()` API와 자연스럽게 호환
- health check (`GET /health`)가 HTTP 수준에서 가능
- 오디오 데이터를 multipart/form-data로 효율적 전송
- 향후 remote-whisper (GPU 서버)로 전환 시 코드 변경 최소화
### 1.2 Sidecar Python 서버 설계
```python
# sidecar/whisper_server.py
from fastapi import FastAPI, UploadFile, File
from faster_whisper import WhisperModel
import uvicorn
import sys
import json
app = FastAPI()
model: WhisperModel | None = None
@app.get("/health")
async def health():
return {
"status": "ready" if model else "loading",
"model": current_model_id,
"device": current_device
}
@app.post("/load")
async def load_model(body: dict):
"""모델 로드/교체"""
global model, current_model_id
model_id = body["model_id"] # "base", "small", "medium", "large-v3"
device = body.get("device", "auto") # "auto", "cpu", "cuda"
compute_type = body.get("compute_type", "int8")
model = WhisperModel(
model_id,
device=device,
compute_type=compute_type,
download_root=body.get("model_dir", "./models")
)
current_model_id = model_id
return {"status": "loaded", "model_id": model_id}
@app.post("/transcribe")
async def transcribe(
audio: UploadFile = File(...),
language: str = "auto",
initial_prompt: str = "",
vad_filter: bool = True
):
"""PCM16 16kHz mono 오디오를 전사"""
import numpy as np
import io
audio_bytes = await audio.read()
audio_np = np.frombuffer(audio_bytes, dtype=np.int16).astype(np.float32) / 32768.0
segments, info = model.transcribe(
audio_np,
beam_size=5,
language=None if language == "auto" else language,
initial_prompt=initial_prompt or None,
vad_filter=vad_filter,
vad_parameters=dict(min_silence_duration_ms=500),
word_timestamps=False
)
result_segments = []
full_text = ""
for seg in segments:
result_segments.append({
"text": seg.text,
"start": seg.start,
"end": seg.end,
"confidence": seg.avg_log_prob
})
full_text += seg.text
return {
"text": full_text.strip(),
"segments": result_segments,
"language": info.language,
"language_probability": info.language_probability,
"duration": info.duration
}
@app.post("/shutdown")
async def shutdown():
"""Graceful shutdown"""
import asyncio
asyncio.get_event_loop().call_later(0.5, sys.exit, 0)
return {"status": "shutting_down"}
if __name__ == "__main__":
port = int(sys.argv[1]) if len(sys.argv) > 1 else 0 # 0 = 랜덤 포트
uvicorn.run(app, host="127.0.0.1", port=port)
```
### 1.3 Electron Main Process에서 Sidecar 관리
```typescript
// src/main/services/stt/WhisperSidecar.ts
import { spawn, ChildProcess } from 'child_process';
import { EventEmitter } from 'events';
import getPort from 'get-port';
interface SidecarOptions {
pythonPath: string; // 내장 Python 또는 시스템 Python
scriptPath: string; // sidecar/whisper_server.py 경로
modelDir: string; // 모델 다운로드 디렉토리
}
class WhisperSidecar extends EventEmitter {
private process: ChildProcess | null = null;
private port: number = 0;
private baseUrl: string = '';
async start(options: SidecarOptions): Promise<void> {
this.port = await getPort(); // 사용 가능한 랜덤 포트 확보
this.process = spawn(options.pythonPath, [
options.scriptPath,
String(this.port),
], {
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, PYTHONUNBUFFERED: '1' },
});
this.baseUrl = `http://127.0.0.1:${this.port}`;
// stderr 로깅
this.process.stderr?.on('data', (data: Buffer) => {
const msg = data.toString();
// uvicorn 시작 로그에서 포트 확인
if (msg.includes('Uvicorn running')) {
this.emit('ready');
}
});
// 크래시 감지 & 자동 재시작
this.process.on('exit', (code, signal) => {
if (code !== 0 && signal !== 'SIGTERM') {
this.emit('crash', { code, signal });
this.scheduleRestart(options);
}
});
// 시작 후 health check 폴링
await this.waitForReady(10_000);
}
private async waitForReady(timeoutMs: number): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const res = await fetch(`${this.baseUrl}/health`);
if (res.ok) return;
} catch { /* 아직 시작 안 됨 */ }
await new Promise(r => setTimeout(r, 200));
}
throw new Error('Whisper sidecar failed to start within timeout');
}
private restartCount = 0;
private scheduleRestart(options: SidecarOptions): void {
if (this.restartCount >= 3) {
this.emit('error', { code: 'STT_SIDECAR_CRASH', message: 'Sidecar crashed 3 times' });
return;
}
this.restartCount++;
const delay = Math.min(1000 * Math.pow(2, this.restartCount), 10_000);
setTimeout(() => this.start(options), delay);
}
async loadModel(modelId: string, device = 'auto'): Promise<void> {
const res = await fetch(`${this.baseUrl}/load`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model_id: modelId, device }),
});
if (!res.ok) throw new Error(`Model load failed: ${res.statusText}`);
}
async transcribe(audioBuffer: Buffer, options: {
language?: string;
initialPrompt?: string;
vadFilter?: boolean;
} = {}): Promise<TranscriptionResult> {
const formData = new FormData();
formData.append('audio', new Blob([audioBuffer]), 'audio.pcm');
if (options.language) formData.append('language', options.language);
if (options.initialPrompt) formData.append('initial_prompt', options.initialPrompt);
formData.append('vad_filter', String(options.vadFilter ?? true));
const res = await fetch(`${this.baseUrl}/transcribe`, {
method: 'POST',
body: formData,
});
if (!res.ok) throw new Error(`Transcription failed: ${res.statusText}`);
return res.json();
}
async shutdown(): Promise<void> {
try {
await fetch(`${this.baseUrl}/shutdown`, { method: 'POST' });
} catch { /* 이미 종료됨 */ }
this.process?.kill('SIGTERM');
this.process = null;
}
}
```
### 1.4 모델 다운로드 & 관리
faster-whisper는 Hugging Face Hub에서 CTranslate2 변환 모델을 자동 다운로드한다.
```typescript
// 모델 크기별 정보
const WHISPER_MODELS = {
'tiny': { size: 75_000_000, vram: 1_000, rtf_cpu: 6.0 },
'base': { size: 145_000_000, vram: 1_000, rtf_cpu: 4.0 },
'small': { size: 484_000_000, vram: 2_000, rtf_cpu: 2.0 },
'medium': { size: 1_530_000_000, vram: 5_000, rtf_cpu: 1.0 },
'large-v3': { size: 3_090_000_000, vram: 10_000, rtf_cpu: 0.5 },
'turbo': { size: 1_620_000_000, vram: 6_000, rtf_cpu: 0.8 },
} as const;
// 모델 디렉토리: {app.getPath('userData')}/models/whisper/
// faster-whisper가 download_root 옵션으로 자동 관리
// 다운로드 진행률은 Python 측에서 huggingface_hub 콜백 사용
```
**모델 다운로드 진행률 전달:**
```python
# sidecar에 다운로드 전용 엔드포인트 추가
@app.post("/download")
async def download_model(body: dict):
"""모델 다운로드 (SSE로 진행률 스트리밍)"""
from starlette.responses import StreamingResponse
async def progress_stream():
from huggingface_hub import snapshot_download
import threading
# snapshot_download는 동기이므로 스레드에서 실행
# tqdm 콜백 훅으로 진행률 전달
yield json.dumps({"status": "downloading", "progress": 0}) + "\n"
snapshot_download(
f"Systran/faster-whisper-{body['model_id']}",
local_dir=f"./models/faster-whisper-{body['model_id']}"
)
yield json.dumps({"status": "complete", "progress": 100}) + "\n"
return StreamingResponse(progress_stream(), media_type="application/x-ndjson")
```
### 1.5 실시간 전사 (transcription_delta) 가능 여부
**결론: 제한적으로 가능**
faster-whisper 자체는 진정한 스트리밍을 지원하지 않는다. `transcribe()`는 전체 오디오를 받아 세그먼트를 generator로 반환하지만, 각 세그먼트는 30초 윈도우 단위로 처리된다.
**의사-스트리밍 구현 전략:**
```
[오디오 버퍼 누적 중]
├── 매 2-3초마다 현재까지의 버퍼로 transcribe → delta 이벤트
│ (마지막 세그먼트는 불완전할 수 있으므로 isFinal: false)
└── 녹음 종료 시 전체 버퍼로 최종 transcribe → isFinal: true
```
이 접근은 WhisperLive/whisper_streaming 프로젝트가 사용하는 패턴과 동일하다. 지연시간은 세그먼트 단위(~2-5초)이며, 실시간 자막 수준의 즉각적 피드백은 아니다.
**D3RO-VOICE에서의 실용적 선택:**
- 녹음 완료 후 일괄 전사 (기본 모드) — 가장 정확
- 녹음 중 중간 델타 표시 (옵션) — UI 피드백용, 최종 결과와 다를 수 있음
### 1.6 대안 분석
#### whisper.cpp Node.js 바인딩
| 패키지 | 최종 업데이트 | 특징 | 판정 |
|--------|-------------|------|------|
| `@fugood/whisper.node` | 2026-03 | 활발한 유지보수, whisper.rn과 API 호환 | 후보 |
| `whisper-node` | 2023-11 | 방치됨 | ✗ |
| `nodejs-whisper` | 2025-05 | CLI 래퍼, 프로세스 스폰 | △ |
| `smart-whisper` | 2025 | 모델 자동 오프로딩 | △ |
**`@fugood/whisper.node` 장점:** Python 의존성 제거, Electron 네이티브 모듈로 직접 통합
**단점:** GPU 가속 제한적 (CUDA 지원은 빌드 필요), faster-whisper 대비 성능 약간 열세
**권장:** MVP에서는 faster-whisper Python sidecar 사용, 향후 `@fugood/whisper.node`로 마이그레이션 검토
#### Ollama에 Whisper 로드 가능 여부
**불가능.** Ollama는 텍스트 생성 LLM 전용이며, 오디오→텍스트 모달리티를 지원하지 않는다.
---
## 2. Ollama LLM REST API
### 2.1 엔드포인트 전체 명세
기본 URL: `http://localhost:11434`
#### POST /api/generate — 텍스트 생성
```typescript
// 요청
interface OllamaGenerateRequest {
model: string; // 필수: "qwen3:4b"
prompt: string; // 필수
suffix?: string; // FIM (Fill-in-Middle) 지원 모델용
system?: string; // 시스템 프롬프트
format?: 'json' | object; // JSON 모드 또는 JSON schema
stream?: boolean; // 기본 true
options?: {
temperature?: number; // 0.0~2.0, 기본 0.8
top_p?: number; // 0.0~1.0
top_k?: number; // 기본 40
num_predict?: number; // 최대 토큰 수, 기본 -1(무제한)
stop?: string[]; // stop 시퀀스
};
keep_alive?: string; // 모델 메모리 유지: "5m", "0"(즉시 해제)
}
// 스트리밍 응답 (NDJSON, 각 줄이 JSON 객체)
interface OllamaGenerateStreamChunk {
model: string;
created_at: string; // ISO 8601
response: string; // 생성된 토큰
done: boolean;
}
// 최종 응답 (done: true일 때 추가 필드)
interface OllamaGenerateFinalChunk extends OllamaGenerateStreamChunk {
done: true;
total_duration: number; // 나노초
load_duration: number; // 나노초
prompt_eval_count: number; // 프롬프트 토큰 수
prompt_eval_duration: number;// 나노초
eval_count: number; // 생성 토큰 수
eval_duration: number; // 나노초
context: number[]; // 컨텍스트 토큰 (후속 요청에 재사용 가능)
}
```
#### POST /api/chat — 대화형 생성
```typescript
interface OllamaChatRequest {
model: string;
messages: Array<{
role: 'system' | 'user' | 'assistant';
content: string;
images?: string[]; // base64 이미지 (멀티모달 모델)
}>;
stream?: boolean;
format?: 'json' | object;
tools?: OllamaTool[]; // 함수 호출
options?: OllamaModelOptions;
keep_alive?: string;
}
// 스트리밍 응답
interface OllamaChatStreamChunk {
model: string;
created_at: string;
message: {
role: 'assistant';
content: string; // 토큰 조각
};
done: boolean;
}
```
#### GET /api/tags — 로컬 모델 목록
```typescript
// GET http://localhost:11434/api/tags
interface OllamaTagsResponse {
models: Array<{
name: string; // "qwen3:4b"
model: string;
modified_at: string; // ISO 8601
size: number; // 바이트
digest: string; // SHA256
details: {
parent_model: string;
format: string; // "gguf"
family: string; // "qwen3"
parameter_size: string; // "4B"
quantization_level: string; // "Q4_K_M"
};
}>;
}
```
#### POST /api/pull — 모델 다운로드
```typescript
// 요청
interface OllamaPullRequest {
name: string; // "qwen3:4b"
stream?: boolean; // 기본 true
}
// 스트리밍 응답 (진행률)
interface OllamaPullStreamChunk {
status: string; // "pulling manifest", "downloading sha256:...", "success"
digest?: string;
total?: number; // 전체 바이트
completed?: number; // 완료 바이트
}
```
#### POST /api/show — 모델 정보
```typescript
// POST http://localhost:11434/api/show
// 요청: { "name": "qwen3:4b" }
interface OllamaShowResponse {
modelfile: string;
parameters: string;
template: string;
details: {
parent_model: string;
format: string;
family: string;
parameter_size: string;
quantization_level: string;
};
model_info: Record<string, unknown>;
}
```
#### DELETE /api/delete — 모델 삭제
```typescript
// DELETE http://localhost:11434/api/delete
// 요청: { "name": "qwen3:4b" }
// 응답: 200 OK (성공) | 404 Not Found
```
### 2.2 Node.js 클라이언트 구현
```typescript
// src/main/services/llm/OllamaClient.ts
const OLLAMA_BASE = 'http://localhost:11434';
class OllamaClient {
private abortController: AbortController | null = null;
/** Ollama 서버 가용성 확인 */
async checkAvailability(): Promise<boolean> {
try {
const res = await fetch(`${OLLAMA_BASE}/api/tags`, {
signal: AbortSignal.timeout(3000),
});
return res.ok;
} catch {
return false;
}
}
/** 모델 목록 조회 */
async getModels(): Promise<OllamaTagsResponse> {
const res = await fetch(`${OLLAMA_BASE}/api/tags`);
if (!res.ok) throw this.toServiceError(res);
return res.json();
}
/** 비스트리밍 생성 */
async generate(prompt: string, options: GenerateOptions = {}): Promise<GenerateResult> {
const res = await fetch(`${OLLAMA_BASE}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: options.model ?? 'qwen3:4b',
prompt,
system: options.systemPrompt,
stream: false,
options: {
temperature: options.temperature ?? 0.7,
num_predict: options.maxTokens ?? 2048,
top_p: options.topP,
top_k: options.topK,
},
}),
});
if (!res.ok) throw this.toServiceError(res);
const data = await res.json();
return {
text: data.response,
model: data.model,
promptTokens: data.prompt_eval_count ?? 0,
completionTokens: data.eval_count ?? 0,
totalDuration: Math.round((data.total_duration ?? 0) / 1_000_000), // ns → ms
};
}
/** 스트리밍 생성 */
stream(
prompt: string,
options: Omit<GenerateOptions, 'stream'>,
onToken: (token: string, done: boolean) => void,
): AbortController {
const controller = new AbortController();
this.abortController = controller;
const run = async () => {
const res = await fetch(`${OLLAMA_BASE}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: options.model ?? 'qwen3:4b',
prompt,
system: options.systemPrompt,
stream: true,
options: {
temperature: options.temperature ?? 0.7,
num_predict: options.maxTokens ?? 2048,
},
}),
signal: controller.signal,
});
if (!res.ok) throw this.toServiceError(res);
const reader = res.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) as OllamaGenerateStreamChunk;
onToken(chunk.response, chunk.done);
}
}
};
run().catch(err => {
if (err.name !== 'AbortError') {
onToken('', true); // 에러 시에도 done 신호
}
});
return controller;
}
/** 모델 다운로드 (진행률 콜백) */
async pullModel(
name: string,
onProgress: (status: string, completed: number, total: number) => void,
): Promise<void> {
const res = await fetch(`${OLLAMA_BASE}/api/pull`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, stream: true }),
});
const reader = res.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) as OllamaPullStreamChunk;
onProgress(chunk.status, chunk.completed ?? 0, chunk.total ?? 0);
}
}
}
/** 현재 스트리밍 취소 */
abort(): void {
this.abortController?.abort();
this.abortController = null;
}
private toServiceError(res: Response): ServiceError {
return {
code: res.status === 404 ? 'LLM_MODEL_NOT_FOUND' : 'LLM_CONNECTION_FAILED',
message: `Ollama API error: ${res.status} ${res.statusText}`,
};
}
}
```
### 2.3 시스템 프롬프트 설계
```typescript
const SYSTEM_PROMPTS = {
/** 텍스트 다듬기 (문체 교정) */
polish_formal: `당신은 한국어 텍스트 교정 전문가입니다.
사용자가 제공하는 음성 인식 결과를 자연스럽고 격식체인 문장으로 다듬어주세요.
- 구어체를 문어체로 변환
- 불필요한 반복이나 말더듬 제거
- 맞춤법과 문법 교정
- 원본 의미를 절대 변경하지 마세요
교정된 텍스트만 출력하세요. 설명이나 부연은 붙이지 마세요.`,
polish_casual: `당신은 한국어 텍스트 교정 전문가입니다.
사용자가 제공하는 음성 인식 결과를 자연스러운 구어체로 다듬어주세요.
- 말더듬, 반복어 제거
- 맞춤법 교정
- 자연스러운 대화체 유지
교정된 텍스트만 출력하세요.`,
/** 번역 */
translate: (targetLang: string) => `You are a professional translator.
Translate the following text to ${targetLang}.
Output ONLY the translated text, nothing else.`,
/** 요약 */
summarize: `당신은 요약 전문가입니다.
사용자가 제공하는 텍스트를 핵심만 간결하게 요약해주세요.
3문장 이내로 요약하세요. 요약문만 출력하세요.`,
/** 사용자 정의 명령어용 래퍼 */
custom: (userPrompt: string) => `다음 지시에 따라 텍스트를 처리해주세요:
${userPrompt}
처리된 텍스트만 출력하세요.`,
} as const;
```
### 2.4 가용성 모니터링 & 에러 처리
```typescript
// Ollama 가용성 폴링
class OllamaHealthMonitor {
private intervalId: NodeJS.Timeout | null = null;
private _available = false;
start(onChanged: (available: boolean) => void): void {
this.intervalId = setInterval(async () => {
const client = new OllamaClient();
const nowAvailable = await client.checkAvailability();
if (nowAvailable !== this._available) {
this._available = nowAvailable;
onChanged(nowAvailable);
}
}, 5_000);
}
stop(): void {
if (this.intervalId) clearInterval(this.intervalId);
}
get available(): boolean { return this._available; }
}
```
**에러 시나리오:**
| 상황 | 감지 방법 | 대응 |
|------|-----------|------|
| Ollama 미실행 | `fetch` ECONNREFUSED | UI에 "Ollama를 실행해주세요" 알림, 설치 링크 제공 |
| 모델 미다운로드 | 404 응답 또는 `/api/tags`에 없음 | 모델 다운로드 UI 표시, `/api/pull` 호출 |
| 생성 중 타임아웃 | AbortController.timeout | 사용자에게 재시도 옵션 제공 |
| 스트리밍 중 연결 끊김 | reader에서 에러 | 부분 결과 보존, 에러 표시 |
| VRAM 부족 | Ollama 에러 응답 | 더 작은 모델 추천 |
---
## 3. TTS 엔진 (Kokoro + edge-tts)
### 3.1 아키텍처 결정: 이중 TTS 전략
**Piper TTS는 채택하지 않는다.**
- 공식 리포지토리가 2025-10-06에 아카이브됨
- 한국어 음성 모델 공식 미지원 (커뮤니티 학습 모델만 존재)
- 후속 프로젝트(piper1-gpl)도 한국어 지원 불확실
**채택 전략:**
| 엔진 | 역할 | 한국어 | 오프라인 | 품질 |
|------|------|--------|---------|------|
| **Kokoro TTS** (kokoro-onnx) | 기본 TTS | ◎ 지원 (6개 언어 중 하나) | ◎ 완전 오프라인 | ◎ 82M 파라미터, 고품질 |
| **edge-tts** | 폴백 TTS | ◎ 다수 한국어 음성 | ✗ 인터넷 필요 | ◎◎ Microsoft 클라우드 품질 |
**우선순위:** Kokoro (오프라인) → edge-tts (고품질 폴백) → 없음 (TTS 비활성)
### 3.2 Kokoro TTS Sidecar 설계
Kokoro는 Python 기반이므로 Whisper sidecar와 같은 프로세스에서 호스팅 가능하다.
```python
# sidecar/tts_server.py (또는 whisper_server.py에 통합)
from kokoro_onnx import Kokoro
import soundfile as sf
import numpy as np
import io
kokoro: Kokoro | None = None
@app.post("/tts/load")
async def tts_load(body: dict):
"""Kokoro 모델 로드"""
global kokoro
kokoro = Kokoro(
model_path=body.get("model_path", "kokoro-v1_0.onnx"),
voices_path=body.get("voices_path", "voices-v1_0.bin")
)
return {"status": "loaded"}
@app.post("/tts/synthesize")
async def tts_synthesize(body: dict):
"""텍스트를 PCM 오디오로 변환"""
text = body["text"]
voice = body.get("voice", "kf_default") # 한국어 여성 기본
speed = body.get("speed", 1.0)
samples, sample_rate = kokoro.create(
text=text,
voice=voice,
speed=speed,
lang="ko" # 한국어
)
# PCM16으로 변환하여 반환
pcm16 = (samples * 32767).astype(np.int16)
buf = io.BytesIO()
sf.write(buf, pcm16, sample_rate, format='WAV')
buf.seek(0)
from starlette.responses import Response
return Response(
content=buf.read(),
media_type="audio/wav",
headers={"X-Sample-Rate": str(sample_rate)}
)
@app.get("/tts/voices")
async def tts_voices():
"""사용 가능한 음성 목록"""
return {
"voices": [
{"id": "kf_default", "name": "Korean Female", "language": "ko", "gender": "female"},
{"id": "km_default", "name": "Korean Male", "language": "ko", "gender": "male"},
# Kokoro 82M 모델의 한국어 음성들
]
}
```
### 3.3 edge-tts 폴백 (Node.js)
edge-tts에는 Node.js 구현(`@nicepkg/edge-tts` 또는 `edge-tts` npm)이 존재한다.
```typescript
// src/main/services/tts/EdgeTTSClient.ts
// edge-tts npm 패키지 사용 (andresayac/edge-tts)
import { MsEdgeTTS } from 'edge-tts';
class EdgeTTSClient {
async synthesize(text: string, voice = 'ko-KR-SunHiNeural'): Promise<Buffer> {
const tts = new MsEdgeTTS();
await tts.setMetadata(voice, 'audio-24khz-48kbitrate-mono-mp3');
const { audioStream } = await tts.toStream(text);
const chunks: Buffer[] = [];
for await (const chunk of audioStream) {
chunks.push(Buffer.from(chunk));
}
return Buffer.concat(chunks);
}
async getVoices(): Promise<Array<{id: string; name: string; locale: string}>> {
// 한국어 음성 목록
return [
{ id: 'ko-KR-SunHiNeural', name: '선히 (여성)', locale: 'ko-KR' },
{ id: 'ko-KR-InJoonNeural', name: '인준 (남성)', locale: 'ko-KR' },
{ id: 'ko-KR-BongJinNeural', name: '봉진 (남성)', locale: 'ko-KR' },
{ id: 'ko-KR-GookMinNeural', name: '국민 (남성)', locale: 'ko-KR' },
{ id: 'ko-KR-JiMinNeural', name: '지민 (여성)', locale: 'ko-KR' },
{ id: 'ko-KR-SeoHyeonNeural', name: '서현 (여성)', locale: 'ko-KR' },
{ id: 'ko-KR-SoonBokNeural', name: '순복 (여성)', locale: 'ko-KR' },
{ id: 'ko-KR-YuJinNeural', name: '유진 (여성)', locale: 'ko-KR' },
];
}
}
```
### 3.4 통합 TTS 서비스
```typescript
// src/main/services/tts/LocalTTSService.ts
class LocalTTSServiceImpl implements ILocalTTSService {
private kokoroAvailable = false;
private edgeTTSAvailable = false;
async speak(text: string, voiceId?: string, options?: TTSOptions): Promise<void> {
let audioBuffer: Buffer;
if (this.kokoroAvailable) {
// 1순위: Kokoro (오프라인)
audioBuffer = await this.sidecar.synthesize(text, voiceId, options);
} else if (this.edgeTTSAvailable) {
// 2순위: edge-tts (온라인)
audioBuffer = await this.edgeTTS.synthesize(text, voiceId);
} else {
throw { code: 'TTS_ENGINE_NOT_FOUND', message: 'No TTS engine available' };
}
// Web Audio API로 재생 (renderer에 IPC로 전달)
this.emit('audio-output', {
buffer: audioBuffer,
sampleRate: 24000,
isFinal: true,
});
}
}
```
### 3.5 오디오 재생: Renderer에서 Web Audio API
```typescript
// src/renderer/hooks/useAudioPlayer.ts
function useAudioPlayer() {
const audioContextRef = useRef<AudioContext | null>(null);
const playPCM = async (wavBuffer: ArrayBuffer, sampleRate: number) => {
if (!audioContextRef.current) {
audioContextRef.current = new AudioContext({ sampleRate });
}
const ctx = audioContextRef.current;
// WAV 디코딩
const audioBuffer = await ctx.decodeAudioData(wavBuffer);
const source = ctx.createBufferSource();
source.buffer = audioBuffer;
source.connect(ctx.destination);
source.start();
return new Promise<void>(resolve => {
source.onended = () => resolve();
});
};
return { playPCM };
}
```
---
## 4. 통합 시퀀스 다이어그램
### 4.1 전체 파이프라인: 핫키 → 텍스트 삽입
```
User HotkeyService VoiceModeService AudioCapture WhisperSidecar OllamaClient TextInsert
│ │ │ │ │ │ │
│── Press hotkey ──────►│ │ │ │ │ │
│ │── keyDown ─────►│ │ │ │ │
│ │ │ │ │ │ │
│ │ │── [1] 병렬 시작 ─┤ │ │ │
│ │ │ startSession() │ │ │ │
│ │ │ │ │ │ │
│ │ │── checkModel() ─────────────────►│ │ │
│ │ │ (이미 로드됨? → skip) │ │ │
│ │ │ │ │ │ │
│ │ │── start() ──────►│ │ │ │
│ │ │ │── capturing ──►│ │ │
│ │ │ │ (PCM16 60ms) │ │ │
│ │ │ │ │ │ │
│ │ │◄─ audio-data ────│ │ │ │
│ │ │ [버퍼 누적] │ │ │ │
│ │ │ │ │ │ │
│◄─ audio-level ────────────────────────────────────────────│ │ │ │
│ (UI 볼륨 미터) │ │ │ │ │ │
│ │ │ │ │ │ │
│── Release hotkey ────►│ │ │ │ │ │
│ │── keyUp ───────►│ │ │ │ │
│ │ │── stop() ───────►│ │ │ │
│ │ │ │── stopped ────►│ │ │
│ │ │ │ │ │ │
│ │ │── [2] 이중 조건 플러시 ────────────┤ │ │
│ │ │ (모델 ready + 오디오 ready) │ │ │
│ │ │ │ │ │
│ │ │── POST /transcribe ──────────────►│ │ │
│ │ │ (PCM16 전체 버퍼) │ │ │
│ │ │ │ │ │
│ │ │◄── { text: "안녕하세요" } ─────────│ │ │
│ │ │ │ │ │
│ │ │── [3] LLM 후처리 (옵션) ──────────────────────────►│ │
│ │ │ POST /api/generate │ │
│ │ │ system: polish_formal │ │
│ │ │ prompt: "안녕하세요" │ │
│ │ │ │ │
│◄─ processing-update ──────────────────────◄── stream tokens ────────────────────────────────│ │
│ (UI 스트리밍 표시) │ │ │ │
│ │ │◄── { text: "안녕하세요." } ────────────────────────│ │
│ │ │ │
│ │ │── [4] insertText() ──────────────────────────────────────────────►│
│ │ │ (클립보드 + Ctrl+V) │
│ │ │ │
│ │ │◄── success ──────────────────────────────────────────────────────│
│ │ │ │
│ │ │── [5] HistoryService.save() │
│ │ │ │
│◄─ session-completed ──────────────────── │ │
```
### 4.2 타이밍 예상 (medium 모델, 5초 녹음 기준)
| 단계 | 소요 시간 | 비고 |
|------|----------|------|
| 녹음 | 사용자 제어 | PTT: 누르는 동안 |
| STT 전사 | 1-3초 | CPU medium 모델, 5초 오디오 기준 |
| LLM 후처리 | 1-2초 | Qwen3 4B, 짧은 텍스트 |
| 텍스트 삽입 | <100ms | 클립보드 + 입력 시뮬레이션 |
| **총 지연** | **~2-5초** | LLM 후처리 미사용 ~1-3초 |
### 4.3 이중 조건 플러시 상세
```
┌──────────────────────────────────────────┐
│ VoiceModeService │
│ │
startSession() │ ┌──────────┐ ┌──────────────────┐ │
─────────────► │ │ 모델 로딩 │ │ 오디오 버퍼링 │ │
│ │ │ │ │ │
│ │ sidecar │ │ AudioCapture │ │
│ │ /load │ │ audio-data 누적 │ │
│ │ │ │ │ │
│ │ ▼ │ │ ▼ │ │
│ │ modelReady│ │ bufferReady │ │
│ │ = true │ │ = true │ │
│ └────┬─────┘ └────────┬─────────┘ │
│ │ │ │
│ └────────┬────────────┘ │
│ │ │
│ ▼ │
│ tryFlushAll() │
│ if (modelReady && bufferReady) { │
│ → POST /transcribe │
│ } │
└──────────────────────────────────────────┘
```
---
## 5. 에러 시나리오 & 복구 전략
### 5.1 STT (Whisper Sidecar)
| 에러 | 감지 | 복구 |
|------|------|------|
| Python 미설치 | sidecar spawn 실패 | "Python 3.10+ 설치 필요" 안내, 내장 Python 번들 검토 |
| sidecar 크래시 | `exit` 이벤트, code 0 | 지수 백오프 재시작 (최대 3회) |
| 모델 다운로드 실패 | `/load` HTTP 에러 | 네트워크 확인 안내, 재시도 버튼 |
| GPU 메모리 부족 | CUDA OOM 에러 | `device: "cpu"` 폴백, 작은 모델 추천 |
| 전사 타임아웃 | 30초 이상 응답 없음 | AbortController로 취소, "다시 시도" 안내 |
| 잘못된 오디오 포맷 | 전사 결과 비어있음 | PCM16 16kHz mono 검증 로직 추가 |
### 5.2 LLM (Ollama)
| 에러 | 감지 | 복구 |
|------|------|------|
| Ollama 미실행 | ECONNREFUSED | LLM 후처리 스킵, 원본 텍스트 사용 |
| 모델 미다운로드 | 404 또는 tags에 없음 | 모델 다운로드 UI 표시 |
| 생성 무한루프 | eval_count > maxTokens | AbortController 취소 |
| 느린 응답 | 토큰 간격 > 10초 | 타임아웃 후 부분 결과 사용 |
### 5.3 TTS
| 에러 | 감지 | 복구 |
|------|------|------|
| Kokoro 모델 없음 | `/tts/load` 실패 | edge-tts 폴백 |
| edge-tts 네트워크 실패 | fetch 에러 | TTS 비활성, 텍스트만 표시 |
| 오디오 재생 실패 | AudioContext 에러 | 사용자에게 오디오 장치 확인 안내 |
---
## 6. 모델 추천 & 한국어 성능
### 6.1 STT 모델 추천
| 시나리오 | 모델 | 크기 | CPU 성능 | 비고 |
|---------|------|------|---------|------|
| 빠른 응답 우선 | `base` | 145MB | RTF ~4x | 짧은 문장 위주, 약간의 오류 허용 |
| **균형 (기본 추천)** | **`small`** | 484MB | **RTF ~2x** | **한국어 인식률 양호, 합리적 속도** |
| 정확도 우선 | `medium` | 1.5GB | RTF ~1x | 한국어 전문 용어 포함 시 |
| GPU 사용자 | `large-v3` | 3.0GB | GPU에서 빠름 | 최고 정확도, VRAM 10GB+ 필요 |
| GPU + 속도 | `turbo` | 1.6GB | GPU 최적화 | large-v3에 근접한 정확도, 더 빠름 |
**한국어 팁:** `language: "ko"` 명시 지정이 `"auto"`보다 정확도 높음. `initial_prompt`에 "한국어 음성입니다."를 넣으면 추가 개선.
### 6.2 LLM 모델 추천
| 모델 | 크기 | 한국어 | RAM | 추천 용도 |
|------|------|--------|-----|----------|
| **`qwen3:4b`** | 2.7GB | ◎◎ 우수 | 4GB+ | **기본 추천. 한중일 강점** |
| `qwen3.5:4b` | ~3GB | ◎◎ 최상 | 4GB+ | 최신 모델, 한국어 특히 강화 |
| `gemma3:4b` | 3.3GB | ◎ 양호 | 4GB+ | Google 다국어, 140개 언어 |
| `llama3.2:3b` | 2.0GB | △ 보통 | 3GB+ | 영어 위주, 한국어 약함 |
| `phi-4-mini` | 2.5GB | △ 보통 | 3GB+ | 추론 강점, 한국어 제한적 |
**판정:** Qwen3/3.5 계열이 한국어에서 압도적. Alibaba의 다국어 학습 데이터에 한중일이 충분히 포함됨.
**텍스트 후처리에 권장 설정:**
```json
{
"model": "qwen3:4b",
"temperature": 0.3,
"num_predict": 512,
"top_p": 0.9,
"stop": ["\n\n"]
}
```
낮은 temperature(0.3)로 원문 의미 보존, 짧은 출력으로 빠른 응답.
### 6.3 TTS 음성 추천
| 엔진 | 음성 | 품질 | 지연시간 | 비고 |
|------|------|------|---------|------|
| **Kokoro** | `kf_default` (한국어 여성) | ◎ | ~1초 (CPU) | 오프라인, 82M 경량 |
| edge-tts | `ko-KR-SunHiNeural` | ◎◎ | ~0.5초 + 네트워크 | Microsoft 품질, 인터넷 필요 |
| edge-tts | `ko-KR-InJoonNeural` | ◎◎ | ~0.5초 + 네트워크 | 남성 음성 |
---
## 부록 A: Sidecar 프로세스 통합 설계
STT(Whisper)와 TTS(Kokoro)를 단일 Python sidecar로 통합 운영한다.
```
┌─────────────────────────────────────────────────┐
│ unified_sidecar.py │
│ (FastAPI on random port) │
│ │
│ GET /health → 전체 상태 │
│ │
│ ─── STT (faster-whisper) ─── │
│ POST /stt/load → 모델 로드 │
│ POST /stt/transcribe → 전사 │
│ POST /stt/download → 모델 다운로드 (SSE) │
│ │
│ ─── TTS (kokoro-onnx) ─── │
│ POST /tts/load → 음성 모델 로드 │
│ POST /tts/synthesize → 합성 (WAV 반환) │
│ GET /tts/voices → 음성 목록 │
│ │
│ POST /shutdown → 종료 │
└─────────────────────────────────────────────────┘
```
**장점:**
- 프로세스 1개만 관리 (spawn, health check, crash recovery 단일화)
- Python 환경/의존성 1세트
- 포트 1개만 사용
**requirements.txt:**
```
fastapi>=0.115
uvicorn>=0.34
faster-whisper>=1.1
kokoro-onnx>=0.5
numpy>=1.24
soundfile>=0.12
```
## 부록 B: Python 환경 관리 전략
| 전략 | 복잡도 | 사용자 경험 | 판정 |
|------|--------|------------|------|
| 시스템 Python 요구 | 낮음 | Python 설치 필요 | MVP용 |
| embedded Python 번들 | 중간 | 무설치 | **추후 채택** |
| conda/venv 자동 생성 | 중간 | 첫 실행 시 느림 | △ |
| Docker 컨테이너 | 높음 | Docker Desktop 필요 | ✗ |
**MVP 전략:**
1. 앱 첫 실행 시 `python --version` 확인
2. 없으면 Python 설치 안내
3. venv 자동 생성 + pip install 실행
4. 이후 실행에서는 venv 재사용
```typescript
// Python 환경 자동 설정
async function ensurePythonEnv(appDir: string): Promise<string> {
const venvPath = path.join(appDir, '.venv');
const pythonPath = process.platform === 'win32'
? path.join(venvPath, 'Scripts', 'python.exe')
: path.join(venvPath, 'bin', 'python');
if (!fs.existsSync(pythonPath)) {
// venv 생성
await execAsync(`python -m venv "${venvPath}"`);
// 의존성 설치
await execAsync(`"${pythonPath}" -m pip install -r "${path.join(appDir, 'sidecar', 'requirements.txt')}"`);
}
return pythonPath;
}
```