- 기본 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 통과
1159 lines
44 KiB
Markdown
1159 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", "large-v3-turbo"
|
|
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; // 필수: "gemma4:e4b"
|
|
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; // "gemma4:e4b"
|
|
model: string;
|
|
modified_at: string; // ISO 8601
|
|
size: number; // 바이트
|
|
digest: string; // SHA256
|
|
details: {
|
|
parent_model: string;
|
|
format: string; // "gguf"
|
|
family: string; // "gemma4"
|
|
parameter_size: string; // "4.5B"
|
|
quantization_level: string; // "Q4_K_M"
|
|
};
|
|
}>;
|
|
}
|
|
```
|
|
|
|
#### POST /api/pull — 모델 다운로드
|
|
|
|
```typescript
|
|
// 요청
|
|
interface OllamaPullRequest {
|
|
name: string; // "gemma4:e4b"
|
|
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": "gemma4:e4b" }
|
|
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": "gemma4:e4b" }
|
|
// 응답: 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 ?? 'gemma4:e4b',
|
|
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 ?? 'gemma4:e4b',
|
|
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 | 추천 용도 |
|
|
|------|------|--------|-----|----------|
|
|
| **`gemma4:e4b`** | 3.3GB | ◎◎ 우수 | 4GB+ | **기본 추천. non-reasoning, 140개 언어, Ollama think:false 지원** |
|
|
| `gemma4:e2b` | 1.8GB | ◎ 양호 | 2GB+ | 더 빠른 응답. 정제 품질은 한 끗 낮음 |
|
|
| `llama3.2:3b` | 2.0GB | ◎ 양호 | 3GB+ | 가장 빠름, Meta 소형 플래그십 |
|
|
| `phi4-mini:3.8b` | 2.5GB | △ 보통 | 3GB+ | 추론/수학 강점, 한국어 제한적 |
|
|
| `qwen3:4b` | 2.7GB | ◎◎ 우수 | 4GB+ | ⚠ reasoning 모델. strip empty 버그 유의 (think:false 필수) |
|
|
|
|
**판정:** Gemma 4는 Google의 최신 non-reasoning 플래그십 소형 모델(2026-04). 한국어 포함 140개 언어 강력하고, reasoning 토큰을 기본으로 생성하지 않아 정제 파이프라인이 단순하다. Qwen3 계열은 한국어 품질은 좋지만 reasoning 모델이라 thinking block strip 후 빈 응답 fallback 이슈가 있음 — 수동 선택 시 Ollama v0.20+의 `think: false` 파라미터 필수.
|
|
|
|
**텍스트 후처리에 권장 설정:**
|
|
```json
|
|
{
|
|
"model": "gemma4:e4b",
|
|
"think": false,
|
|
"temperature": 0.3,
|
|
"num_predict": 512,
|
|
"top_p": 0.9,
|
|
"stop": ["\n\n"]
|
|
}
|
|
```
|
|
낮은 temperature(0.3)로 원문 의미 보존, 짧은 출력으로 빠른 응답. `think: false`는 gemma4/llama3.2는 무시하고, 사용자가 qwen3/deepseek-r1 등으로 교체했을 때 reasoning 토큰 차단.
|
|
|
|
### 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;
|
|
}
|
|
```
|