feat(bootstrap): Whisper large-v3-turbo 기본 전환 + 온보딩 2단계 다운로드 진행률

- 기본 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 통과
This commit is contained in:
Yun Chan 2026-07-21 11:59:49 +09:00
parent 9dc8b26c11
commit 983c60cda2
27 changed files with 688 additions and 76 deletions

View file

@ -3,13 +3,16 @@ D3RO-VOICE STT Sidecar (FastAPI HTTP 서버)
faster-whisper + CTranslate2만 사용. torch/pyannote 비의존으로 슬림 배포.
사용법:
python main.py --port 18765
python main.py --port 18765 --models-dir <path>
엔드포인트:
GET /health - 헬스체크
POST /load - Whisper 모델 로딩
POST /transcribe - 오디오 전사 (multipart)
POST /shutdown - 서버 종료
GET /health - 헬스체크
POST /load - Whisper 모델 로딩
POST /transcribe - 오디오 전사 (multipart)
POST /download - 모델 다운로드 시작 (백그라운드)
GET /download/status - 다운로드 진행률 조회
POST /download/cancel - 다운로드 취소
POST /shutdown - 서버 종료
: 화자 구분(diarization) Phase 15.5에서 LLM 추정 경로가 primary이며,
pyannote 기반 고정밀 화자 구분은 추후 서버 사이드 API로 제공될 예정.
@ -18,12 +21,15 @@ pyannote 기반 고정밀 화자 구분은 추후 서버 사이드 API로 제공
from __future__ import annotations
import argparse
import fnmatch
import logging
import os
import signal
import sys
import threading
import time
from contextlib import asynccontextmanager
from pathlib import Path
from typing import AsyncGenerator
import numpy as np
@ -47,6 +53,37 @@ _model: "WhisperModel | None" = None
_model_id: str | None = None
_gpu_available: bool = False
_server: uvicorn.Server | None = None
_models_dir: Path | None = None
# ── 다운로드 상태 (스레드 공유) ────────────────────────────
_download_lock = threading.Lock()
_download_thread: threading.Thread | None = None
_download_cancel = threading.Event()
_download_state: dict = {
"status": "idle", # idle | downloading | done | cancelled | error
"model_id": None,
"percent": 0,
"downloaded_bytes": 0,
"total_bytes": 0,
"bytes_per_second": 0,
"message": None,
}
# faster-whisper가 다운로드하는 파일과 동일한 화이트리스트
_DOWNLOAD_PATTERNS = [
"config.json",
"preprocessor_config.json",
"model.bin",
"tokenizer.json",
"vocabulary.*",
]
# faster_whisper.utils._MODELS 매핑 실패 시 폴백
_FALLBACK_REPOS = {
"large-v3-turbo": "mobiuslabsgmbh/faster-whisper-large-v3-turbo",
"turbo": "mobiuslabsgmbh/faster-whisper-large-v3-turbo",
}
# ── FastAPI 앱 ─────────────────────────────────────────────
@ -84,6 +121,135 @@ def _detect_gpu() -> None:
logger.info("GPU 감지 실패, CPU 모드로 동작: %s", exc)
# ── 모델 다운로드 헬퍼 ─────────────────────────────────────
def _resolve_repo(model_id: str) -> str:
"""모델 ID를 HuggingFace repo ID로 변환한다."""
if "/" in model_id:
return model_id
try:
from faster_whisper.utils import _MODELS
if model_id in _MODELS:
return _MODELS[model_id]
except Exception:
pass
if model_id in _FALLBACK_REPOS:
return _FALLBACK_REPOS[model_id]
return f"Systran/faster-whisper-{model_id}"
def _local_model_dir(model_id: str) -> Path | None:
"""models_dir 내 다운로드 완료된 모델 디렉토리를 반환한다 (없으면 None)."""
if _models_dir is None:
return None
model_dir = _models_dir / model_id
if (model_dir / "model.bin").exists():
return model_dir
return None
def _set_download_state(**kwargs: object) -> None:
with _download_lock:
_download_state.update(kwargs)
def _download_worker(model_id: str) -> None:
"""백그라운드 스레드: HF repo 파일들을 스트리밍 다운로드한다."""
import requests
from huggingface_hub import HfApi, hf_hub_url
try:
repo_id = _resolve_repo(model_id)
logger.info("모델 다운로드 시작: %s (repo=%s)", model_id, repo_id)
api = HfApi()
info = api.model_info(repo_id, files_metadata=True)
files = [
s
for s in (info.siblings or [])
if any(fnmatch.fnmatch(s.rfilename, p) for p in _DOWNLOAD_PATTERNS)
]
if not files:
raise RuntimeError(f"다운로드할 파일이 없습니다: {repo_id}")
assert _models_dir is not None
target_dir = _models_dir / model_id
target_dir.mkdir(parents=True, exist_ok=True)
total_bytes = sum(s.size or 0 for s in files)
downloaded = 0
start_time = time.monotonic()
_set_download_state(total_bytes=total_bytes, downloaded_bytes=0, percent=0)
for sibling in files:
fname = sibling.rfilename
fsize = sibling.size or 0
dest = target_dir / fname
# 멱등: 이미 크기 일치하는 파일은 스킵
if dest.exists() and fsize > 0 and dest.stat().st_size == fsize:
downloaded += fsize
_set_download_state(
downloaded_bytes=downloaded,
percent=int(downloaded * 100 / total_bytes) if total_bytes else 0,
)
logger.info("이미 존재, 스킵: %s", fname)
continue
if _download_cancel.is_set():
raise InterruptedError()
url = hf_hub_url(repo_id, fname)
part = dest.with_suffix(dest.suffix + ".part")
logger.info("다운로드: %s (%.1f MB)", fname, fsize / 1e6)
with requests.get(url, stream=True, timeout=30) as resp:
resp.raise_for_status()
with open(part, "wb") as fh:
for chunk in resp.iter_content(chunk_size=1024 * 1024):
if _download_cancel.is_set():
raise InterruptedError()
fh.write(chunk)
downloaded += len(chunk)
elapsed = time.monotonic() - start_time
_set_download_state(
downloaded_bytes=downloaded,
percent=(
int(downloaded * 100 / total_bytes) if total_bytes else 0
),
bytes_per_second=int(downloaded / elapsed) if elapsed > 0 else 0,
)
os.replace(part, dest)
_set_download_state(status="done", percent=100)
logger.info("모델 다운로드 완료: %s (%.1f MB)", model_id, downloaded / 1e6)
except InterruptedError:
_set_download_state(status="cancelled", message="사용자 취소")
logger.info("모델 다운로드 취소: %s", model_id)
_cleanup_partial(model_id)
except Exception as exc:
_set_download_state(status="error", message=str(exc))
logger.error("모델 다운로드 실패: %s", exc, exc_info=True)
_cleanup_partial(model_id)
def _cleanup_partial(model_id: str) -> None:
"""취소/실패 시 .part 잔여 파일 정리."""
if _models_dir is None:
return
target_dir = _models_dir / model_id
if not target_dir.exists():
return
for part in target_dir.glob("*.part"):
try:
part.unlink()
except OSError:
pass
# ── 엔드포인트 ─────────────────────────────────────────────
@ -111,7 +277,7 @@ async def load_model(body: dict) -> JSONResponse: # noqa: ANN001
"""
global _model, _model_id
model_id: str = body.get("model_id", "large-v3")
model_id: str = body.get("model_id", "large-v3-turbo")
logger.info("모델 로딩 시작: %s", model_id)
start_time = time.monotonic()
@ -122,8 +288,15 @@ async def load_model(body: dict) -> JSONResponse: # noqa: ANN001
device = "cuda" if _gpu_available else "cpu"
compute_type = "float16" if _gpu_available else "int8"
# /download로 미리 받아둔 로컬 디렉토리가 있으면 우선 사용.
# 없으면 faster-whisper의 HF 자동 다운로드 경로로 폴백.
local_dir = _local_model_dir(model_id)
model_source = str(local_dir) if local_dir else model_id
if local_dir:
logger.info("로컬 모델 디렉토리 사용: %s", local_dir)
_model = WhisperModel(
model_id,
model_source,
device=device,
compute_type=compute_type,
)
@ -266,6 +439,71 @@ async def transcribe(
)
@app.post("/download")
async def download_model(body: dict) -> JSONResponse: # noqa: ANN001
"""모델 다운로드를 백그라운드로 시작한다.
Request body:
{ "model_id": "large-v3-turbo" }
Returns:
{ "status": "started" } 또는 이미 완료된 경우 { "status": "done" }
"""
global _download_thread
if _models_dir is None:
return JSONResponse(
status_code=500,
content={"status": "error", "message": "models-dir가 설정되지 않았습니다"},
)
model_id: str = body.get("model_id", "large-v3-turbo")
# 이미 다운로드 완료된 모델이면 즉시 done
if _local_model_dir(model_id) is not None:
_set_download_state(status="done", model_id=model_id, percent=100)
return JSONResponse(content={"status": "done"})
if _download_thread is not None and _download_thread.is_alive():
return JSONResponse(
status_code=409,
content={"status": "error", "message": "이미 다운로드가 진행 중입니다"},
)
_download_cancel.clear()
_set_download_state(
status="downloading",
model_id=model_id,
percent=0,
downloaded_bytes=0,
total_bytes=0,
bytes_per_second=0,
message=None,
)
_download_thread = threading.Thread(
target=_download_worker, args=(model_id,), daemon=True
)
_download_thread.start()
return JSONResponse(content={"status": "started"})
@app.get("/download/status")
async def download_status() -> JSONResponse:
"""현재 다운로드 상태를 반환한다."""
with _download_lock:
return JSONResponse(content=dict(_download_state))
@app.post("/download/cancel")
async def download_cancel() -> JSONResponse:
"""진행 중인 다운로드를 취소한다."""
if _download_thread is not None and _download_thread.is_alive():
_download_cancel.set()
return JSONResponse(content={"status": "cancelling"})
return JSONResponse(content={"status": "idle"})
@app.post("/shutdown")
async def shutdown() -> JSONResponse:
"""서버를 graceful하게 종료한다."""
@ -282,7 +520,7 @@ async def shutdown() -> JSONResponse:
def main() -> None:
"""CLI 진입점."""
global _server
global _server, _models_dir
parser = argparse.ArgumentParser(description="D3RO-VOICE STT Sidecar")
parser.add_argument(
@ -297,8 +535,18 @@ def main() -> None:
default="127.0.0.1",
help="HTTP 서버 호스트 (기본: 127.0.0.1)",
)
parser.add_argument(
"--models-dir",
type=str,
default=None,
help="사전 다운로드 모델 저장 디렉토리 (미지정 시 HF 캐시만 사용)",
)
args = parser.parse_args()
if args.models_dir:
_models_dir = Path(args.models_dir)
_models_dir.mkdir(parents=True, exist_ok=True)
logger.info("D3RO-VOICE STT Sidecar 시작 (port=%d)", args.port)
def signal_handler(signum: int, _frame: object) -> None: