# 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 { 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 { 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 { 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 { 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 { 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; } ``` #### 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 { try { const res = await fetch(`${OLLAMA_BASE}/api/tags`, { signal: AbortSignal.timeout(3000), }); return res.ok; } catch { return false; } } /** 모델 목록 조회 */ async getModels(): Promise { 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 { 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, 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 { 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 { 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> { // 한국어 음성 목록 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 { 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(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(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 { 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; } ```