- npm workspaces 루트 (apps/*, packages/*) 세팅 - V1 전체를 apps/desktop/으로 git mv (src, resources, tests, sidecar, scripts, electron.vite.config.ts, electron-builder.yml, vitest.config.ts, tsconfig.node.json, tsconfig.web.json) - apps/desktop/package.json 신규 (name=@d3ro/desktop) - productName: 'd3ro-voice' 명시 — app.getName()을 고정하여 userData 경로 %APPDATA%\d3ro-voice\ 그대로 유지 (기존 DB/설정 연속성 보장) - 루트 package.json을 workspace 루트로 재구성, 공통 devDep만 유지 (typescript, eslint, prettier) - turbo.json, tsconfig.base.json 추가 (Turborepo 자체 설치는 별도 sub-phase) - memory/project_status.md 생성 (규칙 13) 검증: - npm run typecheck 통과 - npm run build 통과 (electron-vite main+preload+renderer) - npm run dev 실제 실행 → DB/핫키/Ollama 자동 실행 모두 정상
458 lines
14 KiB
Python
458 lines
14 KiB
Python
"""
|
|
D3RO-VOICE STT Sidecar (FastAPI HTTP 서버)
|
|
faster-whisper를 사용한 로컬 음성 인식 서비스.
|
|
|
|
사용법:
|
|
python main.py --port 18765
|
|
|
|
엔드포인트:
|
|
GET /health - 헬스체크
|
|
POST /load - Whisper 모델 로딩
|
|
POST /transcribe - 오디오 전사 (multipart)
|
|
POST /shutdown - 서버 종료
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import logging
|
|
import os
|
|
import signal
|
|
import sys
|
|
import time
|
|
from contextlib import asynccontextmanager
|
|
from typing import AsyncGenerator
|
|
|
|
import numpy as np
|
|
import uvicorn
|
|
from fastapi import FastAPI, File, Form, UploadFile
|
|
from fastapi.responses import JSONResponse
|
|
|
|
# ── 로깅 설정 ──────────────────────────────────────────────
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s",
|
|
datefmt="%Y-%m-%d %H:%M:%S",
|
|
stream=sys.stdout,
|
|
)
|
|
logger = logging.getLogger("sidecar")
|
|
|
|
# ── 전역 상태 ──────────────────────────────────────────────
|
|
|
|
_model: "WhisperModel | None" = None
|
|
_model_id: str | None = None
|
|
_gpu_available: bool = False
|
|
_server: uvicorn.Server | None = None
|
|
|
|
# ── FastAPI 앱 ─────────────────────────────────────────────
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
|
|
"""서버 시작/종료 생명주기."""
|
|
logger.info("Sidecar 서버 시작")
|
|
_detect_gpu()
|
|
yield
|
|
logger.info("Sidecar 서버 종료")
|
|
|
|
|
|
app = FastAPI(title="D3RO-VOICE STT Sidecar", lifespan=lifespan)
|
|
|
|
|
|
def _detect_gpu() -> None:
|
|
"""GPU(CUDA) 사용 가능 여부를 감지한다."""
|
|
global _gpu_available
|
|
try:
|
|
import torch
|
|
|
|
_gpu_available = torch.cuda.is_available()
|
|
if _gpu_available:
|
|
device_name = torch.cuda.get_device_name(0)
|
|
logger.info("GPU 감지: %s", device_name)
|
|
else:
|
|
logger.info("GPU 미감지, CPU 모드로 동작")
|
|
except ImportError:
|
|
_gpu_available = False
|
|
logger.info("PyTorch 미설치, CPU 모드로 동작")
|
|
|
|
|
|
# ── 엔드포인트 ─────────────────────────────────────────────
|
|
|
|
|
|
@app.get("/health")
|
|
async def health() -> JSONResponse:
|
|
"""헬스체크. sidecar가 준비되었는지 확인한다."""
|
|
return JSONResponse(
|
|
content={
|
|
"status": "ready" if _model is not None else "no_model",
|
|
"model": _model_id,
|
|
"gpu": _gpu_available,
|
|
}
|
|
)
|
|
|
|
|
|
@app.post("/load")
|
|
async def load_model(body: dict) -> JSONResponse: # noqa: ANN001
|
|
"""Whisper 모델을 로딩한다.
|
|
|
|
Request body:
|
|
{ "model_id": "base" } -- tiny, base, small, medium, large-v3
|
|
|
|
Returns:
|
|
{ "status": "loaded", "model_id": "base", "load_time_ms": 1234 }
|
|
"""
|
|
global _model, _model_id
|
|
|
|
model_id: str = body.get("model_id", "base")
|
|
logger.info("모델 로딩 시작: %s", model_id)
|
|
|
|
start_time = time.monotonic()
|
|
|
|
try:
|
|
from faster_whisper import WhisperModel
|
|
|
|
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,
|
|
compute_type=compute_type,
|
|
)
|
|
_model_id = model_id
|
|
|
|
load_time_ms = int((time.monotonic() - start_time) * 1000)
|
|
logger.info(
|
|
"모델 로딩 완료: %s (device=%s, compute=%s, %dms)",
|
|
model_id,
|
|
device,
|
|
compute_type,
|
|
load_time_ms,
|
|
)
|
|
|
|
return JSONResponse(
|
|
content={
|
|
"status": "loaded",
|
|
"model_id": model_id,
|
|
"load_time_ms": load_time_ms,
|
|
}
|
|
)
|
|
|
|
except Exception as exc:
|
|
logger.error("모델 로딩 실패: %s", exc)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"status": "error", "message": str(exc)},
|
|
)
|
|
|
|
|
|
@app.post("/transcribe")
|
|
async def transcribe(
|
|
audio: UploadFile = File(...),
|
|
language: str = Form("auto"),
|
|
vad_filter: str = Form("true"),
|
|
initial_prompt: str = Form(""),
|
|
) -> JSONResponse:
|
|
"""오디오 파일을 전사한다.
|
|
|
|
Multipart form:
|
|
audio - PCM16 16kHz mono 바이너리 파일
|
|
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(
|
|
status_code=503,
|
|
content={"status": "error", "message": "모델이 로딩되지 않았습니다"},
|
|
)
|
|
|
|
start_time = time.monotonic()
|
|
|
|
try:
|
|
# PCM16 바이너리 읽기
|
|
pcm_bytes = await audio.read()
|
|
|
|
if len(pcm_bytes) == 0:
|
|
return JSONResponse(
|
|
status_code=400,
|
|
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
|
|
|
|
logger.info(
|
|
"전사 시작: %.1f초 오디오, language=%s, vad=%s",
|
|
audio_duration,
|
|
language,
|
|
vad_filter,
|
|
)
|
|
|
|
# 전사 옵션 구성
|
|
transcribe_kwargs: dict = {
|
|
"vad_filter": vad_filter.lower() == "true",
|
|
"beam_size": 5,
|
|
}
|
|
|
|
if language != "auto":
|
|
transcribe_kwargs["language"] = language
|
|
|
|
if initial_prompt:
|
|
transcribe_kwargs["initial_prompt"] = initial_prompt
|
|
|
|
# faster-whisper 전사 실행
|
|
# VAD가 전체 오디오를 제거하면 max() 에러 발생 → VAD 없이 재시도
|
|
try:
|
|
segments_iter, info = _model.transcribe(audio_array, **transcribe_kwargs)
|
|
except ValueError as ve:
|
|
if "empty sequence" in str(ve) and transcribe_kwargs.get("vad_filter"):
|
|
logger.warning("VAD가 전체 오디오를 제거함 → VAD 없이 재시도")
|
|
transcribe_kwargs["vad_filter"] = False
|
|
segments_iter, info = _model.transcribe(audio_array, **transcribe_kwargs)
|
|
else:
|
|
raise
|
|
|
|
# 세그먼트 수집
|
|
segments_list: list[dict] = []
|
|
full_text_parts: list[str] = []
|
|
|
|
for segment in segments_iter:
|
|
seg_dict = {
|
|
"text": segment.text.strip(),
|
|
"start": round(segment.start, 3),
|
|
"end": round(segment.end, 3),
|
|
"avg_logprob": round(segment.avg_logprob, 4),
|
|
}
|
|
segments_list.append(seg_dict)
|
|
full_text_parts.append(segment.text.strip())
|
|
|
|
full_text = " ".join(full_text_parts).strip()
|
|
processing_time = int((time.monotonic() - start_time) * 1000)
|
|
|
|
detected_language = info.language if info.language else "unknown"
|
|
|
|
logger.info(
|
|
"전사 완료: '%s' (lang=%s, %.1f초, %dms)",
|
|
full_text[:80],
|
|
detected_language,
|
|
audio_duration,
|
|
processing_time,
|
|
)
|
|
|
|
return JSONResponse(
|
|
content={
|
|
"text": full_text,
|
|
"segments": segments_list,
|
|
"language": detected_language,
|
|
"duration": round(audio_duration, 3),
|
|
"processing_time": processing_time,
|
|
}
|
|
)
|
|
|
|
except Exception as exc:
|
|
logger.error("전사 실패: %s", exc, exc_info=True)
|
|
return JSONResponse(
|
|
status_code=500,
|
|
content={"status": "error", "message": str(exc)},
|
|
)
|
|
|
|
|
|
# ── 화자 구분 (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하게 종료한다."""
|
|
logger.info("종료 요청 수신")
|
|
|
|
if _server is not None:
|
|
_server.should_exit = True
|
|
|
|
return JSONResponse(content={"status": "shutting_down"})
|
|
|
|
|
|
# ── 메인 ───────────────────────────────────────────────────
|
|
|
|
|
|
def main() -> None:
|
|
"""CLI 진입점."""
|
|
global _server
|
|
|
|
parser = argparse.ArgumentParser(description="D3RO-VOICE STT Sidecar")
|
|
parser.add_argument(
|
|
"--port",
|
|
type=int,
|
|
default=18765,
|
|
help="HTTP 서버 포트 (기본: 18765)",
|
|
)
|
|
parser.add_argument(
|
|
"--host",
|
|
type=str,
|
|
default="127.0.0.1",
|
|
help="HTTP 서버 호스트 (기본: 127.0.0.1)",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
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)
|
|
if _server is not None:
|
|
_server.should_exit = True
|
|
|
|
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 자체 로그는 최소화 (우리 로거 사용)
|
|
access_log=False,
|
|
)
|
|
_server = uvicorn.Server(config)
|
|
_server.run()
|
|
|
|
logger.info("Sidecar 서버 종료 완료")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|