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>
174 lines
5.7 KiB
Python
174 lines
5.7 KiB
Python
from __future__ import annotations
|
|
|
|
import argparse
|
|
import gc
|
|
import subprocess
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import Any, Dict, Optional, Tuple
|
|
|
|
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 Qwen3 Worker")
|
|
|
|
# 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",
|
|
"fr": "french", "de": "german", "es": "spanish", "ru": "russian",
|
|
"vi": "vietnamese", "th": "thai", "ar": "arabic", "pt": "portuguese",
|
|
"it": "italian", "nl": "dutch", "pl": "polish", "tr": "turkish",
|
|
}
|
|
|
|
|
|
def _log(msg: str) -> None:
|
|
print(f"[qwen3] {msg}", flush=True)
|
|
|
|
|
|
def _free_gpu(*objs: Any) -> None:
|
|
for obj in objs:
|
|
try:
|
|
del obj
|
|
except Exception:
|
|
pass
|
|
gc.collect()
|
|
try:
|
|
import torch
|
|
if DEVICE == "cuda" and torch.cuda.is_available():
|
|
torch.cuda.empty_cache()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
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
|
|
|
|
_log(f"loading model {model_id}")
|
|
dtype = torch.float16 if DEVICE == "cuda" else torch.float32
|
|
|
|
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()
|
|
|
|
_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("Qwen/Qwen3-ASR-1.7B-hf"),
|
|
language: Optional[str] = Form("ko"),
|
|
) -> 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)
|
|
|
|
wav_path: Optional[Path] = None
|
|
try:
|
|
import torch
|
|
|
|
model_obj, processor = _load_model(model)
|
|
|
|
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 or 'auto'} model={model}")
|
|
|
|
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]
|
|
|
|
with torch.no_grad():
|
|
generated = model_obj.generate(**inputs)
|
|
|
|
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": detected_language,
|
|
"duration": None,
|
|
"text": full_text,
|
|
"segments": segments,
|
|
"diarized": False,
|
|
})
|
|
|
|
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 wav_path is not None:
|
|
wav_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=8004)
|
|
args = parser.parse_args()
|
|
_log(f"starting host={args.host} port={args.port} device={DEVICE}")
|
|
uvicorn.run(app, host=args.host, port=args.port)
|