feat: 배포 파이프라인 — Ollama/sidecar 번들 + NSIS 자동 VC++ + GitLab CI + 온보딩 모달

- sidecar 슬림화: torch/pyannote 제거, ctranslate2 GPU 감지, /diarize 삭제
- Ollama 번들: resources/ollama/에 포터블 바이너리 배치, LocalLLMService 1순위 탐색
- installer.nsh: VC++ 재배포 x64 자동 다운로드(aka.ms 경유) + 사일런트 설치
- electron-builder: extraResources에 ollama 추가, nsis.include로 installer.nsh 연결
- scripts: download-ollama.ps1/sh 신규
- LLM.PULL_MODEL IPC 핸들러 + LocalLLMService.pullModel() 구현 (api/pull 스트리밍)
- 온보딩 모달: gemma4:e4b 미설치 감지 시 자동 표시, 진행률 UI, i18n(ko/en) 키 추가
- .gitlab-ci.yml: Windows 러너에서 sidecar/sox/ollama 준비 후 NSIS 패키징, 태그 시 Release 자동 생성
This commit is contained in:
yunchan8804 2026-04-15 19:47:27 +09:00
parent d1edad6727
commit aa65e710ec
16 changed files with 725 additions and 500 deletions

View file

@ -1,6 +1,6 @@
"""
D3RO-VOICE STT Sidecar (FastAPI HTTP 서버)
faster-whisper사용한 로컬 음성 인식 서비스.
faster-whisper + CTranslate2만 사용. torch/pyannote 비의존으로 슬림 배포.
사용법:
python main.py --port 18765
@ -10,6 +10,9 @@ faster-whisper를 사용한 로컬 음성 인식 서비스.
POST /load - Whisper 모델 로딩
POST /transcribe - 오디오 전사 (multipart)
POST /shutdown - 서버 종료
: 화자 구분(diarization) Phase 15.5에서 LLM 추정 경로가 primary이며,
pyannote 기반 고정밀 화자 구분은 추후 서버 사이드 API로 제공될 예정.
"""
from __future__ import annotations
@ -61,20 +64,24 @@ app = FastAPI(title="D3RO-VOICE STT Sidecar", lifespan=lifespan)
def _detect_gpu() -> None:
"""GPU(CUDA) 사용 가능 여부를 감지한다."""
"""GPU(CUDA) 사용 가능 여부를 ctranslate2로 감지한다.
torch 의존 제거를 위해 ctranslate2의 네이티브 CUDA 감지를 사용한다.
ctranslate2는 faster-whisper의 백엔드이므로 항상 함께 설치된다.
"""
global _gpu_available
try:
import torch
import ctranslate2
_gpu_available = torch.cuda.is_available()
cuda_count = ctranslate2.get_cuda_device_count()
_gpu_available = cuda_count > 0
if _gpu_available:
device_name = torch.cuda.get_device_name(0)
logger.info("GPU 감지: %s", device_name)
logger.info("GPU 감지: CUDA 디바이스 %d", cuda_count)
else:
logger.info("GPU 미감지, CPU 모드로 동작")
except ImportError:
except Exception as exc:
_gpu_available = False
logger.info("PyTorch 미설치, CPU 모드로 동작")
logger.info("GPU 감지 실패, CPU 모드로 동작: %s", exc)
# ── 엔드포인트 ─────────────────────────────────────────────
@ -97,14 +104,14 @@ async def load_model(body: dict) -> JSONResponse: # noqa: ANN001
"""Whisper 모델을 로딩한다.
Request body:
{ "model_id": "base" } -- tiny, base, small, medium, large-v3
{ "model_id": "large-v3" } -- tiny, base, small, medium, large-v3
Returns:
{ "status": "loaded", "model_id": "base", "load_time_ms": 1234 }
{ "status": "loaded", "model_id": "large-v3", "load_time_ms": 1234 }
"""
global _model, _model_id
model_id: str = body.get("model_id", "base")
model_id: str = body.get("model_id", "large-v3")
logger.info("모델 로딩 시작: %s", model_id)
start_time = time.monotonic()
@ -115,10 +122,6 @@ async def load_model(body: dict) -> JSONResponse: # noqa: ANN001
device = "cuda" if _gpu_available else "cpu"
compute_type = "float16" if _gpu_available else "int8"
# 모델 크기별 compute_type 조정
if model_id in ("large-v3", "medium") and not _gpu_available:
compute_type = "int8"
_model = WhisperModel(
model_id,
device=device,
@ -165,15 +168,6 @@ async def transcribe(
language - 언어 코드 ('auto', 'ko', 'en', ...)
vad_filter - VAD 필터 활성화 ('true' / 'false')
initial_prompt - 초기 프롬프트 (컨텍스트 힌트)
Returns:
{
"text": "전사된 텍스트",
"segments": [...],
"language": "ko",
"duration": 3.5,
"processing_time": 1234
}
"""
if _model is None:
return JSONResponse(
@ -184,7 +178,6 @@ async def transcribe(
start_time = time.monotonic()
try:
# PCM16 바이너리 읽기
pcm_bytes = await audio.read()
if len(pcm_bytes) == 0:
@ -193,12 +186,10 @@ async def transcribe(
content={"status": "error", "message": "오디오 데이터가 비어있습니다"},
)
# PCM16 → float32 변환 (-1.0 ~ 1.0)
audio_array = (
np.frombuffer(pcm_bytes, dtype=np.int16).astype(np.float32) / 32768.0
)
# 오디오 길이 계산 (16kHz mono 기준)
sample_rate = 16000
audio_duration = len(audio_array) / sample_rate
@ -209,7 +200,6 @@ async def transcribe(
vad_filter,
)
# 전사 옵션 구성
transcribe_kwargs: dict = {
"vad_filter": vad_filter.lower() == "true",
"beam_size": 5,
@ -221,7 +211,6 @@ async def transcribe(
if initial_prompt:
transcribe_kwargs["initial_prompt"] = initial_prompt
# faster-whisper 전사 실행
# VAD가 전체 오디오를 제거하면 max() 에러 발생 → VAD 없이 재시도
try:
segments_iter, info = _model.transcribe(audio_array, **transcribe_kwargs)
@ -233,7 +222,6 @@ async def transcribe(
else:
raise
# 세그먼트 수집
segments_list: list[dict] = []
full_text_parts: list[str] = []
@ -278,123 +266,6 @@ async def transcribe(
)
# ── 화자 구분 (Phase 15.5) ────────────────────────────────
_diarization_pipeline = None
def _get_diarization_pipeline(hf_token: str):
"""pyannote speaker diarization 파이프라인을 로드한다 (캐시)."""
global _diarization_pipeline
if _diarization_pipeline is not None:
return _diarization_pipeline
try:
from pyannote.audio import Pipeline
logger.info("Diarization 파이프라인 로딩 시작")
_diarization_pipeline = Pipeline.from_pretrained(
"pyannote/speaker-diarization-3.1",
use_auth_token=hf_token,
)
if _gpu_available:
import torch
_diarization_pipeline.to(torch.device("cuda"))
logger.info("Diarization 파이프라인 로딩 완료 (GPU)")
else:
logger.info("Diarization 파이프라인 로딩 완료 (CPU)")
except Exception as exc:
logger.error("Diarization 파이프라인 로딩 실패: %s", exc)
raise
return _diarization_pipeline
@app.post("/diarize")
async def diarize(
audio: UploadFile = File(...),
hf_token: str = Form(default=""),
num_speakers: int = Form(default=0),
) -> JSONResponse:
"""오디오 파일의 화자 구분을 수행한다."""
if not hf_token:
return JSONResponse(
status_code=400,
content={"status": "error", "message": "HuggingFace 토큰이 필요합니다"},
)
start_time = time.monotonic()
try:
import tempfile
import os
pipeline = _get_diarization_pipeline(hf_token)
# 오디오 파일 임시 저장 (pyannote는 파일 경로 필요)
pcm_bytes = await audio.read()
if len(pcm_bytes) == 0:
return JSONResponse(
status_code=400,
content={"status": "error", "message": "오디오 데이터가 비어있습니다"},
)
# PCM16 → WAV 변환
import wave
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".wav")
try:
with wave.open(tmp_path, "wb") as wf:
wf.setnchannels(1)
wf.setsampwidth(2) # 16-bit
wf.setframerate(16000)
wf.writeframes(pcm_bytes)
# diarization 실행
diarize_kwargs = {}
if num_speakers > 0:
diarize_kwargs["num_speakers"] = num_speakers
logger.info("화자 구분 시작: %.1f초 오디오", len(pcm_bytes) / (16000 * 2))
diarization = pipeline(tmp_path, **diarize_kwargs)
# 결과 파싱
segments = []
speakers = set()
for turn, _, speaker in diarization.itertracks(yield_label=True):
segments.append({
"speaker": speaker,
"start": round(turn.start, 3),
"end": round(turn.end, 3),
})
speakers.add(speaker)
processing_time = int((time.monotonic() - start_time) * 1000)
logger.info(
"화자 구분 완료: %d개 세그먼트, %d명 화자, %dms",
len(segments),
len(speakers),
processing_time,
)
return JSONResponse(
content={
"segments": segments,
"num_speakers": len(speakers),
"processing_time": processing_time,
}
)
finally:
os.close(tmp_fd)
os.unlink(tmp_path)
except Exception as exc:
logger.error("화자 구분 실패: %s", exc, exc_info=True)
return JSONResponse(
status_code=500,
content={"status": "error", "message": str(exc)},
)
@app.post("/shutdown")
async def shutdown() -> JSONResponse:
"""서버를 graceful하게 종료한다."""
@ -430,7 +301,6 @@ def main() -> None:
logger.info("D3RO-VOICE STT Sidecar 시작 (port=%d)", args.port)
# SIGINT/SIGTERM 핸들러
def signal_handler(signum: int, _frame: object) -> None:
sig_name = signal.Signals(signum).name
logger.info("시그널 수신: %s, 종료 시작", sig_name)
@ -440,12 +310,11 @@ def main() -> None:
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
# uvicorn 서버 구성 및 시작
config = uvicorn.Config(
app=app,
host=args.host,
port=args.port,
log_level="warning", # uvicorn 자체 로그는 최소화 (우리 로거 사용)
log_level="warning",
access_log=False,
)
_server = uvicorn.Server(config)

View file

@ -3,5 +3,3 @@ fastapi>=0.109.0
uvicorn>=0.27.0
python-multipart>=0.0.6
numpy>=1.24.0
pyannote.audio>=3.3.0
torch>=2.0.0