Compare commits

..

2 Commits

Author SHA1 Message Date
du5t
ce064d6894 Add VibeVoice-ASR backend
Third ASR backend option alongside faster-whisper and qwen3. Builds
microsoft/VibeVoice from source (pinned to a specific commit, since it's
custom modeling code not in transformers' Auto* registry) into its own venv,
inheriting the base image's torch/CUDA. Comes with built-in speaker
diarization (VibeVoiceASRProcessor.post_process_transcription returns
per-segment speaker ids directly, no separate pyannote pass needed).

Verified end-to-end against a real recording: 200 OK, correct Korean
transcription, speaker labels populated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 16:54:34 +09:00
du5t
a32b0acb09 Fix broken file transcription and speaker diarization
qwen3 backend: model list pointed at nonexistent HF repos (Qwen3-ASR-2B/8B
don't exist), and inference went through the generic HF ASR pipeline with
Whisper-only options (return_timestamps, task/language generate_kwargs) that
Qwen3-ASR's chat-style architecture doesn't support. Switched to the real
0.6B/1.7B-hf checkpoints and drive them via
processor.apply_transcription_request() + model.generate(). Also added an
ffmpeg pre-conversion step since the model's feature extractor can't decode
m4a via librosa.

faster-whisper backend: speaker diarization was broken by two pyannote.audio
API changes it hadn't caught up with (use_auth_token= renamed to token=, and
pipeline() now returns a DiarizeOutput wrapper instead of an Annotation
directly). Also pinned LD_LIBRARY_PATH for that worker so it picks up its own
venv's cuDNN instead of the older one shadowing it via the container's global
LD_LIBRARY_PATH, which crashed pyannote's GPU init.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 16:31:55 +09:00
9 changed files with 268 additions and 76 deletions

View File

@@ -38,6 +38,19 @@ RUN python -m venv --system-site-packages /opt/venvs/xtts && \
/opt/venvs/xtts/bin/pip install --upgrade pip && \ /opt/venvs/xtts/bin/pip install --upgrade pip && \
/opt/venvs/xtts/bin/pip install -r /build/envs/xtts.txt /opt/venvs/xtts/bin/pip install -r /build/envs/xtts.txt
# vibevoice는 pip 패키지가 아니라 GitHub 소스를 직접 빌드해야 함(커스텀 모델링 코드,
# transformers Auto* 레지스트리에 없음). 특정 커밋에 고정해 업스트림 변경에 흔들리지 않게 함.
RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/* && \
git clone https://github.com/microsoft/VibeVoice.git /opt/vibevoice_src && \
cd /opt/vibevoice_src && git checkout 303b2833e01cff4578ec278bbfe536da54bd19fe
# vibevoice venv — base image의 torch/CUDA 상속. fastapi/uvicorn/python-multipart는
# vibevoice의 pyproject.toml(직접 의존성 또는 gradio 경유 간접 의존성)으로 이미 호환되는
# 버전이 설치되므로 별도 requirements 파일 불필요.
RUN python -m venv --system-site-packages /opt/venvs/vibevoice && \
/opt/venvs/vibevoice/bin/pip install --upgrade pip && \
/opt/venvs/vibevoice/bin/pip install -e /opt/vibevoice_src
COPY app/ /app/ COPY app/ /app/
RUN chmod +x /app/start.sh RUN chmod +x /app/start.sh

View File

@@ -19,6 +19,7 @@ DEFAULT_LANGUAGE = env_str("DEFAULT_LANGUAGE", "ko")
FASTER_WHISPER_URL = env_str("FASTER_WHISPER_URL", "http://127.0.0.1:8001") FASTER_WHISPER_URL = env_str("FASTER_WHISPER_URL", "http://127.0.0.1:8001")
QWEN3_URL = env_str("QWEN3_URL", "http://127.0.0.1:8004") QWEN3_URL = env_str("QWEN3_URL", "http://127.0.0.1:8004")
VIBEVOICE_URL = env_str("VIBEVOICE_URL", "http://127.0.0.1:8006")
PYANNOTE_HF_TOKEN = env_str("PYANNOTE_HF_TOKEN", "") PYANNOTE_HF_TOKEN = env_str("PYANNOTE_HF_TOKEN", "")

View File

@@ -18,6 +18,7 @@ from asr.config import (
DEFAULT_MODEL, DEFAULT_MODEL,
FASTER_WHISPER_URL, FASTER_WHISPER_URL,
QWEN3_URL, QWEN3_URL,
VIBEVOICE_URL,
RESULT_DIR, RESULT_DIR,
UPLOAD_DIR, UPLOAD_DIR,
ensure_runtime_dirs, ensure_runtime_dirs,
@@ -29,6 +30,7 @@ ws_router = APIRouter()
BACKENDS = { BACKENDS = {
"faster-whisper": FASTER_WHISPER_URL, "faster-whisper": FASTER_WHISPER_URL,
"qwen3": QWEN3_URL, "qwen3": QWEN3_URL,
"vibevoice": VIBEVOICE_URL,
} }
REALTIME_SAMPLE_RATE = 16000 REALTIME_SAMPLE_RATE = 16000
@@ -48,7 +50,10 @@ def config() -> Dict[str, Any]:
"models": ["tiny", "base", "small", "medium", "large-v3", "large-v2", "turbo"], "models": ["tiny", "base", "small", "medium", "large-v3", "large-v2", "turbo"],
}, },
"qwen3": { "qwen3": {
"models": ["Qwen/Qwen3-ASR-2B", "Qwen/Qwen3-ASR-8B"], "models": ["Qwen/Qwen3-ASR-0.6B-hf", "Qwen/Qwen3-ASR-1.7B-hf"],
},
"vibevoice": {
"models": ["microsoft/VibeVoice-ASR"],
}, },
}, },
} }
@@ -168,6 +173,10 @@ async def transcribe(
"language": effective_language, "language": effective_language,
"task": task, "task": task,
} }
elif backend == "vibevoice":
data = {
"model": model,
}
else: else:
# faster-whisper # faster-whisper
data = { data = {

View File

@@ -209,7 +209,7 @@ def _get_diarization_pipeline():
raise RuntimeError("화자 분리를 사용하려면 PYANNOTE_HF_TOKEN 환경변수를 설정하세요.") raise RuntimeError("화자 분리를 사용하려면 PYANNOTE_HF_TOKEN 환경변수를 설정하세요.")
_DIARIZATION_PIPELINE = Pipeline.from_pretrained( _DIARIZATION_PIPELINE = Pipeline.from_pretrained(
"pyannote/speaker-diarization-3.1", "pyannote/speaker-diarization-3.1",
use_auth_token=PYANNOTE_HF_TOKEN, token=PYANNOTE_HF_TOKEN,
) )
if DEVICE == "cuda": if DEVICE == "cuda":
import torch import torch
@@ -234,7 +234,9 @@ def _apply_diarization(
if max_speakers is not None: if max_speakers is not None:
kwargs["max_speakers"] = max_speakers kwargs["max_speakers"] = max_speakers
diarization = pipeline(str(audio_path), **kwargs) # pyannote.audio 4.x: pipeline() returns a DiarizeOutput dataclass instead of
# an Annotation directly; the itertracks-capable Annotation is .speaker_diarization.
diarization = pipeline(str(audio_path), **kwargs).speaker_diarization
segments = result.get("segments", []) segments = result.get("segments", [])
for seg in segments: for seg in segments:
seg_start = float(seg.get("start", 0)) seg_start = float(seg.get("start", 0))

View File

@@ -2,9 +2,10 @@ from __future__ import annotations
import argparse import argparse
import gc import gc
import subprocess
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, Optional, Tuple
import uvicorn import uvicorn
from fastapi import FastAPI, File, Form, HTTPException, UploadFile from fastapi import FastAPI, File, Form, HTTPException, UploadFile
@@ -22,7 +23,11 @@ os.environ.setdefault("NUMBA_CACHE_DIR", "/tmp/numba_cache")
app = FastAPI(title="ASR Qwen3 Worker") app = FastAPI(title="ASR Qwen3 Worker")
_PIPE_CACHE: Dict[str, Any] = {} # model_id -> (model, processor). Qwen3-ASR is a chat-style audio LM, not a
# Whisper/CTC model, so it cannot go through the generic HF ASR `pipeline()`
# (no return_timestamps, no task/language generate_kwargs). Load it directly
# and drive it through Qwen3ASRProcessor.apply_transcription_request().
_MODEL_CACHE: Dict[str, Tuple[Any, Any]] = {}
LANG_MAP = { LANG_MAP = {
"ko": "korean", "en": "english", "ja": "japanese", "zh": "chinese", "ko": "korean", "en": "english", "ja": "japanese", "zh": "chinese",
@@ -51,47 +56,43 @@ def _free_gpu(*objs: Any) -> None:
pass pass
def _load_pipe(model_id: str) -> Any: def _to_wav(src_path: Path) -> Path:
if model_id in _PIPE_CACHE: """librosa/soundfile (used by Qwen3ASRFeatureExtractor) can't decode m4a/mp3/etc,
return _PIPE_CACHE[model_id] so normalize everything to 16kHz mono WAV via ffmpeg before handing it to the
processor, the same way faster-whisper does internally."""
wav_path = src_path.with_suffix(".conv.wav")
result = subprocess.run(
["ffmpeg", "-y", "-i", str(src_path), "-ar", "16000", "-ac", "1", "-f", "wav", str(wav_path)],
capture_output=True,
)
if result.returncode != 0:
raise RuntimeError(f"ffmpeg conversion failed: {result.stderr.decode(errors='replace')[-500:]}")
return wav_path
def _load_model(model_id: str) -> Tuple[Any, Any]:
if model_id in _MODEL_CACHE:
return _MODEL_CACHE[model_id]
import torch import torch
from transformers import AutoModelForSpeechSeq2Seq, AutoProcessor, pipeline from transformers import AutoModelForSpeechSeq2Seq, AutoProcessor
_log(f"loading model {model_id}") _log(f"loading model {model_id}")
dtype = torch.float16 if DEVICE == "cuda" else torch.float32 dtype = torch.float16 if DEVICE == "cuda" else torch.float32
try: processor = AutoProcessor.from_pretrained(model_id, cache_dir=str(MODEL_CACHE))
model = AutoModelForSpeechSeq2Seq.from_pretrained( model = AutoModelForSpeechSeq2Seq.from_pretrained(
model_id, model_id,
torch_dtype=dtype, dtype=dtype,
low_cpu_mem_usage=True, low_cpu_mem_usage=True,
cache_dir=str(MODEL_CACHE), cache_dir=str(MODEL_CACHE),
) )
model.to(DEVICE) model.to(DEVICE)
processor = AutoProcessor.from_pretrained(model_id, cache_dir=str(MODEL_CACHE)) model.eval()
pipe = pipeline(
"automatic-speech-recognition",
model=model,
tokenizer=processor.tokenizer,
feature_extractor=processor.feature_extractor,
torch_dtype=dtype,
device=DEVICE,
)
except Exception:
# Fallback: let pipeline handle loading (uses device_map)
_log("direct load failed, trying pipeline auto-load")
pipe = pipeline(
"automatic-speech-recognition",
model=model_id,
torch_dtype=torch.float16 if DEVICE == "cuda" else torch.float32,
device_map="auto" if DEVICE == "cuda" else None,
model_kwargs={"cache_dir": str(MODEL_CACHE)},
)
_PIPE_CACHE[model_id] = pipe _MODEL_CACHE[model_id] = (model, processor)
_log(f"model {model_id} loaded") _log(f"model {model_id} loaded")
return pipe return _MODEL_CACHE[model_id]
@app.on_event("startup") @app.on_event("startup")
@@ -101,15 +102,14 @@ def startup() -> None:
@app.get("/health") @app.get("/health")
def health() -> Dict[str, Any]: def health() -> Dict[str, Any]:
return {"status": "ok", "device": DEVICE, "loaded_models": list(_PIPE_CACHE.keys())} return {"status": "ok", "device": DEVICE, "loaded_models": list(_MODEL_CACHE.keys())}
@app.post("/transcribe") @app.post("/transcribe")
async def transcribe( async def transcribe(
file: UploadFile = File(...), file: UploadFile = File(...),
model: str = Form("Qwen/Qwen3-ASR-2B"), model: str = Form("Qwen/Qwen3-ASR-1.7B-hf"),
language: Optional[str] = Form("ko"), language: Optional[str] = Form("ko"),
task: str = Form("transcribe"),
) -> JSONResponse: ) -> JSONResponse:
ensure_runtime_dirs() ensure_runtime_dirs()
suffix = Path(file.filename or "audio.bin").suffix or ".wav" suffix = Path(file.filename or "audio.bin").suffix or ".wav"
@@ -118,49 +118,36 @@ async def transcribe(
tmp.write(await file.read()) tmp.write(await file.read())
tmp_path = Path(tmp.name) tmp_path = Path(tmp.name)
wav_path: Optional[Path] = None
try: try:
pipe = _load_pipe(model) import torch
lang_code = (language or "ko").strip().lower() model_obj, processor = _load_model(model)
lang_name = LANG_MAP.get(lang_code, lang_code)
generate_kwargs: Dict[str, Any] = {"task": task} lang_code = (language or "").strip().lower()
if lang_code: lang_name = LANG_MAP.get(lang_code, lang_code) if lang_code else None
generate_kwargs["language"] = lang_name
_log(f"transcribing language={lang_code} model={model}") _log(f"transcribing language={lang_code or 'auto'} model={model}")
raw = pipe(
str(tmp_path),
return_timestamps=True,
generate_kwargs=generate_kwargs,
)
# raw may be {"text": "...", "chunks": [...]} or {"text": "..."} wav_path = _to_wav(tmp_path)
full_text: str = raw.get("text", "").strip() inputs = processor.apply_transcription_request(audio=str(wav_path), language=lang_name)
chunks: List[Dict[str, Any]] = raw.get("chunks", []) inputs = inputs.to(model_obj.device, dtype=model_obj.dtype)
prompt_len = inputs["input_ids"].shape[1]
segments = [] with torch.no_grad():
for i, chunk in enumerate(chunks): generated = model_obj.generate(**inputs)
ts = chunk.get("timestamp") or (None, None)
start = float(ts[0]) if ts[0] is not None else None
end = float(ts[1]) if ts[1] is not None else None
segments.append({
"id": i,
"start": round(start, 3) if start is not None else None,
"end": round(end, 3) if end is not None else None,
"text": chunk.get("text", "").strip(),
})
# Estimate duration from last segment end parsed = processor.decode(generated[0][prompt_len:], return_format="parsed")
duration = None full_text = (parsed.get("transcription") or "").strip()
if segments and segments[-1]["end"] is not None: detected_language = parsed.get("language") or lang_code or None
duration = segments[-1]["end"]
segments = [{"id": 0, "start": None, "end": None, "text": full_text}] if full_text else []
return JSONResponse({ return JSONResponse({
"backend": "qwen3", "backend": "qwen3",
"model": model, "model": model,
"language": lang_code, "language": detected_language,
"duration": duration, "duration": None,
"text": full_text, "text": full_text,
"segments": segments, "segments": segments,
"diarized": False, "diarized": False,
@@ -173,6 +160,8 @@ async def transcribe(
raise HTTPException(status_code=500, detail=f"{type(e).__name__}: {e}") raise HTTPException(status_code=500, detail=f"{type(e).__name__}: {e}")
finally: finally:
tmp_path.unlink(missing_ok=True) tmp_path.unlink(missing_ok=True)
if wav_path is not None:
wav_path.unlink(missing_ok=True)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -0,0 +1,164 @@
from __future__ import annotations
import argparse
import gc
import tempfile
from pathlib import Path
from typing import Any, Dict, List, Optional
import uvicorn
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.responses import JSONResponse
import sys
sys.path.insert(0, "/app")
from asr.config import DEVICE, MODEL_CACHE, ensure_runtime_dirs
import os
# 컨테이너는 983 유저로 실행되는데 HOME(/app)이 root 소유라 numba/matplotlib/torch가
# 각자 ~/.cache, ~/.config 아래에 쓰려다 실패한다. HOME을 쓰기 가능한 곳으로 돌린다.
os.environ["HOME"] = "/tmp" # 컨테이너 기본 HOME=/app은 983 유저가 쓰기 불가 (setdefault로는 덮어쓰기 안 됨)
os.environ.setdefault("NUMBA_CACHE_DIR", "/tmp/numba_cache")
app = FastAPI(title="ASR VibeVoice Worker")
_MODEL_CACHE: Dict[str, Any] = {}
def _log(msg: str) -> None:
print(f"[vibevoice] {msg}", flush=True)
def _load_model(model_id: str) -> Any:
if model_id in _MODEL_CACHE:
return _MODEL_CACHE[model_id]
import torch
from vibevoice.modular.modeling_vibevoice_asr import VibeVoiceASRForConditionalGeneration
from vibevoice.processor.vibevoice_asr_processor import VibeVoiceASRProcessor
_log(f"loading model {model_id}")
processor = VibeVoiceASRProcessor.from_pretrained(
model_id,
language_model_pretrained_name="Qwen/Qwen2.5-1.5B",
cache_dir=str(MODEL_CACHE),
)
model = VibeVoiceASRForConditionalGeneration.from_pretrained(
model_id,
dtype=torch.bfloat16 if DEVICE == "cuda" else torch.float32,
attn_implementation="sdpa",
trust_remote_code=True,
cache_dir=str(MODEL_CACHE),
)
model.to(DEVICE)
model.eval()
_MODEL_CACHE[model_id] = (model, processor)
_log(f"model {model_id} loaded")
return _MODEL_CACHE[model_id]
@app.on_event("startup")
def startup() -> None:
ensure_runtime_dirs()
@app.get("/health")
def health() -> Dict[str, Any]:
return {"status": "ok", "device": DEVICE, "loaded_models": list(_MODEL_CACHE.keys())}
@app.post("/transcribe")
async def transcribe(
file: UploadFile = File(...),
model: str = Form("microsoft/VibeVoice-ASR"),
max_new_tokens: int = Form(4096),
) -> JSONResponse:
ensure_runtime_dirs()
suffix = Path(file.filename or "audio.bin").suffix or ".wav"
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
tmp.write(await file.read())
tmp_path = Path(tmp.name)
try:
import torch
vv_model, processor = _load_model(model)
inputs = processor(
audio=[str(tmp_path)],
sampling_rate=None,
return_tensors="pt",
padding=True,
add_generation_prompt=True,
)
inputs = {k: v.to(DEVICE) if isinstance(v, torch.Tensor) else v for k, v in inputs.items()}
gen_config = {
"max_new_tokens": max_new_tokens,
"pad_token_id": processor.pad_id,
"eos_token_id": processor.tokenizer.eos_token_id,
"do_sample": False,
}
_log(f"transcribing model={model}")
with torch.no_grad():
output_ids = vv_model.generate(**inputs, **gen_config)
input_length = inputs["input_ids"].shape[1]
generated_ids = output_ids[0, input_length:]
raw_text = processor.decode(generated_ids, skip_special_tokens=True)
try:
raw_segments = processor.post_process_transcription(raw_text)
except Exception as e:
_log(f"post_process_transcription failed: {e}")
raw_segments = []
segments: List[Dict[str, Any]] = []
full_text_parts: List[str] = []
for i, seg in enumerate(raw_segments):
text = (seg.get("text") or "").strip()
speaker_id = seg.get("speaker_id")
start = seg.get("start_time")
end = seg.get("end_time")
segments.append({
"id": i,
"start": round(float(start), 3) if start is not None else None,
"end": round(float(end), 3) if end is not None else None,
"text": text,
"speaker": f"SPEAKER_{speaker_id:02d}" if speaker_id is not None else "UNKNOWN",
})
if text:
full_text_parts.append(text)
duration = segments[-1]["end"] if segments and segments[-1]["end"] is not None else None
full_text = " ".join(full_text_parts)
return JSONResponse({
"backend": "vibevoice",
"model": model,
"language": None,
"duration": duration,
"text": full_text,
"segments": segments,
"diarized": True,
})
except HTTPException:
raise
except Exception as e:
_log(f"error: {type(e).__name__}: {e}")
raise HTTPException(status_code=500, detail=f"{type(e).__name__}: {e}")
finally:
tmp_path.unlink(missing_ok=True)
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--host", default="0.0.0.0")
parser.add_argument("--port", type=int, default=8006)
args = parser.parse_args()
_log(f"starting host={args.host} port={args.port} device={DEVICE}")
uvicorn.run(app, host=args.host, port=args.port)

View File

@@ -6,6 +6,7 @@ pidfile=/tmp/supervisord.pid
[program:faster_whisper] [program:faster_whisper]
command=/opt/venvs/faster_whisper/bin/python /app/asr/workers/faster_whisper_worker.py --host 0.0.0.0 --port 8001 command=/opt/venvs/faster_whisper/bin/python /app/asr/workers/faster_whisper_worker.py --host 0.0.0.0 --port 8001
directory=/app directory=/app
environment=LD_LIBRARY_PATH="/opt/venvs/faster_whisper/lib/python3.11/site-packages/nvidia/cudnn/lib:%(ENV_LD_LIBRARY_PATH)s"
autostart=true autostart=true
autorestart=true autorestart=true
stdout_logfile=/dev/fd/1 stdout_logfile=/dev/fd/1
@@ -25,6 +26,17 @@ stderr_logfile=/dev/fd/2
stderr_logfile_maxbytes=0 stderr_logfile_maxbytes=0
priority=20 priority=20
[program:vibevoice]
command=/opt/venvs/vibevoice/bin/python /app/asr/workers/vibevoice_worker.py --host 0.0.0.0 --port 8006
directory=/app
autostart=true
autorestart=true
stdout_logfile=/dev/fd/1
stdout_logfile_maxbytes=0
stderr_logfile=/dev/fd/2
stderr_logfile_maxbytes=0
priority=25
[program:xtts] [program:xtts]
command=/opt/venvs/xtts/bin/python /app/tts/workers/xtts_worker.py --host 0.0.0.0 --port 8005 command=/opt/venvs/xtts/bin/python /app/tts/workers/xtts_worker.py --host 0.0.0.0 --port 8005
directory=/app directory=/app

View File

@@ -13,7 +13,8 @@ async function loadConfig() {
default_language: 'ko', default_language: 'ko',
backends: { backends: {
'faster-whisper': { models: ['tiny','base','small','medium','large-v3','large-v2','turbo'] }, 'faster-whisper': { models: ['tiny','base','small','medium','large-v3','large-v2','turbo'] },
'qwen3': { models: ['Qwen/Qwen3-ASR-2B','Qwen/Qwen3-ASR-8B'] }, 'qwen3': { models: ['Qwen/Qwen3-ASR-0.6B-hf','Qwen/Qwen3-ASR-1.7B-hf'] },
'vibevoice': { models: ['microsoft/VibeVoice-ASR'] },
}, },
}; };
} }
@@ -52,8 +53,8 @@ function populateModels(backend) {
} }
function onBackendChange() { function onBackendChange() {
const isQwen = backendSel.value === 'qwen3'; const isFasterWhisper = backendSel.value === 'faster-whisper';
fwOptions.style.display = isQwen ? 'none' : ''; fwOptions.style.display = isFasterWhisper ? '' : 'none';
populateModels(backendSel.value); populateModels(backendSel.value);
} }

View File

@@ -47,6 +47,7 @@
<select id="backend" name="backend"> <select id="backend" name="backend">
<option value="faster-whisper" selected>faster-whisper</option> <option value="faster-whisper" selected>faster-whisper</option>
<option value="qwen3">Qwen3-ASR</option> <option value="qwen3">Qwen3-ASR</option>
<option value="vibevoice">VibeVoice-ASR</option>
</select> </select>
</label> </label>
<label>모델 <label>모델