Compare commits
10 Commits
ff5086db81
...
14df6d8805
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14df6d8805 | ||
|
|
e74805f2fd | ||
|
|
1c7e09ffcf | ||
|
|
49c2e4a5a7 | ||
|
|
5edd0b32e7 | ||
|
|
723a964d9c | ||
|
|
9ec998e1f1 | ||
|
|
26ff9b59c2 | ||
|
|
2caa74ac05 | ||
|
|
a984b86766 |
10
.cursor/rules/git-push-after-changes.mdc
Normal file
10
.cursor/rules/git-push-after-changes.mdc
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
description: 코드·문서 수정 후 origin/main에 커밋하고 git push까지 한다
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Git: 수정 후 push
|
||||||
|
|
||||||
|
- 이 저장소에서 **의도한 변경**을 마치면 `git add` → **의미 있는 커밋 메시지**로 `git commit` → **`git push origin main`** 까지 수행한다.
|
||||||
|
- 사용자가 별도로 말하지 않아도, 작업 완료 시점에 푸시한다(원격이 `main`이 아니면 해당 브랜치).
|
||||||
|
- 커밋에 포함하면 안 되는 로컬 전용 파일은 `.gitignore`를 따른다.
|
||||||
154
README.md
154
README.md
@@ -4,8 +4,9 @@
|
|||||||
|
|
||||||
- **백엔드**: FastAPI (업로드/검증/STT 수행)
|
- **백엔드**: FastAPI (업로드/검증/STT 수행)
|
||||||
- **STT 엔진**: `faster-whisper` (Whisper 모델)
|
- **STT 엔진**: `faster-whisper` (Whisper 모델)
|
||||||
|
- **화자 분리(웹 기본 켜짐)**: `pyannote` — `whisper_stt.py`와 동일하게 로컬 `./models/pyannote-diarization-3.1` + HF 토큰·게이트 동의 필요. UI/API에서 끌 수 있음(`diarize=false`).
|
||||||
- **프론트**: 단일 HTML (파일 선택 → 전사 → 결과 표시/다운로드)
|
- **프론트**: 단일 HTML (파일 선택 → 전사 → 결과 표시/다운로드)
|
||||||
- **선택 CLI**: `whisper_stt.py` — OpenAI Whisper 기반 로컬 전사(**기본: 화자 구분**, 로컬 `./models/pyannote-diarization-3.1`)
|
- **선택 CLI**: `whisper_stt.py` — OpenAI Whisper 기반 로컬 전사(**기본: 화자 구분**, 동일 pyannote 스냅샷)
|
||||||
|
|
||||||
## 동작 개요 (pseudocode)
|
## 동작 개요 (pseudocode)
|
||||||
|
|
||||||
@@ -71,9 +72,14 @@ brew install ffmpeg
|
|||||||
```bash
|
```bash
|
||||||
conda create -n stt python=3.11 -y
|
conda create -n stt python=3.11 -y
|
||||||
conda activate stt
|
conda activate stt
|
||||||
|
pip install -U pip setuptools wheel
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
pip install -r requirements-diarize.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Linux에서 `pip install -r requirements.txt` 만으로 `resolution-too-deep` 이 난 경우**
|
||||||
|
예전에는 `pyannote.audio>=3.1.0` 이 한 파일에 있어 최신 버전까지 풀며 그래프가 깊어질 수 있었습니다. 지금은 **베이스**(`requirements.txt`)와 **화자 분리**(`requirements-diarize.txt`, `torch`·`pyannote.audio==3.3.2` 고정)로 나뉘어 있습니다. 위처럼 **두 파일을 순서대로** 설치하고, 그래도 실패하면 **conda로 PyTorch 먼저** 한 뒤 `pip install -r requirements-diarize.txt` 만 다시 시도해 보세요 (`requirements-whisper-stt.txt` 절의 torch 복구 절차 참고).
|
||||||
|
|
||||||
### 2) (선택) 로컬 전사 CLI — `whisper_stt.py`
|
### 2) (선택) 로컬 전사 CLI — `whisper_stt.py`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -140,7 +146,7 @@ conda env create -f environment.yml
|
|||||||
conda activate ncue
|
conda activate ncue
|
||||||
```
|
```
|
||||||
|
|
||||||
`pip` 의존성은 `requirements.txt`를 통해 설치됩니다. 팀에서 이미 `ncue`를 쓰는 경우에만 사용해도 됩니다.
|
`pip` 의존성은 `requirements.txt`와 `requirements-diarize.txt`를 통해 설치됩니다. 팀에서 이미 `ncue`를 쓰는 경우에만 사용해도 됩니다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -151,9 +157,100 @@ conda activate stt # 또는 ncue
|
|||||||
uvicorn app.main:app --reload --host 127.0.0.1 --port 8025
|
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` 접속.
|
브라우저에서 `http://127.0.0.1:8025` 접속.
|
||||||
|
|
||||||
웹 UI는 **faster-whisper** 전사만 수행합니다. 화자 구분이 필요하면 **`whisper_stt.py`**(로컬 CLI)를 사용하세요.
|
웹 UI는 **faster-whisper** 전사와(옵션) **pyannote 화자 분리**를 지원합니다. OpenAI Whisper 기반 CLI는 **`whisper_stt.py`** 를 사용하세요. 비동기 작업(`POST /api/jobs`)은 **서버 스레드에서 계속** 돌아가며, UI는 **`?job=<uuid>`·`localStorage`** 로 새로고침 후에도 같은 작업을 다시 조회·폴링합니다(메모리 TTL `APP_JOB_TTL_SEC` 초과 시 job 은 사라짐).
|
||||||
|
|
||||||
|
### HTTPS 도메인(`https://ncue.net/stt/…`)에서 503 (Service Unavailable)
|
||||||
|
|
||||||
|
브라우저가 **503**을 보여 줄 때, 대부분 **리버스 프록시(nginx·Apache 등)가 백엔드(uvicorn)에 붙지 못했다**는 뜻입니다. 이 저장소의 앱은 일반적으로 **503을 직접 내지 않습니다.**
|
||||||
|
|
||||||
|
**흔한 원인**
|
||||||
|
|
||||||
|
1. **`run.sh`를 다른 머신에서만 실행한 경우**
|
||||||
|
`run.sh`는 uvicorn을 **`127.0.0.1:8025`**에만 띄웁니다. **웹 서버(프록시)가 돌아가는 서버와 같은 호스트**에서 프로세스가 떠 있어야 `https://도메인/stt/` 프록시가 연결됩니다. 노트북에서만 `./run.sh`를 켜 두면 공개 도메인 쪽 업스트림은 비어 있어 **503**이 납니다.
|
||||||
|
|
||||||
|
2. **프로세스가 곧바로 종료된 경우**
|
||||||
|
DB 설정 오류, **import 실패(NumPy·torch 등)** 로 uvicorn이 뜨자마자 죽으면 프록시도 503을 냅니다. **서버에서** `tail -n 100 server.log` 로 스택 트레이스를 확인하세요.
|
||||||
|
|
||||||
|
3. **포트·프록시 설정 불일치**
|
||||||
|
프록시가 가리키는 포트가 실제 uvicorn 포트(`PORT`, 기본 8025)와 같아야 합니다.
|
||||||
|
|
||||||
|
#### 구형 CPU / NumPy `X86_V2` 오류 (`server.log`)
|
||||||
|
|
||||||
|
다음처럼 **앱이 시작조차 못 하고** 죽는 경우가 있습니다.
|
||||||
|
|
||||||
|
`RuntimeError: NumPy was built with baseline optimizations: (X86_V2) but your machine doesn't support: (X86_V2).`
|
||||||
|
|
||||||
|
의미: 설치된 NumPy **바이너리**가 x86_64_v2(AVX2 등)를 요구하는데, 서버 CPU가 이를 지원하지 않습니다. (가상머신·오래된 Xeon 등에서 흔합니다.)
|
||||||
|
|
||||||
|
**조치** (웹 서버에서, 사용 중인 conda env — 예: `ncue`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda activate ncue
|
||||||
|
pip install "numpy>=1.26,<2.2" --force-reinstall
|
||||||
|
pip install -r requirements.txt
|
||||||
|
pip install -r requirements-diarize.txt
|
||||||
|
./run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
pip만으로 안 되면 **conda-forge** NumPy가 해당 CPU에 맞는 빌드를 주는 경우가 많습니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
conda install -n ncue "numpy<2.2" -c conda-forge
|
||||||
|
```
|
||||||
|
|
||||||
|
이 저장소 `requirements.txt`에는 위와 같이 **`numpy<2.2`** 를 명시해 두었습니다. 이미 깨진 환경은 위처럼 한 번 재설치하면 됩니다.
|
||||||
|
|
||||||
|
**`pyannote-metrics 4.x` 와 NumPy 버전 경고**
|
||||||
|
`pyannote-metrics` 4.0.0 이상은 **`numpy>=2.2.2`** 를 요구해, 위 `numpy<2.2` 정책과 맞지 않습니다. `requirements-diarize.txt` 에서 **`pyannote-metrics>=3.2,<4`** 로 상한을 두고, **`pyannote-core` / `pyannote-database` / `pyannote-pipeline` 도 6대 미만 등 3.x 스택**에 맞게 캡해 두었습니다. (CPU가 **X86_V2를 지원**하고 NumPy 2.2+ 휠을 쓸 수 있다면, 팀 정책에 따라 `numpy` 상한을 올리고 metrics 4.x + `pyannote-audio` 4.x를 쓰는 선택도 가능합니다.)
|
||||||
|
|
||||||
|
**`pyannote-audio 4.x` 와 `pyannote-metrics 3.x` 충돌**
|
||||||
|
`pyannote-audio` 4.x 는 **`pyannote-metrics>=4`** 를 요구합니다. metrics 를 3.x로 두려면 **audio 도 3.3.2** 여야 합니다. 경고가 나오면:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip uninstall -y pyannote-audio pyannote.audio
|
||||||
|
pip install -r requirements-diarize.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
구형 CPU용으로 **`numpy<2.2`** 를 쓰는 경우, 위 설치 후 NumPy가 올라갔다면 `pip install "numpy>=1.26,<2.2" --force-reinstall` 로 다시 맞춥니다.
|
||||||
|
|
||||||
|
**같은 서버에서 빠른 점검**
|
||||||
|
|
||||||
|
```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** 하세요.
|
||||||
|
|
||||||
|
**Apache (`httpd`) 예시** (`mod_proxy`, `mod_proxy_http` 필요)
|
||||||
|
|
||||||
|
```apache
|
||||||
|
ProxyPass "/stt/" "http://127.0.0.1:8025/"
|
||||||
|
ProxyPassReverse "/stt/" "http://127.0.0.1:8025/"
|
||||||
|
```
|
||||||
|
|
||||||
|
설정 반영 후 `apachectl configtest` 및 재시작(또는 reload) 하세요.
|
||||||
|
|
||||||
|
공개 URL은 가능하면 **`https://예시/stt/`** 처럼 **슬래시까지 포함**해 두면, UI의 상대 경로(`healthz`, `api/…`)가 같은 접두사 아래로 잘 붙습니다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -161,15 +258,64 @@ uvicorn app.main:app --reload --host 127.0.0.1 --port 8025
|
|||||||
|
|
||||||
- **모델**: 기본 `small` (정확도/속도 균형). `APP_WHISPER_MODEL=base|small|medium|large-v3` 등으로 변경 가능.
|
- **모델**: 기본 `small` (정확도/속도 균형). `APP_WHISPER_MODEL=base|small|medium|large-v3` 등으로 변경 가능.
|
||||||
- **디바이스**: 기본 CPU. Apple Silicon에서 Metal은 `faster-whisper` 단독으로는 제한이 있어 CPU 기본값을 권장.
|
- **디바이스**: 기본 CPU. Apple Silicon에서 Metal은 `faster-whisper` 단독으로는 제한이 있어 CPU 기본값을 권장.
|
||||||
|
- **화자 분리**: `pip install -r requirements-diarize.txt`에 `pyannote.audio`·`torch` 등이 포함됩니다(베이스 `requirements.txt` 설치 후). 모델 폴더는 `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` 예시를 참고.
|
- **기타**: `APP_WHISPER_DEVICE`, `APP_WHISPER_COMPUTE_TYPE`, 업로드 크기 등은 `app/main.py` 및 `.env` 예시를 참고.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Linux + Conda: `libtinfo.so.6` / `vi`·`bash` 경고
|
||||||
|
|
||||||
|
`(ncue)` 등 conda 환경을 켠 뒤 **`LD_LIBRARY_PATH`에 `${CONDA_PREFIX}/lib`가 들어가면**, 터미널에서 돌아가는 **시스템 프로그램(`vi`, `bash`, `less` 등)** 이 conda에 들어 있는 `libtinfo.so.6`을 먼저 물 수 있습니다. 그 라이브러리에 ELF 버전 태그가 맞지 않을 때 **«no version information available»** 가 납니다. (`run.sh`는 스크립트 안에서 이를 피하도록 처리해 두었습니다.)
|
||||||
|
|
||||||
|
**`conda deactivate` 해도 경고가 남는 경우**
|
||||||
|
|
||||||
|
- `deactivate`만으로 **`LD_LIBRARY_PATH`가 비워지지 않을 수 있습니다** (이전 셸 값 복원 실패, `.bashrc`에서 직접 export, 다른 툴이 덧붙임).
|
||||||
|
- **`vi`가 conda·vim 빌드**일 수 있습니다. `type -a vi`, `which -a vi` 로 확인하고, 가능하면 **`/usr/bin/vi`** 또는 **`/usr/bin/vim`** 을 쓰세요.
|
||||||
|
|
||||||
|
**당장 편집·실행**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
unset LD_LIBRARY_PATH
|
||||||
|
vi run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
또는 한 줄로:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
env -u LD_LIBRARY_PATH /usr/bin/vi run.sh
|
||||||
|
LD_LIBRARY_PATH= /usr/bin/vi run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
저장소 헬퍼 (`chmod +x scripts/env-no-ld.sh` 후):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/env-no-ld.sh vi run.sh
|
||||||
|
./scripts/env-no-ld.sh /usr/bin/vi run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**원인 확인**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo "LD_LIBRARY_PATH=${LD_LIBRARY_PATH-<비어 있음>}"
|
||||||
|
type -a vi
|
||||||
|
conda deactivate
|
||||||
|
echo "LD_LIBRARY_PATH=${LD_LIBRARY_PATH-<비어 있음>}" # 여전히 miniconda 경로면 .bashrc 등 확인
|
||||||
|
grep -n LD_LIBRARY_PATH ~/.bashrc ~/.profile ~/.bash_profile 2>/dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
**장기적으로**
|
||||||
|
|
||||||
|
1. `grep -r LD_LIBRARY_PATH "$HOME/workspace/miniconda3/envs/ncue/etc/conda/activate.d/"` 등으로 어떤 패키지가 넣는지 확인합니다.
|
||||||
|
2. GPU/torch가 꼭 필요한 터미널과 **편집·git용 터미널**을 나누거나, 편집 전에 `unset LD_LIBRARY_PATH` 를 습관화합니다.
|
||||||
|
3. `conda install -c conda-forge ncurses` 로 env 안 ncurses를 맞추면 완화되는 경우가 있습니다(환경마다 다름).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 플랫폼 요약
|
## 플랫폼 요약
|
||||||
|
|
||||||
| 항목 | Ubuntu | macOS |
|
| 항목 | Ubuntu | macOS |
|
||||||
|------|--------|--------|
|
|------|--------|--------|
|
||||||
| `ffmpeg` | `sudo apt install ffmpeg` | `brew install ffmpeg` |
|
| `ffmpeg` | `sudo apt install ffmpeg` | `brew install ffmpeg` |
|
||||||
| Python | Conda `stt` 권장 | 동일 |
|
| Python | Conda `stt` 권장 | 동일 |
|
||||||
| 웹 STT | `pip install -r requirements.txt` | 동일 |
|
| 웹 STT | `pip install -r requirements.txt` 후 `pip install -r requirements-diarize.txt` | 동일 |
|
||||||
| `whisper_stt.py` | `pip install -r requirements-whisper-stt.txt` | 동일 |
|
| `whisper_stt.py` | `pip install -r requirements-whisper-stt.txt` | 동일 |
|
||||||
|
|||||||
172
app/diarize.py
Normal file
172
app/diarize.py
Normal file
@@ -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
|
||||||
34
app/main.py
34
app/main.py
@@ -61,6 +61,7 @@ class _Job:
|
|||||||
language: str | None
|
language: str | None
|
||||||
vad_filter: bool
|
vad_filter: bool
|
||||||
beam_size: int
|
beam_size: int
|
||||||
|
diarize: bool
|
||||||
author_id: str
|
author_id: str
|
||||||
language_requested: str | None
|
language_requested: str | None
|
||||||
status: str = "queued" # queued|running|completed|failed|cancelled
|
status: str = "queued" # queued|running|completed|failed|cancelled
|
||||||
@@ -128,6 +129,7 @@ async def api_create_job(
|
|||||||
language: str = Form(default="ko"),
|
language: str = Form(default="ko"),
|
||||||
vad_filter: bool = Form(default=True),
|
vad_filter: bool = Form(default=True),
|
||||||
beam_size: int = Form(default=5),
|
beam_size: int = Form(default=5),
|
||||||
|
diarize: bool = Form(default=True),
|
||||||
author_id: str = Form(default=DEFAULT_AUTHOR_ID),
|
author_id: str = Form(default=DEFAULT_AUTHOR_ID),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
_cleanup_jobs()
|
_cleanup_jobs()
|
||||||
@@ -146,6 +148,7 @@ async def api_create_job(
|
|||||||
language=(lang or None),
|
language=(lang or None),
|
||||||
vad_filter=bool(vad_filter),
|
vad_filter=bool(vad_filter),
|
||||||
beam_size=int(beam_size),
|
beam_size=int(beam_size),
|
||||||
|
diarize=bool(diarize),
|
||||||
author_id=(author_id.strip() or DEFAULT_AUTHOR_ID),
|
author_id=(author_id.strip() or DEFAULT_AUTHOR_ID),
|
||||||
language_requested=(language.strip() or None),
|
language_requested=(language.strip() or None),
|
||||||
status="queued",
|
status="queued",
|
||||||
@@ -188,6 +191,7 @@ async def api_transcribe(
|
|||||||
language: str = Form(default="ko"),
|
language: str = Form(default="ko"),
|
||||||
vad_filter: bool = Form(default=True),
|
vad_filter: bool = Form(default=True),
|
||||||
beam_size: int = Form(default=5),
|
beam_size: int = Form(default=5),
|
||||||
|
diarize: bool = Form(default=True),
|
||||||
author_id: str = Form(default=DEFAULT_AUTHOR_ID),
|
author_id: str = Form(default=DEFAULT_AUTHOR_ID),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
_validate_upload(file)
|
_validate_upload(file)
|
||||||
@@ -203,6 +207,7 @@ async def api_transcribe(
|
|||||||
language=(lang or None),
|
language=(lang or None),
|
||||||
vad_filter=bool(vad_filter),
|
vad_filter=bool(vad_filter),
|
||||||
beam_size=int(beam_size),
|
beam_size=int(beam_size),
|
||||||
|
diarize=bool(diarize),
|
||||||
)
|
)
|
||||||
# 단발성 API도 DB 저장
|
# 단발성 API도 DB 저장
|
||||||
try:
|
try:
|
||||||
@@ -357,6 +362,7 @@ def _run_job(job_id: str) -> None:
|
|||||||
language = job.language
|
language = job.language
|
||||||
vad_filter = job.vad_filter
|
vad_filter = job.vad_filter
|
||||||
beam_size = job.beam_size
|
beam_size = job.beam_size
|
||||||
|
do_diarize = job.diarize
|
||||||
author_id = job.author_id
|
author_id = job.author_id
|
||||||
language_requested = job.language_requested
|
language_requested = job.language_requested
|
||||||
filename = job.filename
|
filename = job.filename
|
||||||
@@ -421,6 +427,34 @@ def _run_job(job_id: str) -> None:
|
|||||||
job.progress = None
|
job.progress = None
|
||||||
job.updated_at = time.time()
|
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:
|
with _JOBS_LOCK:
|
||||||
job = _JOBS.get(job_id)
|
job = _JOBS.get(job_id)
|
||||||
if job is None:
|
if job is None:
|
||||||
|
|||||||
@@ -305,6 +305,11 @@
|
|||||||
VAD 필터 (무음 구간 감소)
|
VAD 필터 (무음 구간 감소)
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<input id="diarize" type="checkbox" checked />
|
||||||
|
화자 분리 (pyannote, whisper_stt.py와 동일 방식 — 서버에 로컬 모델·HF 토큰 필요)
|
||||||
|
</label>
|
||||||
|
|
||||||
<div class="row" style="margin-top: 12px">
|
<div class="row" style="margin-top: 12px">
|
||||||
<button class="btn primary" id="go" disabled>전사(STT) 실행</button>
|
<button class="btn primary" id="go" disabled>전사(STT) 실행</button>
|
||||||
<button class="btn" id="cancel" disabled>취소</button>
|
<button class="btn" id="cancel" disabled>취소</button>
|
||||||
@@ -314,7 +319,9 @@
|
|||||||
|
|
||||||
<div class="hint">
|
<div class="hint">
|
||||||
- 허용: mp3, m4a, wav, mp4, aac, ogg, flac, webm<br />
|
- 허용: mp3, m4a, wav, mp4, aac, ogg, flac, webm<br />
|
||||||
- 첫 실행 시 Whisper 모델 다운로드로 시간이 걸릴 수 있습니다.
|
- 첫 실행 시 Whisper 모델 다운로드로 시간이 걸릴 수 있습니다.<br />
|
||||||
|
- 화자 분리 켜짐: <span class="mono">./models/pyannote-diarization-3.1</span> 및 gated HF 모델 동의(README 참고).<br />
|
||||||
|
- 전사는 서버에서 계속 실행됩니다. 새로고침·다른 탭 후에도 주소의 <span class="mono">?job=…</span> 또는 이 브라우저 저장값으로 진행 상황을 다시 붙입니다.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="progress">
|
<div class="progress">
|
||||||
@@ -420,11 +427,15 @@
|
|||||||
const progTextEl = $("progText");
|
const progTextEl = $("progText");
|
||||||
const downloadEl = $("download");
|
const downloadEl = $("download");
|
||||||
const clearEl = $("clear");
|
const clearEl = $("clear");
|
||||||
|
const diarizeEl = $("diarize");
|
||||||
const healthEl = $("health");
|
const healthEl = $("health");
|
||||||
const metaEl = $("meta");
|
const metaEl = $("meta");
|
||||||
const timingEl = $("timing");
|
const timingEl = $("timing");
|
||||||
|
|
||||||
const allowedExt = [".mp3", ".m4a", ".wav", ".mp4", ".aac", ".ogg", ".flac", ".webm"];
|
const allowedExt = [".mp3", ".m4a", ".wav", ".mp4", ".aac", ".ogg", ".flac", ".webm"];
|
||||||
|
const STORAGE_ACTIVE_JOB = "stt_active_job_id";
|
||||||
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
let currentJobId = null;
|
let currentJobId = null;
|
||||||
let pollTimer = null;
|
let pollTimer = null;
|
||||||
let startedAt = null;
|
let startedAt = null;
|
||||||
@@ -432,6 +443,107 @@
|
|||||||
let lastSegCount = 0;
|
let lastSegCount = 0;
|
||||||
let lastTextLen = 0;
|
let lastTextLen = 0;
|
||||||
|
|
||||||
|
/** 서버 작업 ID를 브라우저에 남겨 새로고침 후에도 폴링 복구 */
|
||||||
|
function syncActiveJobToBrowser(jobId) {
|
||||||
|
try {
|
||||||
|
if (jobId) {
|
||||||
|
localStorage.setItem(STORAGE_ACTIVE_JOB, jobId);
|
||||||
|
const u = new URL(window.location.href);
|
||||||
|
u.searchParams.set("job", jobId);
|
||||||
|
history.replaceState(null, "", u.pathname + u.search + u.hash);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(STORAGE_ACTIVE_JOB);
|
||||||
|
const u = new URL(window.location.href);
|
||||||
|
u.searchParams.delete("job");
|
||||||
|
history.replaceState(null, "", u.pathname + u.search + u.hash);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryResumeJob() {
|
||||||
|
let jobId = null;
|
||||||
|
try {
|
||||||
|
const u = new URL(window.location.href);
|
||||||
|
jobId = u.searchParams.get("job") || localStorage.getItem(STORAGE_ACTIVE_JOB);
|
||||||
|
} catch (_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!jobId || !UUID_RE.test(jobId.trim())) {
|
||||||
|
if (jobId) syncActiveJobToBrowser(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
jobId = jobId.trim();
|
||||||
|
|
||||||
|
const r = await fetch(`api/jobs/${encodeURIComponent(jobId)}`);
|
||||||
|
if (!r.ok) {
|
||||||
|
syncActiveJobToBrowser(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = await r.json().catch(() => ({}));
|
||||||
|
const st = body.status;
|
||||||
|
if (!st) {
|
||||||
|
syncActiveJobToBrowser(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentJobId = jobId;
|
||||||
|
lastSegCount = 0;
|
||||||
|
lastTextLen = 0;
|
||||||
|
|
||||||
|
const text = body.text || "";
|
||||||
|
outEl.value = text;
|
||||||
|
lastTextLen = text.length;
|
||||||
|
const segs = Array.isArray(body.segments) ? body.segments : [];
|
||||||
|
segEl.value = segs.length ? JSON.stringify(segs, null, 2) : "";
|
||||||
|
lastSegCount = segs.length;
|
||||||
|
setProgress(typeof body.progress === "number" ? body.progress : null);
|
||||||
|
|
||||||
|
const lang = body.detected_language ? `${body.detected_language}` : "-";
|
||||||
|
const prob = typeof body.language_probability === "number" ? body.language_probability.toFixed(3) : "-";
|
||||||
|
const dur = typeof body.duration_sec === "number" ? `${body.duration_sec.toFixed(1)}s` : "-";
|
||||||
|
metaEl.textContent = `감지 언어: ${lang} (p=${prob}), 오디오 길이: ${dur}`;
|
||||||
|
|
||||||
|
if (st === "completed" || st === "failed" || st === "cancelled") {
|
||||||
|
syncActiveJobToBrowser(null);
|
||||||
|
currentJobId = null;
|
||||||
|
stopPolling();
|
||||||
|
if (st === "completed") {
|
||||||
|
setStatus("완료(재접속 시 결과 표시)");
|
||||||
|
setProgress(1);
|
||||||
|
setError("");
|
||||||
|
} else if (st === "failed") {
|
||||||
|
setStatus("실패");
|
||||||
|
setError(body.error || "실패");
|
||||||
|
} else {
|
||||||
|
setStatus("취소됨");
|
||||||
|
setError("");
|
||||||
|
}
|
||||||
|
setIdle();
|
||||||
|
downloadEl.disabled = !outEl.value?.trim();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncActiveJobToBrowser(jobId);
|
||||||
|
const t0 = typeof body.created_at === "number" ? body.created_at : Date.now() / 1000;
|
||||||
|
startedAt = performance.now() - (Date.now() / 1000 - t0) * 1000;
|
||||||
|
setStatus(st === "queued" ? "대기 중 — 서버에서 처리 예정(재접속됨)" : "전사(STT) 처리 중 — 서버에서 실행 중(재접속됨)");
|
||||||
|
metaEl.textContent = `${metaEl.textContent} · 새로고침해도 서버 작업은 계속됩니다`;
|
||||||
|
setRunning();
|
||||||
|
|
||||||
|
await pollJobOnce();
|
||||||
|
stopPolling();
|
||||||
|
pollTimer = setInterval(() => {
|
||||||
|
pollJobOnce().catch((e) => {
|
||||||
|
stopPolling();
|
||||||
|
setError(String(e?.message || e));
|
||||||
|
setStatus("실패");
|
||||||
|
currentJobId = null;
|
||||||
|
syncActiveJobToBrowser(null);
|
||||||
|
setIdle();
|
||||||
|
});
|
||||||
|
}, 700);
|
||||||
|
}
|
||||||
|
|
||||||
// Tabs
|
// Tabs
|
||||||
function setTab(which) {
|
function setTab(which) {
|
||||||
const isAdmin = which === "admin";
|
const isAdmin = which === "admin";
|
||||||
@@ -530,7 +642,17 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
clearEl.addEventListener("click", () => {
|
clearEl.addEventListener("click", () => {
|
||||||
cancelCurrent();
|
stopPolling();
|
||||||
|
if (uploadController) {
|
||||||
|
try {
|
||||||
|
uploadController.abort();
|
||||||
|
} catch (_) {}
|
||||||
|
uploadController = null;
|
||||||
|
} else if (currentJobId) {
|
||||||
|
fetch(`api/jobs/${encodeURIComponent(currentJobId)}/cancel`, { method: "POST" }).catch(() => {});
|
||||||
|
}
|
||||||
|
syncActiveJobToBrowser(null);
|
||||||
|
currentJobId = null;
|
||||||
fileEl.value = "";
|
fileEl.value = "";
|
||||||
outEl.value = "";
|
outEl.value = "";
|
||||||
segEl.value = "";
|
segEl.value = "";
|
||||||
@@ -542,6 +664,7 @@
|
|||||||
goEl.disabled = true;
|
goEl.disabled = true;
|
||||||
cancelEl.disabled = true;
|
cancelEl.disabled = true;
|
||||||
downloadEl.disabled = true;
|
downloadEl.disabled = true;
|
||||||
|
setIdle();
|
||||||
});
|
});
|
||||||
|
|
||||||
downloadEl.addEventListener("click", () => {
|
downloadEl.addEventListener("click", () => {
|
||||||
@@ -625,6 +748,7 @@
|
|||||||
setProgress(1);
|
setProgress(1);
|
||||||
currentJobId = null;
|
currentJobId = null;
|
||||||
uploadController = null;
|
uploadController = null;
|
||||||
|
syncActiveJobToBrowser(null);
|
||||||
setIdle();
|
setIdle();
|
||||||
downloadEl.disabled = !outEl.value?.trim();
|
downloadEl.disabled = !outEl.value?.trim();
|
||||||
return;
|
return;
|
||||||
@@ -634,6 +758,7 @@
|
|||||||
setStatus("취소됨");
|
setStatus("취소됨");
|
||||||
currentJobId = null;
|
currentJobId = null;
|
||||||
uploadController = null;
|
uploadController = null;
|
||||||
|
syncActiveJobToBrowser(null);
|
||||||
setIdle();
|
setIdle();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -643,6 +768,7 @@
|
|||||||
setError(body?.error || "실패");
|
setError(body?.error || "실패");
|
||||||
currentJobId = null;
|
currentJobId = null;
|
||||||
uploadController = null;
|
uploadController = null;
|
||||||
|
syncActiveJobToBrowser(null);
|
||||||
setIdle();
|
setIdle();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -656,6 +782,7 @@
|
|||||||
if (!f) return;
|
if (!f) return;
|
||||||
|
|
||||||
stopPolling();
|
stopPolling();
|
||||||
|
syncActiveJobToBrowser(null);
|
||||||
currentJobId = null;
|
currentJobId = null;
|
||||||
lastSegCount = 0;
|
lastSegCount = 0;
|
||||||
lastTextLen = 0;
|
lastTextLen = 0;
|
||||||
@@ -677,6 +804,7 @@
|
|||||||
const author = (authorEl?.value || "").trim();
|
const author = (authorEl?.value || "").trim();
|
||||||
if (author) fd.append("author_id", author);
|
if (author) fd.append("author_id", author);
|
||||||
fd.append("vad_filter", $("vad").checked ? "true" : "false");
|
fd.append("vad_filter", $("vad").checked ? "true" : "false");
|
||||||
|
fd.append("diarize", !diarizeEl || diarizeEl.checked ? "true" : "false");
|
||||||
fd.append("beam_size", $("beam").value);
|
fd.append("beam_size", $("beam").value);
|
||||||
|
|
||||||
uploadController = new AbortController();
|
uploadController = new AbortController();
|
||||||
@@ -687,6 +815,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
currentJobId = body.job_id;
|
currentJobId = body.job_id;
|
||||||
|
syncActiveJobToBrowser(currentJobId);
|
||||||
uploadController = null;
|
uploadController = null;
|
||||||
setStatus("전사(STT) 처리 중…");
|
setStatus("전사(STT) 처리 중…");
|
||||||
setRunning();
|
setRunning();
|
||||||
@@ -699,6 +828,7 @@
|
|||||||
setStatus("실패");
|
setStatus("실패");
|
||||||
currentJobId = null;
|
currentJobId = null;
|
||||||
uploadController = null;
|
uploadController = null;
|
||||||
|
syncActiveJobToBrowser(null);
|
||||||
setIdle();
|
setIdle();
|
||||||
});
|
});
|
||||||
}, 700);
|
}, 700);
|
||||||
@@ -721,6 +851,7 @@
|
|||||||
|
|
||||||
checkHealth();
|
checkHealth();
|
||||||
setInterval(checkHealth, 5000);
|
setInterval(checkHealth, 5000);
|
||||||
|
tryResumeJob().catch(() => {});
|
||||||
|
|
||||||
// Admin (DB Records)
|
// Admin (DB Records)
|
||||||
const adminQEl = $("admin-q");
|
const adminQEl = $("admin-q");
|
||||||
|
|||||||
17
app/stt.py
17
app/stt.py
@@ -54,6 +54,8 @@ def transcribe_file(
|
|||||||
language: str | None = None,
|
language: str | None = None,
|
||||||
vad_filter: bool = True,
|
vad_filter: bool = True,
|
||||||
beam_size: int = 5,
|
beam_size: int = 5,
|
||||||
|
diarize: bool = True,
|
||||||
|
diarize_model_dir: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
segments_iter, info = transcribe_iter(
|
segments_iter, info = transcribe_iter(
|
||||||
audio_path,
|
audio_path,
|
||||||
@@ -70,10 +72,23 @@ def transcribe_file(
|
|||||||
segments.append(seg)
|
segments.append(seg)
|
||||||
texts.append(seg.text)
|
texts.append(seg.text)
|
||||||
|
|
||||||
|
seg_dicts = [seg.__dict__ for seg in segments]
|
||||||
full_text = "\n".join(texts).strip()
|
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 {
|
return {
|
||||||
"text": full_text,
|
"text": full_text,
|
||||||
"segments": [seg.__dict__ for seg in segments],
|
"segments": seg_dicts,
|
||||||
"detected_language": getattr(info, "language", None),
|
"detected_language": getattr(info, "language", None),
|
||||||
"language_probability": getattr(info, "language_probability", None),
|
"language_probability": getattr(info, "language_probability", None),
|
||||||
"duration_sec": getattr(info, "duration", None),
|
"duration_sec": getattr(info, "duration", None),
|
||||||
|
|||||||
@@ -7,4 +7,5 @@ dependencies:
|
|||||||
- ffmpeg
|
- ffmpeg
|
||||||
- pip:
|
- pip:
|
||||||
- -r requirements.txt
|
- -r requirements.txt
|
||||||
|
- -r requirements-diarize.txt
|
||||||
|
|
||||||
|
|||||||
28
requirements-diarize.txt
Normal file
28
requirements-diarize.txt
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# 웹·로컬 공통: 화자 분리(pyannote). 메인 requirements.txt 설치 후 이 파일을 설치하세요.
|
||||||
|
#
|
||||||
|
# pip install -U pip setuptools wheel
|
||||||
|
# pip install -r requirements.txt
|
||||||
|
# pip install -r requirements-diarize.txt
|
||||||
|
#
|
||||||
|
# 이미 pyannote-audio 4.x 가 깔려 있으면 metrics 3.x 와 절대 맞지 않습니다. 그때는 먼저:
|
||||||
|
# pip uninstall -y pyannote-audio pyannote.audio
|
||||||
|
# pip install -r requirements-diarize.txt
|
||||||
|
#
|
||||||
|
# `pyannote.audio>=3.1.0` 만 단독으로 두면 최신 메이저가 끌려와 torch/lightning/opentelemetry
|
||||||
|
# 등으로 pip 해석 깊이가 커져 Linux에서 resolution-too-deep 이 날 수 있습니다.
|
||||||
|
# 여기서는 pyannote 3.3.x + torch를 고정합니다 (PyPI 메타: torch>=2.0.0).
|
||||||
|
#
|
||||||
|
# CPU Linux: PyTorch 공식 CPU 인덱스
|
||||||
|
--extra-index-url https://download.pytorch.org/whl/cpu
|
||||||
|
torch==2.5.1
|
||||||
|
torchaudio==2.5.1
|
||||||
|
lightning==2.4.0
|
||||||
|
huggingface_hub>=0.26.0,<0.36.0
|
||||||
|
# pyannote 3.x 한 묶음 (audio 4.x 는 metrics>=4·numpy>=2.2.2 쪽으로 가므로 여기서는 전부 3대 유지)
|
||||||
|
pyannote-core>=5.0.0,<6.0.0
|
||||||
|
pyannote-database>=5.0.1,<6.0.0
|
||||||
|
pyannote-pipeline>=3.0.1,<4.0.0
|
||||||
|
# metrics 4.x 는 numpy>=2.2.2 → requirements.txt 의 numpy<2.2 와 충돌 → 3.x 캡
|
||||||
|
pyannote-metrics>=3.2,<4
|
||||||
|
# 배포명은 PyPI에서 pyannote-audio (pip 에서 pyannote.audio 로도 동일)
|
||||||
|
pyannote-audio==3.3.2
|
||||||
@@ -2,7 +2,10 @@ fastapi
|
|||||||
uvicorn[standard]
|
uvicorn[standard]
|
||||||
python-multipart
|
python-multipart
|
||||||
pydantic
|
pydantic
|
||||||
|
# NumPy 2.2+ Linux wheel은 x86_64_v2(AVX2 등) 기준인 경우가 많아, 구형 CPU 서버에서 import 시 RuntimeError가 납니다.
|
||||||
|
numpy>=1.26,<2.2
|
||||||
faster-whisper
|
faster-whisper
|
||||||
imageio-ffmpeg
|
imageio-ffmpeg
|
||||||
psycopg[binary]
|
psycopg[binary]
|
||||||
python-dotenv
|
python-dotenv
|
||||||
|
# 화자 분리(pyannote·HF 허브): requirements-diarize.txt 를 추가로 설치 (torch 고정, pip resolution-too-deep 방지)
|
||||||
|
|||||||
42
run.sh
42
run.sh
@@ -1,19 +1,38 @@
|
|||||||
#!/usr/bin/env bash
|
#!/bin/sh
|
||||||
|
# conda가 LD_LIBRARY_PATH에 lib를 넣은 상태에서 bash가 스크립트를 읽으면
|
||||||
|
# 시스템 bash가 conda의 libtinfo를 물어 경고가 난다. /bin/sh로 한 번 감싼 뒤
|
||||||
|
# LD_LIBRARY_PATH 없이 bash를 다시 실행한다.
|
||||||
|
if [ -z "${STT_RUN_WITH_CLEAN_LDLIB:-}" ]; then
|
||||||
|
export STT_RUN_WITH_CLEAN_LDLIB=1
|
||||||
|
exec env -u LD_LIBRARY_PATH /bin/bash "$0" "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
if [[ -z "${BASH_VERSION:-}" ]]; then
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
echo "ERROR: bash로 실행하세요. 예) ./run.sh 또는 bash run.sh" >&2
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
if [[ -z "${CONDA_BASE:-}" || ! -f "${CONDA_BASE}/bin/activate" ]]; then
|
||||||
|
CONDA_BASE=""
|
||||||
|
for cand in "${HOME}/workspace/miniconda3" "${HOME}/miniconda3" "${HOME}/anaconda3" "/opt/anaconda3"; do
|
||||||
|
if [[ -n "$cand" && -f "$cand/bin/activate" ]]; then
|
||||||
|
CONDA_BASE="$cand"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
if [[ -z "${CONDA_BASE:-}" || ! -f "${CONDA_BASE}/bin/activate" ]]; then
|
||||||
|
echo "ERROR: conda를 찾을 수 없습니다. 예: export CONDA_BASE=/path/to/miniconda3" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cd /home/dsyoon/workspace/stt
|
# 기본은 ncue (기존 동작). 다른 환경은 CONDA_ENV=stt 등으로 지정.
|
||||||
|
CONDA_ENV="${CONDA_ENV:-ncue}"
|
||||||
CONDA_BASE="/home/dsyoon/workspace/miniconda3"
|
# shellcheck source=/dev/null
|
||||||
source "${CONDA_BASE}/bin/activate" ncue
|
source "${CONDA_BASE}/bin/activate" "${CONDA_ENV}"
|
||||||
|
|
||||||
# Torch/ctranslate2 런타임 로딩에 conda lib 경로 필요할 수 있음
|
|
||||||
export LD_LIBRARY_PATH="${CONDA_PREFIX}/lib:${LD_LIBRARY_PATH:-}"
|
|
||||||
|
|
||||||
|
# 전역 LD_LIBRARY_PATH에 conda lib를 넣지 않음 (bash/libtinfo 경고 방지).
|
||||||
|
# torch/ctranslate2 등은 uvicorn 자식에만 경로 전달.
|
||||||
PORT="${PORT:-8025}"
|
PORT="${PORT:-8025}"
|
||||||
RELOAD="${RELOAD:-0}"
|
RELOAD="${RELOAD:-0}"
|
||||||
|
|
||||||
@@ -29,5 +48,6 @@ if [[ "${RELOAD}" == "1" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Starting uvicorn on 127.0.0.1:${PORT} (reload=${RELOAD})"
|
echo "Starting uvicorn on 127.0.0.1:${PORT} (reload=${RELOAD})"
|
||||||
nohup uvicorn app.main:app "${UVICORN_ARGS[@]}" > server.log 2>&1 &
|
nohup env LD_LIBRARY_PATH="${CONDA_PREFIX}/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \
|
||||||
|
uvicorn app.main:app "${UVICORN_ARGS[@]}" > server.log 2>&1 &
|
||||||
echo "Server started (PID: $!). Logs: server.log"
|
echo "Server started (PID: $!). Logs: server.log"
|
||||||
|
|||||||
5
scripts/env-no-ld.sh
Executable file
5
scripts/env-no-ld.sh
Executable file
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# LD_LIBRARY_PATH 없이 명령 실행 (conda libtinfo 때문에 vi/bash 등이 경고 낼 때)
|
||||||
|
# 사용: ./scripts/env-no-ld.sh vi run.sh
|
||||||
|
# ./scripts/env-no-ld.sh bash -c 'echo ok'
|
||||||
|
exec env -u LD_LIBRARY_PATH "$@"
|
||||||
Reference in New Issue
Block a user