"""Canary parameter sweep — direct NeMo calls, no HTTP layer. Runs many parameter combinations against a fixed set of audios and writes JSONL + a human-readable summary. Designed to run inside the doctate-canary Docker image (or its base) with the model volume mounted at /models. Outputs: /sweep/results.jsonl — one JSON object per run /sweep/log.txt — chronological log """ import copy import json import os import subprocess import sys import tempfile import time import wave from contextlib import contextmanager import torch from omegaconf import OmegaConf 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 AUDIO_DIR = "/sweep/audios" OUT_DIR = "/sweep" RESULTS_PATH = f"{OUT_DIR}/results.jsonl" LOG_PATH = f"{OUT_DIR}/log.txt" # Subset for most axes — one short, one medium, one long (>40 s each). SUBSET = [ "2026-04-27T15-00-48Z.m4a", # 48.4 s "2026-04-27T14-55-41Z.m4a", # 65.9 s "2026-04-27T14-58-17Z.m4a", # 80.9 s ] # Single-audio subset for speed/VRAM-only axes. SINGLE = ["2026-04-27T14-58-17Z.m4a"] # 80.9 s DTYPE_MAP = {"bf16": torch.bfloat16, "fp16": torch.float16, "fp32": torch.float32} def log(msg: str) -> None: line = f"{time.strftime('%H:%M:%S')} {msg}" print(line, flush=True) with open(LOG_PATH, "a") as f: f.write(line + "\n") def transcode_to_wav16k(src: str) -> str: dst = tempfile.NamedTemporaryFile(suffix=".wav", delete=False) dst.close() subprocess.run( ["ffmpeg", "-y", "-loglevel", "error", "-i", src, "-ac", "1", "-ar", "16000", "-f", "wav", dst.name], check=True, ) return dst.name def wav_duration(path: str) -> float: with wave.open(path, "rb") as w: return w.getnframes() / float(w.getframerate()) def vram_mb() -> float: if torch.cuda.is_available(): return torch.cuda.max_memory_allocated() / (1024 * 1024) return 0.0 @contextmanager def vram_track(): torch.cuda.reset_peak_memory_stats() try: yield finally: pass def load_model(precision: str): log(f"loading model precision={precision}") t0 = time.monotonic() model = ASRModel.from_pretrained("nvidia/canary-1b-v2") model = model.to("cuda") if precision != "fp32": model = model.to(DTYPE_MAP[precision]) model.eval() log(f"model loaded in {time.monotonic() - t0:.1f}s, vram={vram_mb():.0f}MB") return model def prep_cfg(model): cfg = copy.deepcopy(model._cfg) OmegaConf.set_struct(cfg.preprocessor, False) cfg.preprocessor.dither = 0.0 cfg.preprocessor.pad_to = 0 OmegaConf.set_struct(cfg.preprocessor, True) return cfg def write_manifest(wav_path: str, duration: float, pnc: str = "yes", src_lang: str = "de", tgt_lang: str = "de", task: str = "asr") -> str: f = tempfile.NamedTemporaryFile(suffix=".json", mode="w", delete=False) json.dump({ "audio_filepath": wav_path, "duration": duration, "taskname": task, "source_lang": src_lang, "target_lang": tgt_lang, "pnc": pnc, }, f) f.write("\n") f.close() return f.name def run_buffered(model, model_cfg, wav_path: str, duration: float, chunk_len: float, total_buffer: float, batch_size: int, precision: str, pnc: str = "yes", timestamps: bool = False): manifest = write_manifest(wav_path, duration, pnc=pnc) feature_stride = model_cfg.preprocessor["window_stride"] model_stride_secs = feature_stride * 8 frame_asr = FrameBatchMultiTaskAED( asr_model=model, frame_len=chunk_len, total_buffer=total_buffer, batch_size=batch_size, ) amp_dtype = DTYPE_MAP[precision] use_amp = precision != "fp32" t0 = time.monotonic() with vram_track(): with torch.amp.autocast("cuda", 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, filepaths=None, timestamps=timestamps, ) elapsed = time.monotonic() - t0 peak_vram = vram_mb() os.unlink(manifest) text = (getattr(hyps[0], "text", None) or str(hyps[0])).strip() return text, elapsed, peak_vram def run_transcribe_path1(model, wav_path: str, batch_size: int, pnc: str, timestamps: bool, precision: str): """Path 1 — direct .transcribe() with batch_size=1 enables auto-chunking in NeMo >= 2.5 (per docstring of speech_to_text_aed_chunked_infer.py).""" amp_dtype = DTYPE_MAP[precision] use_amp = precision != "fp32" t0 = time.monotonic() with vram_track(): with torch.amp.autocast("cuda", enabled=use_amp, dtype=amp_dtype): with torch.no_grad(): hyps = model.transcribe( [wav_path], source_lang="de", target_lang="de", pnc=pnc, timestamps=timestamps, batch_size=batch_size, ) elapsed = time.monotonic() - t0 peak_vram = vram_mb() text = (getattr(hyps[0], "text", None) or str(hyps[0])).strip() return text, elapsed, peak_vram def write_result(record: dict) -> None: with open(RESULTS_PATH, "a") as f: f.write(json.dumps(record, ensure_ascii=False) + "\n") def sweep(): open(LOG_PATH, "w").close() open(RESULTS_PATH, "w").close() log("=== preparing audios ===") wavs = {} for name in set(SUBSET + SINGLE): src = os.path.join(AUDIO_DIR, name) wav = transcode_to_wav16k(src) dur = wav_duration(wav) wavs[name] = (wav, dur) log(f" {name} → {wav} ({dur:.1f}s)") # ===== precision axis (B.2) — separate model loads ===== for precision in ["bf16", "fp16", "fp32"]: try: torch.cuda.empty_cache() model = load_model(precision) cfg = prep_cfg(model) for name in SINGLE: wav, dur = wavs[name] axis = f"B2_precision={precision}" log(f"running {axis} on {name}") try: text, t, vram = run_buffered( model, cfg, wav, dur, chunk_len=40.0, total_buffer=40.0, batch_size=8, precision=precision, ) write_result({ "axis": "precision", "precision": precision, "chunk_len": 40.0, "overlap": 0, "batch_size": 8, "decoding": "greedy", "pnc": "yes", "timestamps": False, "audio": name, "duration_audio": dur, "duration_infer": round(t, 2), "vram_peak_mb": round(vram, 0), "chars": len(text), "text": text, }) except Exception as e: log(f" FAIL {axis} on {name}: {e}") write_result({"axis": "precision", "precision": precision, "audio": name, "error": str(e)}) del model torch.cuda.empty_cache() except Exception as e: log(f"FAIL precision={precision}: {e}") # All remaining axes use bf16 default model. torch.cuda.empty_cache() model = load_model("bf16") cfg = prep_cfg(model) # ===== chunk_len axis (A.1) ===== for chunk_len in [10.0, 20.0, 40.0, 60.0]: for name in SUBSET: wav, dur = wavs[name] axis = f"A1_chunk_len={chunk_len}" log(f"running {axis} on {name}") try: text, t, vram = run_buffered( model, cfg, wav, dur, chunk_len=chunk_len, total_buffer=chunk_len, batch_size=8, precision="bf16", ) write_result({ "axis": "chunk_len", "chunk_len": chunk_len, "overlap": 0, "batch_size": 8, "precision": "bf16", "decoding": "greedy", "pnc": "yes", "timestamps": False, "audio": name, "duration_audio": dur, "duration_infer": round(t, 2), "vram_peak_mb": round(vram, 0), "chars": len(text), "text": text, }) except Exception as e: log(f" FAIL: {e}") write_result({"axis": "chunk_len", "chunk_len": chunk_len, "audio": name, "error": str(e)}) # ===== overlap axis (A.2) — chunk_len=40, total_buffer=chunk_len+overlap ===== for overlap in [1.0, 2.0, 5.0, 10.0]: chunk_len = 40.0 total_buffer = chunk_len + overlap for name in SUBSET: wav, dur = wavs[name] axis = f"A2_overlap={overlap}" log(f"running {axis} on {name}") try: text, t, vram = run_buffered( model, cfg, wav, dur, chunk_len=chunk_len, total_buffer=total_buffer, batch_size=8, precision="bf16", ) write_result({ "axis": "overlap", "chunk_len": chunk_len, "overlap": overlap, "batch_size": 8, "precision": "bf16", "decoding": "greedy", "pnc": "yes", "timestamps": False, "audio": name, "duration_audio": dur, "duration_infer": round(t, 2), "vram_peak_mb": round(vram, 0), "chars": len(text), "text": text, }) except Exception as e: log(f" FAIL: {e}") write_result({"axis": "overlap", "overlap": overlap, "audio": name, "error": str(e)}) # ===== batch_size axis (B.1) ===== for batch_size in [1, 2, 4, 8, 16]: for name in SINGLE: wav, dur = wavs[name] axis = f"B1_batch={batch_size}" log(f"running {axis} on {name}") try: text, t, vram = run_buffered( model, cfg, wav, dur, chunk_len=40.0, total_buffer=40.0, batch_size=batch_size, precision="bf16", ) write_result({ "axis": "batch_size", "batch_size": batch_size, "chunk_len": 40.0, "overlap": 0, "precision": "bf16", "decoding": "greedy", "pnc": "yes", "timestamps": False, "audio": name, "duration_audio": dur, "duration_infer": round(t, 2), "vram_peak_mb": round(vram, 0), "chars": len(text), "text": text, }) except Exception as e: log(f" FAIL: {e}") write_result({"axis": "batch_size", "batch_size": batch_size, "audio": name, "error": str(e)}) # ===== pnc axis (C.2) ===== for pnc in ["no", "yes"]: for name in SUBSET: wav, dur = wavs[name] axis = f"C2_pnc={pnc}" log(f"running {axis} on {name}") try: text, t, vram = run_buffered( model, cfg, wav, dur, chunk_len=40.0, total_buffer=40.0, batch_size=8, precision="bf16", pnc=pnc, ) write_result({ "axis": "pnc", "pnc": pnc, "chunk_len": 40.0, "overlap": 0, "batch_size": 8, "precision": "bf16", "decoding": "greedy", "timestamps": False, "audio": name, "duration_audio": dur, "duration_infer": round(t, 2), "vram_peak_mb": round(vram, 0), "chars": len(text), "text": text, }) except Exception as e: log(f" FAIL: {e}") write_result({"axis": "pnc", "pnc": pnc, "audio": name, "error": str(e)}) # ===== timestamps axis (C.3) — when on, NeMo recommends chunk_len=10 ===== for ts_flag, chunk_len in [(False, 40.0), (True, 10.0)]: for name in SUBSET: wav, dur = wavs[name] axis = f"C3_timestamps={ts_flag}" log(f"running {axis} (chunk_len={chunk_len}) on {name}") try: text, t, vram = run_buffered( model, cfg, wav, dur, chunk_len=chunk_len, total_buffer=chunk_len, batch_size=8, precision="bf16", timestamps=ts_flag, ) write_result({ "axis": "timestamps", "timestamps": ts_flag, "chunk_len": chunk_len, "overlap": 0, "batch_size": 8, "precision": "bf16", "decoding": "greedy", "pnc": "yes", "audio": name, "duration_audio": dur, "duration_infer": round(t, 2), "vram_peak_mb": round(vram, 0), "chars": len(text), "text": text, }) except Exception as e: log(f" FAIL: {e}") write_result({"axis": "timestamps", "timestamps": ts_flag, "audio": name, "error": str(e)}) # ===== beam search (C.1) ===== try: from nemo.collections.asr.parts.submodules.multitask_decoding import MultiTaskDecodingConfig for strategy, beam_size in [("beam", 4), ("greedy", 1)]: decoding_cfg = MultiTaskDecodingConfig() decoding_cfg.strategy = strategy if hasattr(decoding_cfg, "beam"): decoding_cfg.beam.beam_size = beam_size try: model.change_decoding_strategy(decoding_cfg) except Exception as e: log(f"change_decoding_strategy failed for {strategy}: {e}") continue for name in SUBSET: wav, dur = wavs[name] axis = f"C1_decoding={strategy}_beam={beam_size}" log(f"running {axis} on {name}") try: text, t, vram = run_buffered( model, cfg, wav, dur, chunk_len=40.0, total_buffer=40.0, batch_size=8, precision="bf16", ) write_result({ "axis": "decoding", "decoding": strategy, "beam_size": beam_size, "chunk_len": 40.0, "overlap": 0, "batch_size": 8, "precision": "bf16", "pnc": "yes", "timestamps": False, "audio": name, "duration_audio": dur, "duration_infer": round(t, 2), "vram_peak_mb": round(vram, 0), "chars": len(text), "text": text, }) except Exception as e: log(f" FAIL: {e}") write_result({"axis": "decoding", "decoding": strategy, "audio": name, "error": str(e)}) except Exception as e: log(f"beam search axis skipped: {e}") # ===== Path-1 (.transcribe() auto-chunking) — D.1 ===== log("=== Path 1: model.transcribe() with auto-chunking ===") for name in SUBSET: wav, dur = wavs[name] axis = "D1_path1_transcribe" log(f"running {axis} on {name}") try: text, t, vram = run_transcribe_path1( model, wav, batch_size=1, pnc="yes", timestamps=False, precision="bf16", ) write_result({ "axis": "path1", "path": "transcribe_auto_chunking", "batch_size": 1, "precision": "bf16", "audio": name, "duration_audio": dur, "duration_infer": round(t, 2), "vram_peak_mb": round(vram, 0), "chars": len(text), "text": text, }) except Exception as e: log(f" FAIL: {e}") write_result({"axis": "path1", "audio": name, "error": str(e)}) log("=== sweep complete ===") if __name__ == "__main__": sweep()