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>
This commit is contained in:
du5t
2026-07-23 16:31:55 +09:00
parent 22e9b805a6
commit a32b0acb09
5 changed files with 66 additions and 74 deletions

View File

@@ -2,9 +2,10 @@ from __future__ import annotations
import argparse
import gc
import subprocess
import tempfile
from pathlib import Path
from typing import Any, Dict, List, Optional
from typing import Any, Dict, Optional, Tuple
import uvicorn
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")
_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 = {
"ko": "korean", "en": "english", "ja": "japanese", "zh": "chinese",
@@ -51,47 +56,43 @@ def _free_gpu(*objs: Any) -> None:
pass
def _load_pipe(model_id: str) -> Any:
if model_id in _PIPE_CACHE:
return _PIPE_CACHE[model_id]
def _to_wav(src_path: Path) -> Path:
"""librosa/soundfile (used by Qwen3ASRFeatureExtractor) can't decode m4a/mp3/etc,
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
from transformers import AutoModelForSpeechSeq2Seq, AutoProcessor, pipeline
from transformers import AutoModelForSpeechSeq2Seq, AutoProcessor
_log(f"loading model {model_id}")
dtype = torch.float16 if DEVICE == "cuda" else torch.float32
try:
model = AutoModelForSpeechSeq2Seq.from_pretrained(
model_id,
torch_dtype=dtype,
low_cpu_mem_usage=True,
cache_dir=str(MODEL_CACHE),
)
model.to(DEVICE)
processor = AutoProcessor.from_pretrained(model_id, cache_dir=str(MODEL_CACHE))
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)},
)
processor = AutoProcessor.from_pretrained(model_id, cache_dir=str(MODEL_CACHE))
model = AutoModelForSpeechSeq2Seq.from_pretrained(
model_id,
dtype=dtype,
low_cpu_mem_usage=True,
cache_dir=str(MODEL_CACHE),
)
model.to(DEVICE)
model.eval()
_PIPE_CACHE[model_id] = pipe
_MODEL_CACHE[model_id] = (model, processor)
_log(f"model {model_id} loaded")
return pipe
return _MODEL_CACHE[model_id]
@app.on_event("startup")
@@ -101,15 +102,14 @@ def startup() -> None:
@app.get("/health")
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")
async def transcribe(
file: UploadFile = File(...),
model: str = Form("Qwen/Qwen3-ASR-2B"),
model: str = Form("Qwen/Qwen3-ASR-1.7B-hf"),
language: Optional[str] = Form("ko"),
task: str = Form("transcribe"),
) -> JSONResponse:
ensure_runtime_dirs()
suffix = Path(file.filename or "audio.bin").suffix or ".wav"
@@ -118,49 +118,36 @@ async def transcribe(
tmp.write(await file.read())
tmp_path = Path(tmp.name)
wav_path: Optional[Path] = None
try:
pipe = _load_pipe(model)
import torch
lang_code = (language or "ko").strip().lower()
lang_name = LANG_MAP.get(lang_code, lang_code)
model_obj, processor = _load_model(model)
generate_kwargs: Dict[str, Any] = {"task": task}
if lang_code:
generate_kwargs["language"] = lang_name
lang_code = (language or "").strip().lower()
lang_name = LANG_MAP.get(lang_code, lang_code) if lang_code else None
_log(f"transcribing language={lang_code} model={model}")
raw = pipe(
str(tmp_path),
return_timestamps=True,
generate_kwargs=generate_kwargs,
)
_log(f"transcribing language={lang_code or 'auto'} model={model}")
# raw may be {"text": "...", "chunks": [...]} or {"text": "..."}
full_text: str = raw.get("text", "").strip()
chunks: List[Dict[str, Any]] = raw.get("chunks", [])
wav_path = _to_wav(tmp_path)
inputs = processor.apply_transcription_request(audio=str(wav_path), language=lang_name)
inputs = inputs.to(model_obj.device, dtype=model_obj.dtype)
prompt_len = inputs["input_ids"].shape[1]
segments = []
for i, chunk in enumerate(chunks):
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(),
})
with torch.no_grad():
generated = model_obj.generate(**inputs)
# Estimate duration from last segment end
duration = None
if segments and segments[-1]["end"] is not None:
duration = segments[-1]["end"]
parsed = processor.decode(generated[0][prompt_len:], return_format="parsed")
full_text = (parsed.get("transcription") or "").strip()
detected_language = parsed.get("language") or lang_code or None
segments = [{"id": 0, "start": None, "end": None, "text": full_text}] if full_text else []
return JSONResponse({
"backend": "qwen3",
"model": model,
"language": lang_code,
"duration": duration,
"language": detected_language,
"duration": None,
"text": full_text,
"segments": segments,
"diarized": False,
@@ -173,6 +160,8 @@ async def transcribe(
raise HTTPException(status_code=500, detail=f"{type(e).__name__}: {e}")
finally:
tmp_path.unlink(missing_ok=True)
if wav_path is not None:
wav_path.unlink(missing_ok=True)
if __name__ == "__main__":