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:
parent
9dc8b26c11
commit
983c60cda2
27 changed files with 688 additions and 76 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
faster-whisper>=1.0.0
|
||||
faster-whisper>=1.1.0
|
||||
fastapi>=0.109.0
|
||||
uvicorn>=0.27.0
|
||||
python-multipart>=0.0.6
|
||||
numpy>=1.24.0
|
||||
huggingface_hub>=0.23.0
|
||||
requests>=2.31.0
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue