Phase 15.5 구현: 화자 구분 (Speaker Diarization)

- Sidecar: pyannote /diarize 엔드포인트 + requirements.txt 업데이트
- LLM 기반 화자 추정 (Phase 1 — 오디오 보존 없이 전사 텍스트 분석)
- CaptionSegment에 speaker 필드 추가
- EditableSegment: 화자별 색상 바 + 화자 Chip 표시
- TranscriptTab: 화자 구분 버튼 + 진행률
- 설정: HuggingFace 토큰 입력 + Diarization 토글
- IPC: DIARIZE + DIARIZATION_PROGRESS 채널
- 에러코드: 895-897
- 12개 locale i18n
This commit is contained in:
Yun Chan 2026-04-08 12:14:00 +09:00
parent da25791c75
commit fc7628327a
25 changed files with 679 additions and 18 deletions

View file

@ -278,6 +278,123 @@ 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하게 종료한다."""

View file

@ -3,3 +3,5 @@ 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