Files
speech/app/asr/workers/faster_whisper_worker.py
du5t acdf098c41 Add XTTS-v2 as first TTS backend, extensible for more
Mirrors the ASR backend pattern exactly: a BACKENDS dict in
tts/router.py maps a backend name to its worker URL, so adding the
next backend is just a new worker file + venv + supervisord entry +
one dict line. XTTS-v2 runs in its own venv (--system-site-packages,
inherits base image torch/CUDA) as a new supervisord program on
port 8005.

XTTS is zero-shot voice cloning, so a reference-voice library was
added (/tts/voices CRUD, stored under /srv/tts/voices) — synthesis
requires picking a previously uploaded voice. Results and model
cache live under /srv/tts/{results,models-cache}, new quadlet
volumes, owned by the same 983:983 user as the existing /srv/asr
dirs.

Fixed two environment issues uncovered while getting XTTS to
actually run inside the container (non-root user, root-built venvs):
- coqui-tts only pins transformers>=4.57 with no ceiling, so pip
  installed an incompatible 5.x; pinned to the last 4.x release.
- HOME defaults to /app (owned by root) for the container's runtime
  user, so numba/matplotlib/torch cache writes failed; HOME is now
  forced to /tmp in all three workers (faster_whisper, qwen3, xtts)
  before any of those libraries get imported.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 18:46:45 +09:00

280 lines
9.4 KiB
Python

