Files
Brummel 6b7a1cea49 feat: Add Canary ASR model service and sweep experiments
This commit introduces the `doctate-canary` service and associated sweep
experiments.

The service provides access to the `nvidia/canary-1b-v2` ASR model via a
native FastAPI API. It includes deployment scripts, Docker
configurations, and detailed documentation on installation, API usage,
and environment variables.

The sweep experiments aim to thoroughly evaluate the Canary model's
performance under various configurations. This includes testing
different inference pipelines (single-shot vs. buffered), decoding
strategies (greedy vs. beam search), and parameter tuning (chunk length,
overlap, batch size, precision). The goal is to reproduce previous
findings and identify optimal settings.

The commit also includes:
- Utility scripts for audio transcoding and manipulation.
- Comprehensive logging and result collection mechanisms for the sweep
  runs.
- Detailed analysis of `dur=0` occurrences and word confidence,
  concluding they are not reliable indicators of hallucination.
- Documentation on the interaction between decoding parameters,
  especially `return_hypotheses`, and the availability of confidence
  scores.
2026-04-30 14:30:12 +02:00

216 lines
7.5 KiB
Python

"""TDT probe — can timestamps_asr_model handle audio standalone, including >40 s?
Background:
EncDecMultiTaskModel.timestamps_asr_model is a Token-Duration-Transducer
(RNN-T variant) with hybrid CTC head, FastConformer encoder enc_hidden=1024.
Bundled inside Canary 1B v2 specifically to provide forced-alignment
timestamps for the AED head, but it is itself a complete ASR model.
Open question: does the relative positional encoding lift the 40 s wall that
AED hits, or does TDT inherit the same training-time max_duration limit?
Test set:
- 20 s : sanity check, well within both models
- 80 s : full source — Canary path1 truncates here, baseline expectation
- 160 s : concat(80, 80), no re-encode — pushes 4x past TDT training horizon
Per case:
- TDT.transcribe(timestamps=False)
- TDT.transcribe(timestamps=True) — also verify timestamp shape
- Canary path1 — reference, will truncate >40 s
"""
import json
import os
import subprocess
import tempfile
import time
import wave
import torch
from nemo.collections.asr.models import EncDecMultiTaskModel
AUDIO_DIR = "/sweep/threshold_audios"
RESULTS_PATH = "/sweep/probe_tdt.jsonl"
LOG_PATH = "/sweep/probe_tdt.log"
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(src: str, duration_secs: float | None = None) -> str:
dst = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
dst.close()
cmd = ["ffmpeg", "-y", "-loglevel", "error", "-i", src,
"-ac", "1", "-ar", "16000"]
if duration_secs is not None:
cmd += ["-t", str(duration_secs)]
cmd += ["-f", "wav", dst.name]
subprocess.run(cmd, check=True)
return dst.name
def concat_wavs(wavs: list[str], repeats: int = 1) -> str:
list_file = tempfile.NamedTemporaryFile(suffix=".txt", mode="w", delete=False)
for w in wavs * repeats:
list_file.write(f"file '{w}'\n")
list_file.close()
dst = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
dst.close()
subprocess.run(["ffmpeg", "-y", "-loglevel", "error",
"-f", "concat", "-safe", "0", "-i", list_file.name,
"-c", "copy", dst.name], check=True)
os.unlink(list_file.name)
return dst.name
def wav_duration(p: str) -> float:
with wave.open(p, "rb") as w:
return w.getnframes() / float(w.getframerate())
def vram_mb() -> float:
return torch.cuda.max_memory_allocated() / (1024 * 1024) if torch.cuda.is_available() else 0.0
def run_tdt(tdt, wav: str, timestamps: bool) -> dict:
torch.cuda.reset_peak_memory_stats()
t0 = time.monotonic()
try:
with torch.amp.autocast("cuda", enabled=True, dtype=torch.bfloat16):
with torch.no_grad():
hyps = tdt.transcribe([wav], timestamps=timestamps)
elapsed = time.monotonic() - t0
h = hyps[0]
text = (getattr(h, "text", None) or "").strip()
ts = getattr(h, "timestamp", None)
ts_summary = None
if isinstance(ts, dict):
ts_summary = {k: (len(v) if hasattr(v, "__len__") else str(type(v).__name__))
for k, v in ts.items()}
return {"ok": True, "text": text, "chars": len(text),
"duration_infer": round(elapsed, 2),
"vram_peak_mb": round(vram_mb(), 0),
"timestamp_summary": ts_summary}
except Exception as e:
return {"ok": False, "error": f"{type(e).__name__}: {e}",
"duration_infer": round(time.monotonic() - t0, 2)}
def run_canary(model, wav: str) -> dict:
torch.cuda.reset_peak_memory_stats()
t0 = time.monotonic()
try:
with torch.amp.autocast("cuda", enabled=True, dtype=torch.bfloat16):
with torch.no_grad():
hyps = model.transcribe([wav], source_lang="de", target_lang="de",
pnc="yes", batch_size=4)
elapsed = time.monotonic() - t0
text = (getattr(hyps[0], "text", None) or str(hyps[0])).strip()
return {"ok": True, "text": text, "chars": len(text),
"duration_infer": round(elapsed, 2),
"vram_peak_mb": round(vram_mb(), 0)}
except Exception as e:
return {"ok": False, "error": f"{type(e).__name__}: {e}",
"duration_infer": round(time.monotonic() - t0, 2)}
def find_source() -> str | None:
for c in ("trim_full.m4a", "full.m4a", "source.m4a"):
p = os.path.join(AUDIO_DIR, c)
if os.path.exists(p):
return p
if not os.path.isdir(AUDIO_DIR):
return None
m4as = [f for f in os.listdir(AUDIO_DIR) if f.endswith(".m4a")]
if not m4as:
return None
m4as.sort(key=lambda f: os.path.getsize(os.path.join(AUDIO_DIR, f)), reverse=True)
return os.path.join(AUDIO_DIR, m4as[0])
def main():
open(RESULTS_PATH, "w").close()
open(LOG_PATH, "w").close()
log("loading EncDecMultiTaskModel (Canary 1B v2)")
model = EncDecMultiTaskModel.from_pretrained("nvidia/canary-1b-v2")
model = model.to("cuda").to(torch.bfloat16)
model.eval()
log(f" vram after load: {vram_mb():.0f} MB")
tdt = getattr(model, "timestamps_asr_model", None)
if tdt is None:
log("FATAL: model has no timestamps_asr_model attribute")
return
log(f" tdt class: {type(tdt).__name__}")
log(f" tdt device: {next(tdt.parameters()).device}, dtype: {next(tdt.parameters()).dtype}")
src = find_source()
if src is None:
log(f"FATAL: no source audio found in {AUDIO_DIR}")
return
log(f"source audio: {src}")
wav_full = transcode(src)
dur_full = wav_duration(wav_full)
log(f" full wav: {dur_full:.1f} s")
wav_20 = transcode(src, duration_secs=20.0)
log(f" 20 s wav: {wav_duration(wav_20):.1f} s")
wav_80 = transcode(src, duration_secs=80.0) if dur_full >= 80 else wav_full
log(f" 80 s wav: {wav_duration(wav_80):.1f} s")
wav_160 = concat_wavs([wav_80], repeats=2)
log(f" 160 s concat wav: {wav_duration(wav_160):.1f} s")
cases = [("20s", wav_20), ("80s", wav_80), ("160s_concat", wav_160)]
def record(d: dict) -> None:
with open(RESULTS_PATH, "a") as f:
f.write(json.dumps(d, ensure_ascii=False) + "\n")
for label, wav in cases:
dur = wav_duration(wav)
log(f"\n=== case {label} ({dur:.1f} s) ===")
log(" TDT timestamps=False")
r = run_tdt(tdt, wav, timestamps=False)
record({"case": label, "duration_audio": dur, "model": "tdt",
"timestamps": False, **r})
if r["ok"]:
log(f" -> {r['chars']} chars in {r['duration_infer']} s, "
f"{r['vram_peak_mb']} MB")
else:
log(f" FAIL: {r['error']}")
log(" TDT timestamps=True")
r = run_tdt(tdt, wav, timestamps=True)
record({"case": label, "duration_audio": dur, "model": "tdt",
"timestamps": True, **r})
if r["ok"]:
log(f" -> {r['chars']} chars, ts_summary={r['timestamp_summary']}")
else:
log(f" FAIL: {r['error']}")
log(" Canary path1 (reference)")
r = run_canary(model, wav)
record({"case": label, "duration_audio": dur, "model": "canary_path1",
**r})
if r["ok"]:
log(f" -> {r['chars']} chars in {r['duration_infer']} s")
else:
log(f" FAIL: {r['error']}")
log("done")
if __name__ == "__main__":
main()