d3ro-voice/docs/design/05-external-engine-integration.md
윤찬 d397bcbf57 feat(desktop): LLM 기본 모델 qwen3:4b → gemma4:e4b 전면 전환 + think:false 안전장치
qwen3:4b가 reasoning 모델이라 <think>...</think> 블록을 길게 생성 →
stripReasoningBlocks 후 빈 문자열 → 원본 transcript fallback으로 끝나면서
LLM refine이 42초 걸리는 병목 발견. Google Gemma 4 e4b(4.5B effective
params, 2026-04-02 릴리스)로 교체. non-reasoning 기본 + Ollama v0.20+
think: false 파라미터로 2중 방어.

실측 결과: 받아쓰기 한 사이클 51.4s → 5.5s (9.3배 빠름).
  STT 500ms + LLM 3,925ms + insert 1,092ms.
refine 품질 정상 동작 확인: "테스트하는 중입니다" → "테스트하고 있습니다".

- LocalLLMService: 3개 fallback 기본값 변경(generate / streamGenerate /
  chatStream) + Ollama 요청 body에 think: false 명시 추가. non-reasoning
  모델은 무시, reasoning 모델은 thinking 토큰 차단. NO_THINK 주석을
  legacy 설명으로 업데이트 — qwen3/deepseek-r1 수동 선택자를 위한 3중
  방어(/no_think + think:false + stripReasoningBlocks) 명시.
- OnboardingModal / OllamaGuideModal: pull 명령어 갱신
- 테스트 fixture 갱신
- 12개 i18n locale JSON: settings.ollamaHint / ollama.step2.alt 키 업데이트
  (qwen3:4b → gemma4:e4b, qwen3:8b → gemma4:26b)
- 10개 site i18n locale TS + HowItWorks.tsx 파이프라인 시각화 — detail
  문자열 'qwen3 / llama3 / gemma3' → 'gemma4 / llama3.2 / phi4',
  파이프라인 라벨 'qwen3:4b @ localhost' → 'gemma4:e4b @ localhost'
- 설계서 00 LLMConfig 기본값 + CONFIG_DEFAULTS
- 설계서 05: 6개 API 스키마 예시, 2개 OllamaClient 코드 예시, LLM 모델
  추천 표 재정렬(gemma4:e4b 최상위, qwen3는 reasoning 경고와 함께 후순위),
  권장 JSON 설정에 think:false 추가
- phase-14 meeting mode 컨텍스트 윈도우 표 갱신
- V2-5 Mac 부트스트랩 가이드 pull 커맨드 갱신
- project_status.md Part 7 전체 섹션 추가
2026-04-12 09:47:08 +09:00

44 KiB

D3RO-VOICE 외부 AI 엔진 연동 상세 설계서

버전: 1.0
작성일: 2026-04-04
기반: 01-service-specifications.md의 LocalSTT, LocalLLM, LocalTTS 서비스 명세


목차

  1. faster-whisper STT Sidecar
  2. Ollama LLM REST API
  3. TTS 엔진 (Kokoro + edge-tts)
  4. 통합 시퀀스 다이어그램
  5. 에러 시나리오 & 복구 전략
  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 서버 설계

# 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 관리

// 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 변환 모델을 자동 다운로드한다.

// 모델 크기별 정보
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 콜백 사용

모델 다운로드 진행률 전달:

# 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 — 텍스트 생성

// 요청
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 — 대화형 생성

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 — 로컬 모델 목록

// 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 — 모델 다운로드

// 요청
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 — 모델 정보

// 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 — 모델 삭제

// DELETE http://localhost:11434/api/delete
// 요청: { "name": "gemma4:e4b" }
// 응답: 200 OK (성공) | 404 Not Found

2.2 Node.js 클라이언트 구현

// 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 시스템 프롬프트 설계

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 가용성 모니터링 & 에러 처리

// 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와 같은 프로세스에서 호스팅 가능하다.

# 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)이 존재한다.

// 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 서비스

// 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

// 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 파라미터 필수.

텍스트 후처리에 권장 설정:

{
  "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 재사용
// 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;
}