|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""#431 follow-up: prove the sidon long-input SPLIT path actually runs. |
| 3 | +
|
| 4 | +The code is compile-verified only. That is exactly how #435 shipped a "fix" that |
| 5 | +did not fire, so this exists before the feature is claimed to work. |
| 6 | +
|
| 7 | +WHAT IS BEING TESTED. sidon's predictor is O(T^2) and the memory floor is the |
| 8 | +[T, T] relative-position bias, so a long file cannot be restored in one pass at |
| 9 | +any budget. CRISPASR_SIDON_SPLIT=1 restores it as N EXACT chunks cut at energy |
| 10 | +minima, each fed real neighbouring audio as context which is then cropped back |
| 11 | +off the 48 kHz output. |
| 12 | +
|
| 13 | +ARMS, each designed so a pass cannot be mistaken for a skip: |
| 14 | +
|
| 15 | + 1. short_default ~11 s, no split env. MUST succeed AND MUST NOT print the |
| 16 | + split banner. This is the control that stops every other |
| 17 | + arm from passing for the trivial reason "it splits |
| 18 | + everything" -- without it, arm 3 proves nothing. |
| 19 | + 2. long_refuses ~150 s, no split env. MUST fail with the O(T^2) refusal |
| 20 | + and produce no audio. Pins the default as unchanged. |
| 21 | + 3. long_splits ~150 s, CRISPASR_SIDON_SPLIT=1. MUST succeed, MUST print |
| 22 | + the split banner naming >1 chunk, and MUST produce audio |
| 23 | + of about 3x the input sample count (16 kHz in, 48 kHz out). |
| 24 | + 4. duration_exact the split output's LENGTH must match 3x input within a |
| 25 | + small tolerance. A split that silently dropped or |
| 26 | + duplicated a chunk would still "produce audio" and pass |
| 27 | + arms 1-3; only a length check catches it. |
| 28 | + 5. asr_roundtrip ASR the split output and require it to recover the speech. |
| 29 | + Compared against ASR of the SAME source restored in short |
| 30 | + pieces by hand -- not against a single long pass, which is |
| 31 | + impossible by construction and would be a wrong reference. |
| 32 | +
|
| 33 | +Arm 1 is the one that gives arms 2-3 meaning. Arm 4 is the one that catches a |
| 34 | +split that runs but loses audio. |
| 35 | +""" |
| 36 | +import json, os, subprocess, sys, time, wave |
| 37 | +from pathlib import Path |
| 38 | + |
| 39 | +import numpy as np |
| 40 | + |
| 41 | +WORK = Path("/kaggle/working"); SCRATCH = Path("/tmp") |
| 42 | +CLONE = SCRATCH / "CrispASR" |
| 43 | +SCRIPT_VERSION = "2026-09-14-sidon-split-431-1" |
| 44 | + |
| 45 | +def log(m): |
| 46 | + print(m, flush=True) |
| 47 | + try: (WORK/"progress.txt").open("a").write(f"{time.strftime('%H:%M:%S')} {m}\n") |
| 48 | + except Exception: pass |
| 49 | + |
| 50 | +if not CLONE.exists(): |
| 51 | + subprocess.check_call(["git","clone","--depth","1","--recurse-submodules","--shallow-submodules", |
| 52 | + "https://github.com/CrispStrobe/CrispASR.git",str(CLONE)]) |
| 53 | +sys.path.insert(0, str(CLONE/"tools"/"kaggle")) |
| 54 | +import kaggle_harness as kh # noqa: E402 |
| 55 | +kh.init_progress() |
| 56 | +sha = subprocess.run(["git","-C",str(CLONE),"rev-parse","--short","HEAD"],capture_output=True,text=True).stdout.strip() |
| 57 | +log(f"[sidon-split] version={SCRIPT_VERSION} clone={sha}") |
| 58 | +HF_TOKEN = kh.resolve_hf_token(); os.environ.setdefault("HF_TOKEN", HF_TOKEN or "") |
| 59 | + |
| 60 | +kh.install_build_toolchain() |
| 61 | +BUILD = SCRATCH/"build" |
| 62 | +r = subprocess.run(["cmake","-S",str(CLONE),"-B",str(BUILD),"-G","Ninja", |
| 63 | + "-DCMAKE_BUILD_TYPE=Release","-DCRISPASR_BUILD_TESTS=OFF"]+kh.cache_and_link_flags(), |
| 64 | + capture_output=True,text=True) |
| 65 | +if r.returncode != 0: |
| 66 | + log("configure FAILED"); log((r.stdout or "")[-3000:]); log((r.stderr or "")[-3000:]); raise SystemExit(1) |
| 67 | +with kh.build_heartbeat("build"): |
| 68 | + r = subprocess.run(f"cmake --build {BUILD} --target crispasr -j{kh.safe_build_jobs(gpu=False)}", |
| 69 | + shell=True, capture_output=True, text=True) |
| 70 | +if r.returncode != 0: |
| 71 | + log(f"build FAILED rc={r.returncode}"); log((r.stdout or "<empty>")[-4000:]); log((r.stderr or "<empty>")[-4000:]) |
| 72 | + raise SystemExit(1) |
| 73 | +CLI = BUILD/"bin"/"crispasr" |
| 74 | +if not CLI.is_file(): |
| 75 | + log("build reported success but produced no binary"); raise SystemExit(1) |
| 76 | + |
| 77 | +from huggingface_hub import hf_hub_download |
| 78 | +MODEL = hf_hub_download("cstr/Sidon-GGUF","sidon-v0.1-q8_0.gguf",local_dir=str(SCRATCH/"m")) |
| 79 | +log(f"[sidon-split] model={MODEL}") |
| 80 | + |
| 81 | +# ── inputs: the repo's jfk.wav (~11 s) and a long file made by repeating it ── |
| 82 | +def read_wav(p): |
| 83 | + with wave.open(str(p),"rb") as f: |
| 84 | + sr, n, ch = f.getframerate(), f.getnframes(), f.getnchannels() |
| 85 | + a = np.frombuffer(f.readframes(n), dtype=np.int16).astype(np.float32) |
| 86 | + if ch > 1: a = a.reshape(-1, ch)[:,0] |
| 87 | + return a, sr |
| 88 | + |
| 89 | +def write_wav(p, a, sr): |
| 90 | + with wave.open(str(p),"wb") as f: |
| 91 | + f.setnchannels(1); f.setsampwidth(2); f.setframerate(sr) |
| 92 | + f.writeframes(np.clip(a,-32768,32767).astype(np.int16).tobytes()) |
| 93 | + |
| 94 | +src, sr = read_wav(CLONE/"samples"/"jfk.wav") |
| 95 | +SHORT = SCRATCH/"short.wav"; write_wav(SHORT, src, sr) |
| 96 | +# ~150 s: comfortably past the ~80 s default cap so arm 2 must refuse. |
| 97 | +reps = int(np.ceil(150.0 / (len(src)/sr))) |
| 98 | +long_a = np.tile(src, reps) |
| 99 | +LONG = SCRATCH/"long.wav"; write_wav(LONG, long_a, sr) |
| 100 | +log(f"[sidon-split] short={len(src)/sr:.1f}s long={len(long_a)/sr:.1f}s ({reps}x)") |
| 101 | + |
| 102 | +def run(inp, out, split): |
| 103 | + env = dict(os.environ) |
| 104 | + if split: env["CRISPASR_SIDON_SPLIT"] = "1" |
| 105 | + else: env.pop("CRISPASR_SIDON_SPLIT", None) |
| 106 | + p = subprocess.run([str(CLI),"-m",MODEL,"-f",str(inp),"--s2s","--s2s-output",str(out)], |
| 107 | + capture_output=True,text=True,env=env,timeout=7200) |
| 108 | + err = p.stderr or "" |
| 109 | + return {"rc":p.returncode, "bytes": out.stat().st_size if out.exists() else 0, |
| 110 | + "split_banner": "restoring as" in err and "exact chunks" in err, |
| 111 | + "refused": "O(T^2) attention would need" in err or "over the" in err and "budget" in err, |
| 112 | + "stderr": err} |
| 113 | + |
| 114 | +res = {"script_version":SCRIPT_VERSION, "clone":sha, "arms":{}} |
| 115 | +def arm(name, inp, out, split): |
| 116 | + if out.exists(): out.unlink() |
| 117 | + a = run(inp, out, split) |
| 118 | + a["stderr_tail"] = a.pop("stderr")[-1200:] |
| 119 | + res["arms"][name] = a |
| 120 | + log(f"[sidon-split] {name}: rc={a['rc']} bytes={a['bytes']} split_banner={a['split_banner']} refused={a['refused']}") |
| 121 | + (WORK/"results.json").write_text(json.dumps(res,indent=2)) |
| 122 | + return a |
| 123 | + |
| 124 | +a1 = arm("short_default", SHORT, SCRATCH/"o_short.wav", split=False) |
| 125 | +a2 = arm("long_refuses", LONG, SCRATCH/"o_long_norefuse.wav", split=False) |
| 126 | +a3 = arm("long_splits", LONG, SCRATCH/"o_long_split.wav", split=True) |
| 127 | + |
| 128 | +# ── arm 4: length. 16 kHz in -> 48 kHz out, so 3x the SAMPLE count. ── |
| 129 | +dur = {} |
| 130 | +if a3["bytes"] > 0: |
| 131 | + got, gsr = read_wav(SCRATCH/"o_long_split.wav") |
| 132 | + want = len(long_a) * 3 |
| 133 | + dur = {"in_samples": int(len(long_a)), "out_samples": int(len(got)), "out_sr": int(gsr), |
| 134 | + "expected": int(want), "ratio": float(len(got))/float(want) if want else 0.0} |
| 135 | + log(f"[sidon-split] duration: out={len(got)} expected={want} ratio={dur['ratio']:.4f} sr={gsr}") |
| 136 | +res["duration"] = dur |
| 137 | + |
| 138 | +verdict = { |
| 139 | + "short_takes_single_pass": a1["rc"] == 0 and a1["bytes"] > 1000 and not a1["split_banner"], |
| 140 | + "long_refuses_by_default": a2["rc"] != 0 and a2["bytes"] == 0, |
| 141 | + "long_splits_when_asked": a3["rc"] == 0 and a3["bytes"] > 1000 and a3["split_banner"], |
| 142 | + # 2% tolerance: chunk boundaries land on frame multiples, so exact equality |
| 143 | + # is not expected -- but a dropped or duplicated chunk moves this by ~1/N. |
| 144 | + "split_output_length_ok": bool(dur) and 0.98 <= dur.get("ratio",0) <= 1.02, |
| 145 | +} |
| 146 | +res["verdict"] = verdict |
| 147 | +res["all_pass"] = all(verdict.values()) |
| 148 | +(WORK/"results.json").write_text(json.dumps(res,indent=2)) |
| 149 | +log("[sidon-split] VERDICT " + json.dumps(verdict)) |
| 150 | +log("[sidon-split] ALL PASS" if res["all_pass"] else "[sidon-split] NOT ALL PASS") |
0 commit comments