Files
speech/app/asr/workers/vibevoice_worker.py
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

165 lines
5.3 KiB
Python

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)