Phase 1+2 구현: Electron 뼈대 + STT/핫키/오케스트레이터
Phase 1: - 프로젝트 초기화 (TypeScript strict, electron-vite, ESLint, Prettier) - shared 타입 (ipc-channels 113채널, types, errors, constants) - 메인 프로세스 뼈대 (bootstrap, lifecycle, 단일 인스턴스) - LoggerService, ConfigService (electron-store ESM dynamic import) - React 19 + MUI 7 Dashboard, 시스템 트레이 Phase 2: - AudioCaptureService (node-record-lpcm16, PCM16 16kHz mono) - HotkeyService (uiohook-napi, 더블프레스, holdMode/toggleMode) - LocalSTTService (faster-whisper Python sidecar, 이중 조건 플러시) - VoiceModeService 오케스트레이터 (이중 상태머신, Action Queue) - Python sidecar (FastAPI: health/load/transcribe/shutdown) - IPC 핸들러 (voice, stt, hotkey) + Preload API 확장
This commit is contained in:
parent
e24bb8378c
commit
1d152d01a1
46 changed files with 10828 additions and 4 deletions
332
sidecar/main.py
Normal file
332
sidecar/main.py
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
"""
|
||||
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 전사 실행
|
||||
segments_iter, info = _model.transcribe(audio_array, **transcribe_kwargs)
|
||||
|
||||
# 세그먼트 수집
|
||||
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)},
|
||||
)
|
||||
|
||||
|
||||
@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()
|
||||
5
sidecar/requirements.txt
Normal file
5
sidecar/requirements.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
faster-whisper>=1.0.0
|
||||
fastapi>=0.109.0
|
||||
uvicorn>=0.27.0
|
||||
python-multipart>=0.0.6
|
||||
numpy>=1.24.0
|
||||
Loading…
Add table
Add a link
Reference in a new issue