from __future__ import annotations
import argparse
import tempfile
from pathlib import Path
from typing import Any, Dict, List, Optional, Union
import uvicorn
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from faster_whisper import WhisperModel
import sys
sys.path.insert(0, "/app")
from asr.config import (
COMPUTE_TYPE,
CUSTOM_MODEL_DIR,
DEFAULT_MODEL,
DEVICE,
MODEL_CACHE,
PYANNOTE_HF_TOKEN,
ensure_runtime_dirs,
resolve_custom_model_path,
)
import os
# 컨테이너는 983 유저로 실행되는데 HOME(/app)이 root 소유라, pyannote.audio가 쓰는
# numba/matplotlib/torch가 각자 ~/.cache, ~/.config 아래에 쓰려다 실패한다.
os.environ["HOME"] = "/tmp" # 컨테이너 기본 HOME=/app은 983 유저가 쓰기 불가 (setdefault로는 덮어쓰기 안 됨)
os.environ.setdefault("NUMBA_CACHE_DIR", "/tmp/numba_cache")
app = FastAPI(title="ASR Faster-Whisper Worker")
_MODEL_CACHE: Dict[str, WhisperModel] = {}
_DIARIZATION_PIPELINE: Any = None
@app.on_event("startup")
def startup() -> None:
ensure_runtime_dirs()
@app.get("/health")
def health() -> Dict[str, Any]:
return {
"status": "ok",
"device": DEVICE,
"compute_type": COMPUTE_TYPE,
"diarization_available": bool(PYANNOTE_HF_TOKEN),
}
@app.post("/transcribe")
async def transcribe(
file: UploadFile = File(...),
model: str = Form(DEFAULT_MODEL),
custom_model_path: Optional[str] = Form(None),
language: Optional[str] = Form(None),
task: str = Form("transcribe"),
beam_size: int = Form(5),
temperature: float = Form(0.0),
word_timestamps: bool = Form(False),
diarize: bool = Form(False),
num_speakers: Optional[int] = Form(None),
min_speakers: Optional[int] = Form(None),
max_speakers: Optional[int] = Form(None),
no_repeat_ngram_size: int = Form(0),
repetition_penalty: float = Form(1.0),
compression_ratio_threshold: float = Form(2.4),
log_prob_threshold: float = Form(-1.0),
no_speech_threshold: float = Form(0.6),
condition_on_previous_text: bool = Form(True),
) -> Dict[str, Any]:
ensure_runtime_dirs()
suffix = Path(file.filename or "upload.bin").suffix or ".bin"
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
tmp.write(await file.read())
tmp_path = Path(tmp.name)
try:
resolved_model = resolve_custom_model_path(custom_model_path) or model
result = _transcribe(
audio_path=tmp_path,
model_name=resolved_model,
language=language or None,
task=task,
beam_size=beam_size,
temperature=temperature,
word_timestamps=word_timestamps,
no_repeat_ngram_size=no_repeat_ngram_size,
repetition_penalty=repetition_penalty,
compression_ratio_threshold=compression_ratio_threshold,
log_prob_threshold=log_prob_threshold,
no_speech_threshold=no_speech_threshold,
condition_on_previous_text=condition_on_previous_text,
)
if diarize:
result = _apply_diarization(
audio_path=tmp_path,
result=result,
num_speakers=num_speakers,
min_speakers=min_speakers,
max_speakers=max_speakers,
)
return result
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Worker failure: {exc}") from exc
finally:
tmp_path.unlink(missing_ok=True)
def _load_model(model_name: str) -> WhisperModel:
if model_name not in _MODEL_CACHE:
_MODEL_CACHE[model_name] = WhisperModel(
model_name,
device=DEVICE,
compute_type=COMPUTE_TYPE,
download_root=str(MODEL_CACHE),
)
return _MODEL_CACHE[model_name]
def _transcribe(
audio_path: Path,
model_name: str,
language: Optional[str],
task: str,
beam_size: int,
temperature: float,
word_timestamps: bool,
no_repeat_ngram_size: int,
repetition_penalty: float,
compression_ratio_threshold: float,
log_prob_threshold: float,
no_speech_threshold: float,
condition_on_previous_text: bool,
) -> Dict[str, Any]:
model = _load_model(model_name)
temperatures: Union[float, List[float]] = [0.0, 0.2, 0.4, 0.6, 0.8, 1.0] if temperature == 0.0 else temperature
segments_iter, info = model.transcribe(
str(audio_path),
language=language,
task=task,
beam_size=beam_size,
temperature=temperatures,
word_timestamps=word_timestamps,
no_repeat_ngram_size=no_repeat_ngram_size,
repetition_penalty=repetition_penalty,
compression_ratio_threshold=compression_ratio_threshold,
log_prob_threshold=log_prob_threshold,
no_speech_threshold=no_speech_threshold,
condition_on_previous_text=condition_on_previous_text,
)
segment_list = []
full_text = []
for seg in segments_iter:
seg_dict: Dict[str, Any] = {
"id": seg.id,
"start": round(float(seg.start), 3),
"end": round(float(seg.end), 3),
"text": seg.text,
"avg_logprob": round(float(seg.avg_logprob), 4) if seg.avg_logprob is not None else None,
"compression_ratio": round(float(seg.compression_ratio), 4) if seg.compression_ratio is not None else None,
"no_speech_prob": round(float(seg.no_speech_prob), 4) if seg.no_speech_prob is not None else None,
}
if word_timestamps and getattr(seg, "words", None):
seg_dict["words"] = [
{
"start": round(float(w.start), 3),
"end": round(float(w.end), 3),
"word": w.word,
"probability": round(float(w.probability), 4),
}
for w in seg.words
]
segment_list.append(seg_dict)
full_text.append(seg.text.strip())
duration = None
try:
duration = round(float(info.duration), 3)
except Exception:
pass
return {
"backend": "faster-whisper",
"model": model_name,
"language": getattr(info, "language", language),
"language_probability": round(float(getattr(info, "language_probability", 0) or 0), 4),
"duration": duration,
"text": " ".join(x for x in full_text if x),
"segments": segment_list,
"diarized": False,
}
def _get_diarization_pipeline():
global _DIARIZATION_PIPELINE
if _DIARIZATION_PIPELINE is None:
try:
from pyannote.audio import Pipeline
import torch
except ImportError as exc:
raise RuntimeError("pyannote.audio가 설치되지 않았습니다.") from exc
if not PYANNOTE_HF_TOKEN:
raise RuntimeError("화자 분리를 사용하려면 PYANNOTE_HF_TOKEN 환경변수를 설정하세요.")
_DIARIZATION_PIPELINE = Pipeline.from_pretrained(
"pyannote/speaker-diarization-3.1",
use_auth_token=PYANNOTE_HF_TOKEN,
)
if DEVICE == "cuda":
import torch
_DIARIZATION_PIPELINE = _DIARIZATION_PIPELINE.to(torch.device("cuda"))
return _DIARIZATION_PIPELINE
def _apply_diarization(
audio_path: Path,
result: Dict[str, Any],
num_speakers: Optional[int],
min_speakers: Optional[int],
max_speakers: Optional[int],
) -> Dict[str, Any]:
pipeline = _get_diarization_pipeline()
kwargs: Dict[str, Any] = {}
if num_speakers is not None:
kwargs["num_speakers"] = num_speakers
else:
if min_speakers is not None:
kwargs["min_speakers"] = min_speakers
if max_speakers is not None:
kwargs["max_speakers"] = max_speakers
diarization = pipeline(str(audio_path), **kwargs)
segments = result.get("segments", [])
for seg in segments:
seg_start = float(seg.get("start", 0))
seg_end = float(seg.get("end", 0))
speaker_times: Dict[str, float] = {}
for turn, _, speaker in diarization.itertracks(yield_label=True):
overlap_start = max(seg_start, turn.start)
overlap_end = min(seg_end, turn.end)
if overlap_end > overlap_start:
speaker_times[speaker] = speaker_times.get(speaker, 0.0) + (overlap_end - overlap_start)
seg["speaker"] = max(speaker_times, key=speaker_times.get) if speaker_times else "UNKNOWN"
lines = []
current_speaker: Optional[str] = None
current_texts: list = []
for seg in segments:
speaker = seg.get("speaker", "UNKNOWN")
text = seg.get("text", "").strip()
if not text:
continue
if speaker != current_speaker:
if current_texts and current_speaker is not None:
lines.append(f"[{current_speaker}]: {' '.join(current_texts)}")
current_speaker = speaker
current_texts = [text]
else:
current_texts.append(text)
if current_texts and current_speaker is not None:
lines.append(f"[{current_speaker}]: {' '.join(current_texts)}")
result["text"] = "\n".join(lines)
result["diarized"] = True
return result
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--host", default="0.0.0.0")
parser.add_argument("--port", type=int, default=8001)
args = parser.parse_args()
ensure_runtime_dirs()
uvicorn.run(app, host=args.host, port=args.port)