Files
doctate/canary/main.py
T
Brummel 268954f722 feat: Add standalone canary service
This commit introduces a new standalone FastAPI service for the NVIDIA
Canary ASR model.

Key changes include:
- A new `canary/` directory containing the service code.
- `Dockerfile`: Defines the Docker image for the Canary service.
- `docker-compose.yml`: Configures the Docker Compose setup for running
  the service.
- `main.py`: Implements the FastAPI application, model loading, and
  inference logic.
- `requirements.txt`: Lists the Python dependencies for the service.
- `CHUNKING.md`: Documents the long-form audio handling strategy for
  Canary.
- `README.md`: Provides an overview of the service, API, and deployment
  instructions.
- `deploy.sh`: A script for deploying the service to a remote host.

This service allows for independent evaluation and deployment of the
Canary ASR model, separate from the existing `doctate-whisper` service.
It utilizes a buffered inference approach for handling long audio files,
as detailed in `CHUNKING.md`.
2026-04-30 09:24:33 +02:00

247 lines
8.1 KiB
Python

"""FastAPI wrapper around NVIDIA NeMo Canary ASR models.
Native Canary HTTP API (POST /inference). Not a Whisper /asr clone.
Inspired by EvilFreelancer/docker-canary-serve, written from scratch.
"""
import copy
import json
import logging
import os
import subprocess
import tempfile
import time
import wave
from typing import Optional
import torch
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.responses import JSONResponse, PlainTextResponse
from nemo.collections.asr.models import ASRModel
from nemo.collections.asr.parts.utils.streaming_utils import FrameBatchMultiTaskAED
from nemo.collections.asr.parts.utils.transcribe_utils import get_buffered_pred_feat_multitaskAED
from omegaconf import OmegaConf
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
log = logging.getLogger("canary")
MODEL_NAME = os.getenv("CANARY_MODEL", "nvidia/canary-1b-v2")
DEVICE = os.getenv("CANARY_DEVICE", "cuda")
PRECISION = os.getenv("CANARY_PRECISION", "bf16").lower()
MODELS_DIR = os.getenv("CANARY_MODELS_DIR", "/models")
CHUNK_LEN_SECS = float(os.getenv("CANARY_CHUNK_LEN_SECS", "40.0"))
CHUNKED_THRESHOLD_SECS = float(os.getenv("CANARY_CHUNKED_THRESHOLD_SECS", "25.0"))
MODEL_STRIDE = int(os.getenv("CANARY_MODEL_STRIDE", "8")) # FastConformer = 8
DTYPE_MAP = {
"fp32": torch.float32,
"fp16": torch.float16,
"bf16": torch.bfloat16,
}
if PRECISION not in DTYPE_MAP:
raise ValueError(f"Unsupported CANARY_PRECISION={PRECISION}; use fp32, fp16, or bf16")
os.environ.setdefault("HF_HOME", MODELS_DIR)
log.info("Loading model=%s device=%s precision=%s", MODEL_NAME, DEVICE, PRECISION)
t_load = time.monotonic()
model = ASRModel.from_pretrained(MODEL_NAME)
model = model.to(DEVICE)
if DTYPE_MAP[PRECISION] != torch.float32:
model = model.to(DTYPE_MAP[PRECISION])
model.eval()
log.info("Model loaded in %.1fs", time.monotonic() - t_load)
# Prepare config for the buffered/chunked inference path (long audio).
# See CHUNKING.md and NVIDIA-NeMo's speech_to_text_aed_chunked_infer.py.
_model_cfg = copy.deepcopy(model._cfg)
OmegaConf.set_struct(_model_cfg.preprocessor, False)
_model_cfg.preprocessor.dither = 0.0
_model_cfg.preprocessor.pad_to = 0
OmegaConf.set_struct(_model_cfg.preprocessor, True)
if _model_cfg.preprocessor.normalize != "per_feature":
log.warning(
"Preprocessor normalize=%s; chunked inference assumes per_feature",
_model_cfg.preprocessor.normalize,
)
MODEL_STRIDE_SECS = float(_model_cfg.preprocessor["window_stride"]) * MODEL_STRIDE
log.info(
"Long-audio config: chunk_len=%.1fs threshold=%.1fs model_stride=%.4fs",
CHUNK_LEN_SECS, CHUNKED_THRESHOLD_SECS, MODEL_STRIDE_SECS,
)
app = FastAPI(title="doctate-canary", version="0.1.0")
def _transcode_to_wav16k(src_path: str) -> str:
"""Convert any audio file to 16 kHz mono WAV. Returns the new path."""
dst = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
dst.close()
cmd = [
"ffmpeg", "-y", "-loglevel", "error",
"-i", src_path,
"-ac", "1", "-ar", "16000",
"-f", "wav",
dst.name,
]
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
os.unlink(dst.name)
raise HTTPException(status_code=400, detail=f"ffmpeg failed: {proc.stderr.strip()}")
return dst.name
def _wav_duration(path: str) -> float:
with wave.open(path, "rb") as w:
return w.getnframes() / float(w.getframerate())
def _transcribe_buffered(
wav_path: str,
duration: float,
language: str,
pnc: str,
timestamps: bool,
):
"""Long-audio path: explicit chunked inference via FrameBatchMultiTaskAED.
Writes a temporary single-line JSONL manifest because language and pnc
must be passed via manifest fields when using the buffered API.
"""
manifest_tmp = tempfile.NamedTemporaryFile(suffix=".json", mode="w", delete=False)
try:
json.dump(
{
"audio_filepath": wav_path,
"duration": duration,
"taskname": "asr",
"source_lang": language,
"target_lang": language,
"pnc": pnc,
},
manifest_tmp,
)
manifest_tmp.write("\n")
manifest_tmp.close()
frame_asr = FrameBatchMultiTaskAED(
asr_model=model,
frame_len=CHUNK_LEN_SECS,
total_buffer=CHUNK_LEN_SECS,
batch_size=8,
)
amp_dtype = DTYPE_MAP[PRECISION]
use_amp = amp_dtype != torch.float32
with torch.amp.autocast(model.device.type, enabled=use_amp, dtype=amp_dtype):
with torch.no_grad():
hyps = get_buffered_pred_feat_multitaskAED(
frame_asr,
_model_cfg.preprocessor,
MODEL_STRIDE_SECS,
model.device,
manifest=manifest_tmp.name,
filepaths=None,
timestamps=timestamps,
)
return hyps
finally:
try:
os.unlink(manifest_tmp.name)
except OSError:
pass
@app.get("/health")
def health():
return {"status": "ok", "model": MODEL_NAME, "device": DEVICE, "precision": PRECISION}
@app.get("/info")
def info():
return {
"model": MODEL_NAME,
"device": DEVICE,
"precision": PRECISION,
"models_dir": MODELS_DIR,
}
@app.post("/inference")
async def inference(
file: UploadFile = File(...),
language: str = Form("de"),
pnc: str = Form("yes"),
timestamps: str = Form("no"),
response_format: str = Form("text"),
):
if language not in {"en", "de", "fr", "es"}:
raise HTTPException(status_code=400, detail=f"Unsupported language: {language}")
if pnc not in {"yes", "no"}:
raise HTTPException(status_code=400, detail="pnc must be yes or no")
if timestamps not in {"yes", "no"}:
raise HTTPException(status_code=400, detail="timestamps must be yes or no")
if response_format not in {"text", "json"}:
raise HTTPException(status_code=400, detail="response_format must be text or json")
upload_suffix = os.path.splitext(file.filename or "")[1] or ".bin"
upload_tmp = tempfile.NamedTemporaryFile(suffix=upload_suffix, delete=False)
wav_path: Optional[str] = None
try:
upload_tmp.write(await file.read())
upload_tmp.flush()
upload_tmp.close()
wav_path = _transcode_to_wav16k(upload_tmp.name)
duration = _wav_duration(wav_path)
ts_flag = timestamps == "yes"
t0 = time.monotonic()
chunked = duration > CHUNKED_THRESHOLD_SECS
if chunked:
hyps = _transcribe_buffered(wav_path, duration, language, pnc, ts_flag)
else:
hyps = model.transcribe(
[wav_path],
source_lang=language,
target_lang=language,
pnc=pnc,
timestamps=ts_flag,
)
infer_secs = time.monotonic() - t0
if not hyps:
raise HTTPException(status_code=500, detail="Empty transcription result")
hyp = hyps[0]
text = (getattr(hyp, "text", None) or str(hyp)).strip()
log.info(json.dumps({
"event": "transcribe",
"duration_audio": round(duration, 2),
"duration_infer": round(infer_secs, 2),
"language": language,
"chars": len(text),
"chunked": chunked,
}))
if response_format == "json":
payload = {
"text": text,
"language": language,
"duration": duration,
}
if timestamps == "yes":
ts = getattr(hyp, "timestamp", None)
if ts:
payload["segments"] = ts.get("segment", [])
return JSONResponse(payload)
return PlainTextResponse(text)
finally:
for p in (upload_tmp.name, wav_path):
if p and os.path.exists(p):
try:
os.unlink(p)
except OSError:
pass