From 26ff9b59c28e511b07ae1b1043050254896e5e7a Mon Sep 17 00:00:00 2001 From: dosangyoon Date: Mon, 23 Mar 2026 15:23:49 +0900 Subject: [PATCH] feat(web): speaker diarization via pyannote (parity with whisper_stt) - Add app/diarize.py: local snapshot, A/B labels, disclaimer text - transcribe_file and async jobs support diarize flag; Form diarize on API - UI checkbox (default on); requirements: pyannote.audio, huggingface_hub - README: env vars and model notes Made-with: Cursor --- README.md | 47 +++++++++++- app/diarize.py | 172 ++++++++++++++++++++++++++++++++++++++++++ app/main.py | 34 +++++++++ app/static/index.html | 10 ++- app/stt.py | 17 ++++- requirements.txt | 3 + 6 files changed, 280 insertions(+), 3 deletions(-) create mode 100644 app/diarize.py diff --git a/README.md b/README.md index 30c9202..fc25415 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,9 @@ - **백엔드**: FastAPI (업로드/검증/STT 수행) - **STT 엔진**: `faster-whisper` (Whisper 모델) +- **화자 분리(웹 기본 켜짐)**: `pyannote` — `whisper_stt.py`와 동일하게 로컬 `./models/pyannote-diarization-3.1` + HF 토큰·게이트 동의 필요. UI/API에서 끌 수 있음(`diarize=false`). - **프론트**: 단일 HTML (파일 선택 → 전사 → 결과 표시/다운로드) -- **선택 CLI**: `whisper_stt.py` — OpenAI Whisper 기반 로컬 전사(**기본: 화자 구분**, 로컬 `./models/pyannote-diarization-3.1`) +- **선택 CLI**: `whisper_stt.py` — OpenAI Whisper 기반 로컬 전사(**기본: 화자 구분**, 동일 pyannote 스냅샷) ## 동작 개요 (pseudocode) @@ -151,16 +152,60 @@ conda activate stt # 또는 ncue uvicorn app.main:app --reload --host 127.0.0.1 --port 8025 ``` +또는 저장소 루트에서 **`./run.sh`** (백그라운드 + `server.log`, 기본 포트 `8025`, `PORT`·`CONDA_ENV` 등으로 조정). + 브라우저에서 `http://127.0.0.1:8025` 접속. 웹 UI는 **faster-whisper** 전사만 수행합니다. 화자 구분이 필요하면 **`whisper_stt.py`**(로컬 CLI)를 사용하세요. +### HTTPS 도메인(`https://ncue.net/stt/…`)에서 503 (Service Unavailable) + +브라우저가 **503**을 보여 줄 때, 대부분 **리버스 프록시(nginx 등)가 백엔드(uvicorn)에 붙지 못했다**는 뜻입니다. 이 저장소의 앱은 일반적으로 **503을 직접 내지 않습니다.** + +**흔한 원인** + +1. **`run.sh`를 다른 머신에서만 실행한 경우** + `run.sh`는 uvicorn을 **`127.0.0.1:8025`**에만 띄웁니다. **nginx가 돌아가는 서버와 같은 호스트**에서 프로세스가 떠 있어야 `https://도메인/stt/` 프록시가 연결됩니다. 노트북에서만 `./run.sh`를 켜 두면 공개 도메인 쪽 업스트림은 비어 있어 **503**이 납니다. + +2. **프로세스가 곧바로 종료된 경우** + DB 설정 오류, import 실패 등으로 uvicorn이 뜨자마자 죽으면 프록시도 503을 냅니다. **서버에서** `tail -n 100 server.log` 로 스택 트레이스를 확인하세요. + +3. **포트·프록시 설정 불일치** + nginx `upstream` / `proxy_pass`가 가리키는 포트가 실제 uvicorn 포트(`PORT`, 기본 8025)와 같아야 합니다. + +**같은 서버에서 빠른 점검** + +```bash +curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8025/healthz +# 200 이어야 정상 +ss -lntp | grep 8025 +# 또는: lsof -iTCP:8025 -sTCP:LISTEN +``` + +**nginx 예시** (`/stt/` 아래로 서비스할 때, 접두사를 벗겨 uvicorn의 `/`·`/api/…`에 넘깁니다) + +```nginx +location /stt/ { + proxy_pass http://127.0.0.1:8025/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} +``` + +`proxy_pass`에 **끝 슬래시(`/`)**가 있어야 `location /stt/`와 짝이 맞아 경로가 올바르게 넘어갑니다. 설정을 고친 뒤 **`nginx -t` 후 reload** 하세요. + +공개 URL은 가능하면 **`https://예시/stt/`** 처럼 **슬래시까지 포함**해 두면, UI의 상대 경로(`healthz`, `api/…`)가 같은 접두사 아래로 잘 붙습니다. + --- ## 옵션·환경 변수 - **모델**: 기본 `small` (정확도/속도 균형). `APP_WHISPER_MODEL=base|small|medium|large-v3` 등으로 변경 가능. - **디바이스**: 기본 CPU. Apple Silicon에서 Metal은 `faster-whisper` 단독으로는 제한이 있어 CPU 기본값을 권장. +- **화자 분리**: `pip install -r requirements.txt`에 `pyannote.audio`가 포함됩니다. 모델 폴더는 `WHISPER_DIARIZE_MODEL_DIR` / `PYANNOTE_MODEL_DIR` 또는 기본 `./models/pyannote-diarization-3.1`. 다른 경로는 `APP_PYANNOTE_MODEL_DIR`로 지정 가능. HF 토큰(`HF_TOKEN` 등)과 gated 저장소 동의는 `whisper_stt.py` 절·`requirements-whisper-stt.txt` 주석과 동일합니다. - **기타**: `APP_WHISPER_DEVICE`, `APP_WHISPER_COMPUTE_TYPE`, 업로드 크기 등은 `app/main.py` 및 `.env` 예시를 참고. --- diff --git a/app/diarize.py b/app/diarize.py new file mode 100644 index 0000000..8226e91 --- /dev/null +++ b/app/diarize.py @@ -0,0 +1,172 @@ +"""웹 STT용 화자 분리 — whisper_stt.py와 동일한 pyannote 로컬 스냅샷 + 타임라인 정렬.""" +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +from .pyannote_auth import load_pyannote_pipeline + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +DEFAULT_DIARIZE_MODEL_DIR = PROJECT_ROOT / "models" / "pyannote-diarization-3.1" + +DIARIZE_DISCLAIMER_KO = ( + "※ 화자 A, B, C… 는 실제 이름이 아니라, 이 녹음에서 말이 처음 잡힌 순서로 붙인 구분자입니다.\n" + "※ 같은 사람이 여러 구간으로 나뉘면 라벨이 바뀌거나 섞일 수 있으니, 중요한 회의는 검수가 필요합니다.\n\n" +) + + +def diarization_annotation(diarization: Any) -> Any: + """pyannote.audio 4.x는 DiarizeOutput; 구간은 .speaker_diarization에 있다.""" + ann = getattr(diarization, "speaker_diarization", None) + return diarization if ann is None else ann + + +def validate_pyannote_snapshot(model_dir: Path | str) -> None: + cfg = Path(model_dir) / "config.yaml" + if cfg.is_file(): + return + p = Path(model_dir).resolve() + raise ValueError( + f"pyannote 모델 폴더가 불완전합니다 (config.yaml 없음): {p}. " + "hf download pyannote/speaker-diarization-3.1 --local-dir ./models/pyannote-diarization-3.1" + ) + + +def resolve_local_diarize_dir(override: str | None) -> Path: + if override: + path = Path(override).expanduser().resolve() + if path.is_dir(): + return path + raise ValueError(f"화자 분리 모델 폴더가 없습니다: {path}") + + for cand in (os.environ.get("WHISPER_DIARIZE_MODEL_DIR"), os.environ.get("PYANNOTE_MODEL_DIR")): + if cand: + path = Path(cand).expanduser().resolve() + if path.is_dir(): + return path + + path = DEFAULT_DIARIZE_MODEL_DIR.resolve() + if path.is_dir(): + return path + raise ValueError( + f"화자 분리 모델 폴더가 없습니다: {path}. " + "프로젝트 루트에서: hf download pyannote/speaker-diarization-3.1 " + "--local-dir ./models/pyannote-diarization-3.1 (약관 동의·HF 토큰 필요)" + ) + + +def speaker_turns(audio_path: str, *, model_dir: str | None = None) -> list[tuple[float, float, str]]: + import torch + + resolved = resolve_local_diarize_dir(model_dir) + validate_pyannote_snapshot(resolved) + pipeline = load_pyannote_pipeline(resolved) + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + pipeline.to(device) + diarization = pipeline(audio_path) + ann = diarization_annotation(diarization) + turns: list[tuple[float, float, str]] = [] + for segment, _, label in ann.itertracks(yield_label=True): + turns.append((float(segment.start), float(segment.end), str(label))) + turns.sort(key=lambda x: x[0]) + return turns + + +def _overlap_sec(a0: float, a1: float, b0: float, b1: float) -> float: + return max(0.0, min(a1, b1) - max(a0, b0)) + + +def _assign_speaker( + seg_start: float, seg_end: float, turns: list[tuple[float, float, str]] +) -> str | None: + best: str | None = None + best_ov = 0.0 + for t0, t1, sp in turns: + ov = _overlap_sec(seg_start, seg_end, t0, t1) + if ov > best_ov: + best_ov = ov + best = sp + if best is None or best_ov < 0.05: + return None + return best + + +def speaker_label_order(turns: list[tuple[float, float, str]]) -> dict[str, str]: + order: list[str] = [] + for t0, _, sp in sorted(turns, key=lambda x: x[0]): + if sp not in order: + order.append(sp) + + def letter(i: int) -> str: + if i < 26: + return chr(ord("A") + i) + return f"SP{i + 1}" + + return {sp: letter(i) for i, sp in enumerate(order)} + + +def format_diarized_text( + whisper_segments: list[dict[str, Any]], + turns: list[tuple[float, float, str]], +) -> str: + labels = speaker_label_order(turns) + lines: list[str] = [] + current_letter: str | None = None + current_parts: list[str] = [] + + def flush() -> None: + nonlocal current_letter, current_parts + if current_letter is not None and current_parts: + lines.append(f"{current_letter}: {' '.join(current_parts).strip()}") + current_letter = None + current_parts = [] + + for seg in whisper_segments: + text = (seg.get("text") or "").strip() + if not text: + continue + start = float(seg["start"]) + end = float(seg["end"]) + sp = _assign_speaker(start, end, turns) + letter = labels.get(sp, "?") if sp is not None else "?" + + if letter == current_letter: + current_parts.append(text) + else: + flush() + current_letter = letter + current_parts = [text] + + flush() + return "\n".join(lines) + + +def segments_with_speakers( + whisper_segments: list[dict[str, Any]], + turns: list[tuple[float, float, str]], +) -> list[dict[str, Any]]: + labels = speaker_label_order(turns) + out: list[dict[str, Any]] = [] + for seg in whisper_segments: + text = (seg.get("text") or "").strip() + if not text: + continue + sp = _assign_speaker(float(seg["start"]), float(seg["end"]), turns) + letter = labels.get(sp, "?") if sp is not None else "?" + out.append({**seg, "text": text, "speaker": letter}) + return out + + +def build_diarized_output( + whisper_segments: list[dict[str, Any]], + audio_path: str, + *, + model_dir: str | None = None, + with_disclaimer: bool = True, +) -> tuple[str, list[dict[str, Any]]]: + turns = speaker_turns(audio_path, model_dir=model_dir) + body = format_diarized_text(whisper_segments, turns) + text = (DIARIZE_DISCLAIMER_KO + body) if with_disclaimer else body + segs = segments_with_speakers(whisper_segments, turns) + return text, segs diff --git a/app/main.py b/app/main.py index 9abe7aa..ef672ae 100644 --- a/app/main.py +++ b/app/main.py @@ -61,6 +61,7 @@ class _Job: language: str | None vad_filter: bool beam_size: int + diarize: bool author_id: str language_requested: str | None status: str = "queued" # queued|running|completed|failed|cancelled @@ -128,6 +129,7 @@ async def api_create_job( language: str = Form(default="ko"), vad_filter: bool = Form(default=True), beam_size: int = Form(default=5), + diarize: bool = Form(default=True), author_id: str = Form(default=DEFAULT_AUTHOR_ID), ) -> dict[str, Any]: _cleanup_jobs() @@ -146,6 +148,7 @@ async def api_create_job( language=(lang or None), vad_filter=bool(vad_filter), beam_size=int(beam_size), + diarize=bool(diarize), author_id=(author_id.strip() or DEFAULT_AUTHOR_ID), language_requested=(language.strip() or None), status="queued", @@ -188,6 +191,7 @@ async def api_transcribe( language: str = Form(default="ko"), vad_filter: bool = Form(default=True), beam_size: int = Form(default=5), + diarize: bool = Form(default=True), author_id: str = Form(default=DEFAULT_AUTHOR_ID), ) -> dict[str, Any]: _validate_upload(file) @@ -203,6 +207,7 @@ async def api_transcribe( language=(lang or None), vad_filter=bool(vad_filter), beam_size=int(beam_size), + diarize=bool(diarize), ) # 단발성 API도 DB 저장 try: @@ -357,6 +362,7 @@ def _run_job(job_id: str) -> None: language = job.language vad_filter = job.vad_filter beam_size = job.beam_size + do_diarize = job.diarize author_id = job.author_id language_requested = job.language_requested filename = job.filename @@ -421,6 +427,34 @@ def _run_job(job_id: str) -> None: job.progress = None job.updated_at = time.time() + if not cancelled and do_diarize: + with _JOBS_LOCK: + job = _JOBS.get(job_id) + if job is None: + return + if job.cancel_event.is_set(): + cancelled = True + else: + segs_snapshot = [dict(s) for s in job.segments] + path_for_diar = job.tmp_path + + if not cancelled and segs_snapshot: + from . import diarize as dz + + mdir = os.getenv("APP_PYANNOTE_MODEL_DIR") or None + with _JOBS_LOCK: + job = _JOBS.get(job_id) + if job is not None: + job.progress = 0.97 + job.updated_at = time.time() + text_d, segs_d = dz.build_diarized_output(segs_snapshot, path_for_diar, model_dir=mdir) + with _JOBS_LOCK: + job = _JOBS.get(job_id) + if job is not None: + job.text = text_d + job.segments = segs_d + job.updated_at = time.time() + with _JOBS_LOCK: job = _JOBS.get(job_id) if job is None: diff --git a/app/static/index.html b/app/static/index.html index 7c725da..d1de38a 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -305,6 +305,11 @@ VAD 필터 (무음 구간 감소) + +
@@ -314,7 +319,8 @@
- 허용: mp3, m4a, wav, mp4, aac, ogg, flac, webm
- - 첫 실행 시 Whisper 모델 다운로드로 시간이 걸릴 수 있습니다. + - 첫 실행 시 Whisper 모델 다운로드로 시간이 걸릴 수 있습니다.
+ - 화자 분리 켜짐: ./models/pyannote-diarization-3.1 및 gated HF 모델 동의(README 참고).
@@ -420,6 +426,7 @@ const progTextEl = $("progText"); const downloadEl = $("download"); const clearEl = $("clear"); + const diarizeEl = $("diarize"); const healthEl = $("health"); const metaEl = $("meta"); const timingEl = $("timing"); @@ -677,6 +684,7 @@ const author = (authorEl?.value || "").trim(); if (author) fd.append("author_id", author); fd.append("vad_filter", $("vad").checked ? "true" : "false"); + fd.append("diarize", !diarizeEl || diarizeEl.checked ? "true" : "false"); fd.append("beam_size", $("beam").value); uploadController = new AbortController(); diff --git a/app/stt.py b/app/stt.py index 1f60644..c3eb9b8 100644 --- a/app/stt.py +++ b/app/stt.py @@ -54,6 +54,8 @@ def transcribe_file( language: str | None = None, vad_filter: bool = True, beam_size: int = 5, + diarize: bool = True, + diarize_model_dir: str | None = None, ) -> dict[str, Any]: segments_iter, info = transcribe_iter( audio_path, @@ -70,10 +72,23 @@ def transcribe_file( segments.append(seg) texts.append(seg.text) + seg_dicts = [seg.__dict__ for seg in segments] full_text = "\n".join(texts).strip() + + if diarize: + from . import diarize as dz + + mdir = diarize_model_dir or os.getenv("APP_PYANNOTE_MODEL_DIR") or None + full_text, seg_dicts = dz.build_diarized_output( + seg_dicts, + audio_path, + model_dir=mdir, + with_disclaimer=True, + ) + return { "text": full_text, - "segments": [seg.__dict__ for seg in segments], + "segments": seg_dicts, "detected_language": getattr(info, "language", None), "language_probability": getattr(info, "language_probability", None), "duration_sec": getattr(info, "duration", None), diff --git a/requirements.txt b/requirements.txt index 0ae3437..07a49ae 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,6 @@ faster-whisper imageio-ffmpeg psycopg[binary] python-dotenv +# 화자 분리(웹·whisper_stt 공통): torch는 pyannote 의존성으로 설치되며, Linux에서 꼬이면 README·requirements-whisper-stt.txt 참고 +huggingface_hub>=0.26.0 +pyannote.audio>=3.1.0