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:
parent
d1edad6727
commit
aa65e710ec
16 changed files with 725 additions and 500 deletions
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue