import gc import os import re import shutil import subprocess import tempfile import time import uuid from pathlib import Path import torch import uvicorn from fastapi import FastAPI, File, Form, UploadFile from fastapi.responses import JSONResponse from lhotse import Recording from lhotse.dataset import DynamicCutSampler from optimum.quanto import quantize, freeze, qint8 from nemo.collections.speechlm2.models import SALM MODEL_ID = "nvidia/canary-qwen-2.5b" SAMPLE_RATE = 16000 CHUNK_SECONDS = 40.0 BATCH_SIZE = 1 app = FastAPI(title="Canary-Qwen Quanto STT", version="0.1") device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = None def clean_text(s: str) -> str: s = s.replace("<|im_start|>", "").replace("<|im_end|>", "") s = re.sub(r".*?", "", s, flags=re.S) s = s.replace("", "").replace("", "") s = re.sub(r"\s+", " ", s).strip() return s def load_model(): global model if model is not None: return model print("[CANARY] loading SALM", flush=True) m = SALM.from_pretrained(MODEL_ID).eval() print("[CANARY] quantizing LLM with Quanto qint8", flush=True) quantize(m.llm, weights=qint8) freeze(m.llm) m.embed_tokens = m.embed_tokens.to(device) m.llm = m.llm.to(device) m.perception = m.perception.to(device).eval() model = m print("[CANARY] ready", flush=True) return model @app.get("/health") def health(): free = total = None if torch.cuda.is_available(): free_b, total_b = torch.cuda.mem_get_info() free = round(free_b / 1024**3, 3) total = round(total_b / 1024**3, 3) return { "ok": True, "backend": "canary-qwen-2.5b-quanto-int8", "device": str(device), "cuda": torch.cuda.is_available(), "vram_free_gib": free, "vram_total_gib": total, } @app.get("/v1/models") def models(): return { "object": "list", "data": [ { "id": "canary-qwen-2.5b-quanto-int8", "object": "model", "owned_by": "local", } ], } @app.post("/v1/audio/transcriptions") async def transcriptions( file: UploadFile = File(...), model_name: str = Form(default="canary-qwen-2.5b-quanto-int8", alias="model"), language: str = Form(default="en"), response_format: str = Form(default="json"), ): m = load_model() start = time.time() suffix = Path(file.filename or "audio.wav").suffix or ".wav" tmpdir = Path(tempfile.mkdtemp(prefix="canary_stt_")) src = tmpdir / f"input{suffix}" try: with src.open("wb") as f: shutil.copyfileobj(file.file, f) # Open WebUI browser mic uploads usually arrive as webm/opus. # Lhotse/Torchaudio can choke on that in this venv, so normalize first. wav = tmpdir / "input_16k_mono.wav" cmd = [ "ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", str(src), "-vn", "-ar", str(SAMPLE_RATE), "-ac", "1", "-c:a", "pcm_s16le", str(wav), ] subprocess.run(cmd, check=True) rec = Recording.from_file(str(wav), recording_id=str(uuid.uuid4())) cut = rec.to_cut() if cut.num_channels > 1: cut = cut.to_mono(mono_downmix=True) sampler = DynamicCutSampler(cut.cut_into_windows(CHUNK_SECONDS), max_cuts=BATCH_SIZE) pred_text = [] for batch in sampler: audio, audio_lens = batch.load_audio(collate=True) with torch.inference_mode(): output_ids = m.generate( prompts=[ [ { "role": "user", "content": f"Transcribe the following: {m.audio_locator_tag}", } ] ] * len(batch), audios=torch.as_tensor(audio).to(device, non_blocking=True), audio_lens=torch.as_tensor(audio_lens).to(device, non_blocking=True), max_new_tokens=256, ) for oids in output_ids.cpu(): pred_text.append(clean_text(m.tokenizer.ids_to_text(oids))) text = " ".join(x for x in pred_text if x).strip() elapsed = round(time.time() - start, 3) gc.collect() if torch.cuda.is_available(): torch.cuda.empty_cache() if response_format == "text": return JSONResponse(content=text) return { "text": text, "model": "canary-qwen-2.5b-quanto-int8", "backend": "canary-qwen-quanto", "language": language, "elapsed_sec": elapsed, } finally: shutil.rmtree(tmpdir, ignore_errors=True) if __name__ == "__main__": load_model() uvicorn.run(app, host="0.0.0.0", port=8022)