Guest User

CS2 Shot Latency Test Script

a guest
Nov 3rd, 2026
156
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 23.12 KB | None | 0 0
  1. #!/usr/bin/env python3
  2. """
  3. cs2_shot_latency.py - measure click -> gunshot-sound latency in CS2 / CS:GO, per weapon.
  4.  
  5. How it works
  6. * Records what your PC is playing (WASAPI loopback) or a microphone.
  7. * Fires the weapon itself with SendInput at random intervals (or records your
  8. own clicks in --manual mode), timestamping each click with the
  9. high-resolution performance counter.
  10. * Maps every audio sample onto the same clock (drift-corrected), finds the
  11. onset of each gunshot, and logs click->sound latency to results/shots.csv.
  12. * --report compares weapons (median, spread, bootstrap 95% CI of the gap).
  13.  
  14. Absolute numbers include a constant Windows-audio pipeline offset; the
  15. DIFFERENCE between weapons is the interesting part (e.g. AK-47 vs Deagle).
  16.  
  17. Usage (Windows, game in an offline practice match):
  18. python cs2_shot_latency.py --label deagle --shots 40 # press F8 in game to start
  19. python cs2_shot_latency.py --label ak47 --shots 40
  20. python cs2_shot_latency.py --report --plot
  21. CS:GO vs CS2 (use the same weapon labels in both games):
  22. python cs2_shot_latency.py --game csgo --label ak47 --shots 40
  23. python cs2_shot_latency.py --game cs2 --label ak47 --shots 40
  24. python cs2_shot_latency.py --report --plot # per-weapon gap + dumbbell chart
  25. Testing a fix (e.g. snd_delay_sound_ms_shift 0 under -dev):
  26. python cs2_shot_latency.py --game cs2fix --label ak47 --shots 40
  27. Other:
  28. --manual record your own clicks instead of auto-firing (F8 start/stop)
  29. --list-devices show audio devices; --device N pick one (e.g. a mic)
  30. --analyze STEM re-run detection on a saved session with new settings
  31. --selftest verify the detector on synthetic audio (works on any OS)
  32. F9 aborts a run.
  33. """
  34. import argparse
  35. import csv
  36. import json
  37. import random
  38. import sys
  39. import threading
  40. import time
  41. import wave
  42. from pathlib import Path
  43.  
  44. import numpy as np
  45.  
  46. OUT = Path("results")
  47. SHOTS_CSV = OUT / "shots.csv"
  48. GAME_NAMES = {"csgo": "CS:GO", "cs2": "CS2", "cs2fix": "CS2 fix"}
  49. GAME_ORDER = ["csgo", "cs2", "cs2fix"]
  50.  
  51.  
  52. # ============================================================================
  53. # Analysis (pure numpy/scipy - runs on any OS)
  54. # ============================================================================
  55.  
  56. def highpass(x, sr, fc=200.0):
  57. """Remove rumble/ambience below fc so the gunshot transient stands out."""
  58. try:
  59. from scipy.signal import butter, sosfilt
  60. sos = butter(4, fc, btype="highpass", fs=sr, output="sos")
  61. return sosfilt(sos, x)
  62. except ImportError:
  63. return np.concatenate([[0.0], np.diff(x)]) # crude pre-emphasis fallback
  64.  
  65.  
  66. def envelope(x, sr, win_ms=0.5):
  67. w = max(1, int(sr * win_ms / 1000))
  68. return np.sqrt(np.convolve(x * x, np.ones(w) / w, mode="same"))
  69.  
  70.  
  71. def detect_onsets(mono, sr, click_idx, pre_ms=80, search_ms=300, k=6.0,
  72. peak_frac=0.05, hp=200.0):
  73. """For each click sample index, return (latency_ms | None, baseline, peak).
  74.  
  75. Threshold = max(k * 99th-percentile of the pre-click envelope,
  76. peak_frac * loudest envelope value in the search window).
  77. The relative-to-peak part makes loud and quiet guns comparable.
  78. """
  79. env = envelope(highpass(mono.astype(np.float64), sr, hp), sr)
  80. pre, srch, guard = (int(sr * v / 1000) for v in (pre_ms, search_ms, 2))
  81. out = []
  82. for c in click_idx:
  83. c = int(round(c))
  84. if c - pre < 0 or c + srch > len(env):
  85. out.append((None, 0.0, 0.0))
  86. continue
  87. base = float(np.percentile(env[c - pre:c - guard], 99))
  88. seg = env[c:c + srch]
  89. peak = float(seg.max())
  90. thr = max(base * k, peak * peak_frac)
  91. hits = np.nonzero(seg > thr)[0]
  92. if peak < base * k or len(hits) == 0:
  93. out.append((None, base, peak)) # no clear shot found
  94. continue
  95. out.append((hits[0] * 1000.0 / sr, base, peak))
  96. return out
  97.  
  98.  
  99. def fit_clock(cb_log, sr):
  100. """cb_log: [(perf_ns_at_callback, total_frames_after_block), ...].
  101.  
  102. Callbacks only ever arrive late, so the lower envelope of
  103. (callback_time - frames/sr) is the true start-of-stream time. A line fit
  104. through per-second minima also corrects audio-clock vs CPU-clock drift.
  105. Returns f(perf_ns) -> fractional sample index.
  106. """
  107. a = np.asarray(cb_log, dtype=np.float64)
  108. t, f = a[:, 0], a[:, 1]
  109. off = t - f * 1e9 / sr
  110. bins = ((t - t[0]) // 1e9).astype(int)
  111. fx, fy = [], []
  112. for b in np.unique(bins):
  113. m = bins == b
  114. j = np.argmin(off[m])
  115. fx.append(f[m][j]); fy.append(off[m][j])
  116. if len(fx) >= 3:
  117. slope, icpt = np.polyfit(fx, fy, 1)
  118. else:
  119. slope, icpt = 0.0, float(off.min())
  120. return lambda t_ns: (t_ns - icpt) / (slope + 1e9 / sr)
  121.  
  122.  
  123. # ============================================================================
  124. # Windows capture + input
  125. # ============================================================================
  126.  
  127. def win_setup():
  128. import ctypes
  129. ctypes.windll.winmm.timeBeginPeriod(1) # 1 ms sleep resolution
  130. k32 = ctypes.windll.kernel32
  131. k32.SetPriorityClass(k32.GetCurrentProcess(), 0x80) # HIGH_PRIORITY_CLASS
  132.  
  133.  
  134. def make_clicker():
  135. import ctypes
  136. from ctypes import wintypes
  137.  
  138. class MOUSEINPUT(ctypes.Structure):
  139. _fields_ = [("dx", wintypes.LONG), ("dy", wintypes.LONG),
  140. ("mouseData", wintypes.DWORD), ("dwFlags", wintypes.DWORD),
  141. ("time", wintypes.DWORD), ("dwExtraInfo", ctypes.c_size_t)]
  142.  
  143. class _U(ctypes.Union):
  144. _fields_ = [("mi", MOUSEINPUT), ("_pad", ctypes.c_byte * 32)]
  145.  
  146. class INPUT(ctypes.Structure):
  147. _anonymous_ = ("u",)
  148. _fields_ = [("type", wintypes.DWORD), ("u", _U)]
  149.  
  150. send = ctypes.windll.user32.SendInput
  151.  
  152. def press(flag):
  153. inp = INPUT(type=0)
  154. inp.mi.dwFlags = flag
  155. send(1, ctypes.byref(inp), ctypes.sizeof(INPUT))
  156.  
  157. return lambda: press(0x0002), lambda: press(0x0004) # LEFTDOWN, LEFTUP
  158.  
  159.  
  160. def open_audio(device_index=None):
  161. import pyaudiowpatch as pyaudio
  162. p = pyaudio.PyAudio()
  163. wasapi = p.get_host_api_info_by_type(pyaudio.paWASAPI)
  164. if device_index is None:
  165. dev = p.get_device_info_by_index(wasapi["defaultOutputDevice"])
  166. if not dev.get("isLoopbackDevice"):
  167. for lb in p.get_loopback_device_info_generator():
  168. if dev["name"] in lb["name"]:
  169. dev = lb
  170. break
  171. else:
  172. sys.exit("Couldn't find a loopback device for your default output.")
  173. else:
  174. dev = p.get_device_info_by_index(device_index)
  175. return p, pyaudio, dev, wasapi
  176.  
  177.  
  178. def list_devices():
  179. import pyaudiowpatch as pyaudio
  180. p = pyaudio.PyAudio()
  181. for i in range(p.get_device_count()):
  182. d = p.get_device_info_by_index(i)
  183. if d["maxInputChannels"] > 0:
  184. api = p.get_host_api_info_by_index(d["hostApi"])["name"]
  185. tag = " [loopback]" if d.get("isLoopbackDevice") else ""
  186. print(f"{i:3d} {d['name']}{tag} ({api}, {int(d['defaultSampleRate'])} Hz)")
  187. p.terminate()
  188.  
  189.  
  190. def record_session(args):
  191. from pynput import keyboard, mouse
  192.  
  193. win_setup()
  194. p, pyaudio, dev, wasapi = open_audio(args.device)
  195. sr, ch = int(dev["defaultSampleRate"]), max(1, dev["maxInputChannels"])
  196. print(f"Audio: {dev['name']} {sr} Hz, {ch} ch")
  197.  
  198. blocks, cb_log, frames = [], [], [0]
  199.  
  200. def cb(data, n, _ti, _st):
  201. t = time.perf_counter_ns()
  202. blocks.append(np.frombuffer(data, dtype=np.float32).copy())
  203. frames[0] += n
  204. cb_log.append((t, frames[0]))
  205. return None, pyaudio.paContinue
  206.  
  207. # A silent output stream keeps WASAPI loopback delivering data in quiet moments.
  208. keep = p.open(format=pyaudio.paFloat32, channels=2, rate=sr, output=True,
  209. output_device_index=wasapi["defaultOutputDevice"],
  210. stream_callback=lambda *_: (bytes(8 * 256), pyaudio.paContinue),
  211. frames_per_buffer=256)
  212. stream = p.open(format=pyaudio.paFloat32, channels=ch, rate=sr, input=True,
  213. input_device_index=dev["index"], frames_per_buffer=128,
  214. stream_callback=cb)
  215.  
  216. start_ev, stop_ev, abort_ev = threading.Event(), threading.Event(), threading.Event()
  217.  
  218. def on_key(key):
  219. if key == keyboard.Key.f8:
  220. (stop_ev if start_ev.is_set() else start_ev).set()
  221. elif key == keyboard.Key.f9:
  222. abort_ev.set(); stop_ev.set(); start_ev.set()
  223.  
  224. klis = keyboard.Listener(on_press=on_key); klis.start()
  225. clicks = []
  226.  
  227. mode = "MANUAL: fire as you like, F8 again to stop" if args.manual else \
  228. f"AUTO: {args.shots} shots, {args.min_gap}-{args.max_gap} ms apart"
  229. print(f"[{args.label}] {mode}.\nSwitch to CS2 and press F8 to start (F9 aborts).")
  230. start_ev.wait()
  231. if abort_ev.is_set():
  232. sys.exit("Aborted.")
  233.  
  234. if args.manual:
  235. def on_click(_x, _y, button, pressed):
  236. if pressed and button == mouse.Button.left and start_ev.is_set():
  237. clicks.append(time.perf_counter_ns())
  238. mlis = mouse.Listener(on_click=on_click); mlis.start()
  239. print("Recording... F8 to stop.")
  240. stop_ev.wait()
  241. mlis.stop()
  242. else:
  243. down, up = make_clicker()
  244. time.sleep(1.5)
  245. for i in range(args.shots):
  246. if abort_ev.wait(random.uniform(args.min_gap, args.max_gap) / 1000):
  247. break
  248. t = time.perf_counter_ns()
  249. down()
  250. time.sleep(args.hold / 1000)
  251. up()
  252. clicks.append(t)
  253. print(f"\r shot {i + 1}/{args.shots}", end="", flush=True)
  254. print()
  255.  
  256. time.sleep(0.6) # catch the last shot's sound
  257. stream.stop_stream(); stream.close(); keep.stop_stream(); keep.close()
  258. p.terminate(); klis.stop()
  259. if abort_ev.is_set() and not clicks:
  260. sys.exit("Aborted.")
  261.  
  262. audio = np.concatenate(blocks).reshape(-1, ch)
  263. to_sample = fit_clock(cb_log, sr)
  264. click_idx = [float(to_sample(t)) for t in clicks]
  265.  
  266. OUT.mkdir(exist_ok=True)
  267. stem = f"{args.game}_{args.label}_{time.strftime('%Y%m%d_%H%M%S')}"
  268. with wave.open(str(OUT / f"{stem}.wav"), "wb") as w:
  269. w.setnchannels(ch); w.setsampwidth(2); w.setframerate(sr)
  270. w.writeframes((np.clip(audio, -1, 1) * 32767).astype("<i2").tobytes())
  271. (OUT / f"{stem}.json").write_text(json.dumps(
  272. {"label": args.label, "game": args.game, "sr": sr, "click_samples": click_idx,
  273. "mode": "manual" if args.manual else "auto"}, indent=1))
  274. analyze_and_log(stem, args.game, args.label, audio, sr, click_idx, args)
  275.  
  276.  
  277. def analyze_and_log(stem, game, label, audio, sr, click_idx, args):
  278. mono = audio[:, :2].mean(axis=1) # front L/R
  279. res = detect_onsets(mono, sr, click_idx, k=args.k, peak_frac=args.peak_frac,
  280. hp=args.hp)
  281. lat = np.array([r[0] for r in res if r[0] is not None])
  282. new = not SHOTS_CSV.exists()
  283. with open(SHOTS_CSV, "a", newline="") as fh:
  284. w = csv.writer(fh)
  285. if new:
  286. w.writerow(["session", "game", "label", "shot", "latency_ms", "baseline", "peak"])
  287. for i, (l, b, pk) in enumerate(res):
  288. w.writerow([stem, game, label, i, "" if l is None else f"{l:.3f}",
  289. f"{b:.6f}", f"{pk:.6f}"])
  290. miss = len(res) - len(lat)
  291. print(f"\n{stem} [{GAME_NAMES.get(game, game)} / {label}]: {len(lat)}/{len(res)} shots detected"
  292. + (f" ({miss} missed - quieter game/other sounds?)" if miss else ""))
  293. if len(lat):
  294. print(f" median {np.median(lat):.1f} ms | mean {lat.mean():.1f} | "
  295. f"sd {lat.std():.1f} | p10 {np.percentile(lat, 10):.1f} | "
  296. f"p90 {np.percentile(lat, 90):.1f}")
  297. print(f" saved {OUT / stem}.wav (open in Audacity to eyeball it) + .json, "
  298. f"appended to {SHOTS_CSV}")
  299.  
  300.  
  301. def reanalyze(args):
  302. meta = json.loads((OUT / f"{args.analyze}.json").read_text())
  303. with wave.open(str(OUT / f"{args.analyze}.wav")) as w:
  304. ch, sr = w.getnchannels(), w.getframerate()
  305. a = np.frombuffer(w.readframes(w.getnframes()), "<i2").reshape(-1, ch) / 32767
  306. # drop this session's old rows, then log fresh ones
  307. if SHOTS_CSV.exists():
  308. rows = list(csv.reader(open(SHOTS_CSV)))
  309. with open(SHOTS_CSV, "w", newline="") as fh:
  310. csv.writer(fh).writerows([r for r in rows if r[0] != args.analyze])
  311. analyze_and_log(args.analyze, meta.get("game", "cs2"), meta["label"], a, sr,
  312. meta["click_samples"], args)
  313.  
  314.  
  315. # ============================================================================
  316. # Report
  317. # ============================================================================
  318.  
  319. def retag(old, game, new):
  320. if not SHOTS_CSV.exists():
  321. sys.exit("No results yet.")
  322. rows = list(csv.DictReader(open(SHOTS_CSV)))
  323. n = 0
  324. for r in rows:
  325. if r["label"] == old:
  326. r["game"], r["label"] = game, new
  327. n += 1
  328. with open(SHOTS_CSV, "w", newline="") as fh:
  329. w = csv.DictWriter(fh, fieldnames=list(rows[0].keys()))
  330. w.writeheader(); w.writerows(rows)
  331. print(f"Retagged {n} shots: '{old}' -> {GAME_NAMES.get(game, game)} / '{new}'")
  332.  
  333.  
  334. def boot_ci(a, b, rng, n=4000):
  335. """95% CI of median(a) - median(b)."""
  336. d = [np.median(rng.choice(a, len(a))) - np.median(rng.choice(b, len(b)))
  337. for _ in range(n)]
  338. return np.percentile(d, [2.5, 97.5])
  339.  
  340.  
  341. def report(args):
  342. if not SHOTS_CSV.exists():
  343. sys.exit("No results yet - record some sessions first.")
  344. data = {} # (game, weapon) -> latencies
  345. for r in csv.DictReader(open(SHOTS_CSV)):
  346. if r["latency_ms"]:
  347. key = (r.get("game") or "cs2", r["label"])
  348. data.setdefault(key, []).append(float(r["latency_ms"]))
  349. data = {k: np.array(v) for k, v in data.items()}
  350. rng = np.random.default_rng(0)
  351. games = sorted({g for g, _ in data},
  352. key=lambda g: (GAME_ORDER.index(g) if g in GAME_ORDER else 99, g))
  353. gn = lambda g: GAME_NAMES.get(g, g)
  354.  
  355. # ---- per-run table ----
  356. print(f"\n{'game':<9}{'weapon':<14}{'n':>5}{'median':>9}{'mean':>8}{'sd':>7}"
  357. f"{'p10':>8}{'p90':>8}")
  358. for (g, w), v in sorted(data.items(), key=lambda kv: (games.index(kv[0][0]), np.median(kv[1]))):
  359. print(f"{gn(g):<9}{w:<14}{len(v):>5}{np.median(v):>9.1f}{v.mean():>8.1f}"
  360. f"{v.std():>7.1f}{np.percentile(v, 10):>8.1f}{np.percentile(v, 90):>8.1f}")
  361.  
  362. # ---- each game vs the baseline game (CS:GO if present), per weapon ----
  363. base = games[0]
  364. rows = {} # weapon -> {game: median}
  365. for (g, w), v in data.items():
  366. rows.setdefault(w, {})[g] = float(np.median(v))
  367. for g in games[1:]:
  368. common = sorted(w for w in rows if base in rows[w] and g in rows[w])
  369. if not common:
  370. continue
  371. print(f"\n{gn(base)} vs {gn(g)} (median, gap = {gn(g)} - {gn(base)}, 95% CI)")
  372. print(f"{'weapon':<14}{gn(base):>9}{gn(g):>9}{'gap':>8} CI")
  373. for w in common:
  374. va, vb = data[(base, w)], data[(g, w)]
  375. lo, hi = boot_ci(vb, va, rng)
  376. print(f"{w:<14}{np.median(va):>9.1f}{np.median(vb):>9.1f}"
  377. f"{np.median(vb) - np.median(va):>+8.1f} [{lo:+.1f}, {hi:+.1f}]")
  378. single = sorted(w for w in rows if len(rows[w]) == 1)
  379. if len(games) > 1 and single:
  380. print(f"(only tested in one game so far: {', '.join(single)})")
  381. pairs = [w for w in rows if base in rows[w] and len(rows[w]) > 1]
  382.  
  383. # ---- within each game, vs a reference weapon ----
  384. for g in games:
  385. ws = {w: v for (gg, w), v in data.items() if gg == g}
  386. if len(ws) < 2:
  387. continue
  388. ref = args.ref if args.ref in ws else min(ws, key=lambda k: np.median(ws[k]))
  389. print(f"\n{gn(g)}: each weapon vs {ref} (median gap, 95% CI)")
  390. for w, v in sorted(ws.items(), key=lambda kv: np.median(kv[1])):
  391. if w == ref:
  392. continue
  393. lo, hi = boot_ci(v, ws[ref], rng)
  394. print(f" {w:<14}{np.median(v) - np.median(ws[ref]):+6.1f} ms [{lo:+.1f}, {hi:+.1f}]")
  395. print("\nAll values in ms. If a CI doesn't include 0, the gap is real, not noise.")
  396.  
  397. if args.plot:
  398. import matplotlib
  399. matplotlib.use("Agg")
  400. import matplotlib.pyplot as plt
  401. colors = {"csgo": "#5B9BD5", "cs2": "#ED7D31", "cs2fix": "#4CAF50"}
  402. if pairs: # dumbbell chart, like the Reddit one
  403. main = "cs2" if "cs2" in games else games[1]
  404. pairs.sort(key=lambda w: rows[w].get(main, 0) - rows[w][base])
  405. gs = [g for g in games if any(g in rows[w] for w in pairs)]
  406. fig, ax = plt.subplots(figsize=(8, 0.45 * len(pairs) + 1.8))
  407. for i, w in enumerate(pairs):
  408. vals = [rows[w][g] for g in gs if g in rows[w]]
  409. ax.plot([min(vals), max(vals)], [i, i], color="#bbbbbb", lw=1.5, zorder=1)
  410. for g in gs:
  411. if g in rows[w]:
  412. ax.scatter(rows[w][g], i, color=colors.get(g, "C3"), zorder=2, s=40)
  413. if main in rows[w]:
  414. gap = rows[w][main] - rows[w][base]
  415. ax.annotate(f"{gap:+.0f}", (max(vals), i), xytext=(10, 0),
  416. textcoords="offset points", va="center", fontsize=9)
  417. ax.set_yticks(range(len(pairs)), pairs)
  418. for g in gs:
  419. ax.scatter([], [], color=colors.get(g, "C3"), label=gn(g))
  420. ax.legend(loc="lower center", bbox_to_anchor=(0.5, 1.0), ncol=len(gs), frameon=False)
  421. ax.set_title("Sound delay by weapon", pad=28, fontweight="bold")
  422. lo_x, hi_x = ax.get_xlim(); ax.set_xlim(lo_x, hi_x + 0.06 * (hi_x - lo_x))
  423. else: # single game: box + dots
  424. keys = sorted(data, key=lambda k: np.median(data[k]))
  425. fig, ax = plt.subplots(figsize=(8, 0.5 * len(keys) + 1.5))
  426. ax.boxplot([data[k] for k in keys], orientation="horizontal",
  427. tick_labels=[f"{gn(g)} {w}" for g, w in keys], showfliers=False)
  428. for i, k in enumerate(keys, 1):
  429. ax.scatter(data[k], i + rng.uniform(-0.15, 0.15, len(data[k])), s=8,
  430. alpha=0.5, color=colors.get(k[0], "C0"))
  431. ax.set_title("Shot sound latency by weapon")
  432. ax.set_xlabel("click -> gunshot sound onset (ms)")
  433. ax.grid(axis="x", alpha=0.3)
  434. fig.tight_layout()
  435. fig.savefig(OUT / "latency_report.png", dpi=150)
  436. print(f"Plot saved to {OUT / 'latency_report.png'}")
  437.  
  438.  
  439. # ============================================================================
  440. # Self-test: synthetic audio with known delays + jittery, drifting callbacks
  441. # ============================================================================
  442.  
  443. def selftest(args):
  444. rng = np.random.default_rng(1)
  445. sr, dur = 48000, 60.0
  446. n = int(sr * dur)
  447. x = rng.normal(0, 0.002, n) # noise floor
  448. x += 0.01 * np.sin(2 * np.pi * 90 * np.arange(n) / sr) # low ambience hum
  449. true_lat, clicks_ns = [], []
  450. t0_ns, drift = 5_000_000_000, 40e-6 # audio clock 40 ppm fast
  451. t = 1.0
  452. i = 0
  453. while t < dur - 1:
  454. lat = (0.040 if i % 2 == 0 else 0.065) + rng.normal(0, 0.002) # 40 vs 65 ms
  455. s = int((t + lat) * sr)
  456. L = int(0.25 * sr)
  457. tt = np.arange(L) / sr
  458. burst = rng.normal(0, 1, L) * (1 - np.exp(-tt / 0.0008)) * np.exp(-tt / 0.06) * 0.4
  459. x[s:s + L] += burst[:max(0, min(L, n - s))]
  460. true_lat.append(lat * 1000)
  461. clicks_ns.append(t0_ns + t * 1e9 / (1 + drift)) # CPU clock vs audio clock
  462. t += rng.uniform(0.6, 1.0)
  463. i += 1
  464. # simulate callbacks every 128 frames, arriving 0-6 ms late
  465. cb, f = [], 0
  466. while f < n:
  467. f += 128
  468. cb.append((t0_ns + f * 1e9 / sr / (1 + drift) + rng.exponential(1.5e6), f))
  469. to_sample = fit_clock(cb, sr)
  470. idx = [to_sample(c) for c in clicks_ns]
  471. res = detect_onsets(x, sr, idx)
  472. got = np.array([r[0] if r[0] is not None else np.nan for r in res])
  473. err = got - np.array(true_lat)
  474. print(f"detected {np.sum(~np.isnan(got))}/{len(got)} shots")
  475. print(f"error vs truth: median {np.nanmedian(err):+.2f} ms, "
  476. f"max |err| {np.nanmax(np.abs(err)):.2f} ms")
  477. gap = np.nanmedian(got[1::2]) - np.nanmedian(got[0::2])
  478. print(f"recovered A/B gap: {gap:.2f} ms (truth 25.00 ms)")
  479. ok = np.nanmax(np.abs(err - np.nanmedian(err))) < 1.5 and abs(gap - 25) < 1.0
  480. print("SELFTEST", "PASSED" if ok else "FAILED")
  481. return 0 if ok else 1
  482.  
  483.  
  484. # ============================================================================
  485.  
  486. def main():
  487. ap = argparse.ArgumentParser(description=__doc__,
  488. formatter_class=argparse.RawDescriptionHelpFormatter)
  489. ap.add_argument("--label", help="weapon name for this run, e.g. ak47, deagle")
  490. ap.add_argument("--game", choices=["cs2", "csgo", "cs2fix"], default="cs2",
  491. help="which game this run is in: cs2 (default), csgo, or cs2fix "
  492. "(CS2 with a test setting, e.g. snd_delay_sound_ms_shift 0)")
  493. ap.add_argument("--retag", nargs=3, metavar=("OLD_LABEL", "GAME", "NEW_LABEL"),
  494. help="move saved results, e.g. --retag ak47_shift0 cs2fix ak47")
  495. ap.add_argument("--shots", type=int, default=40)
  496. ap.add_argument("--min-gap", type=int, default=700, help="ms between auto shots (min)")
  497. ap.add_argument("--max-gap", type=int, default=1100, help="ms between auto shots (max)")
  498. ap.add_argument("--hold", type=int, default=25, help="ms the button is held per shot")
  499. ap.add_argument("--manual", action="store_true")
  500. ap.add_argument("--device", type=int, help="capture device index (see --list-devices)")
  501. ap.add_argument("--list-devices", action="store_true")
  502. ap.add_argument("--k", type=float, default=6.0, help="onset threshold x baseline")
  503. ap.add_argument("--peak-frac", type=float, default=0.05,
  504. help="onset threshold as fraction of the shot's peak")
  505. ap.add_argument("--hp", type=float, default=200.0, help="high-pass cutoff Hz")
  506. ap.add_argument("--report", action="store_true")
  507. ap.add_argument("--ref", help="reference weapon for the report")
  508. ap.add_argument("--plot", action="store_true")
  509. ap.add_argument("--analyze", metavar="STEM")
  510. ap.add_argument("--selftest", action="store_true")
  511. a = ap.parse_args()
  512.  
  513. if a.selftest:
  514. sys.exit(selftest(a))
  515. if a.list_devices:
  516. return list_devices()
  517. if a.retag:
  518. return retag(*a.retag)
  519. if a.report:
  520. return report(a)
  521. if a.analyze:
  522. return reanalyze(a)
  523. if not a.label:
  524. ap.error("--label is required for a recording run")
  525. if sys.platform != "win32":
  526. sys.exit("Recording needs Windows (WASAPI + SendInput). --selftest/--report work anywhere.")
  527. record_session(a)
  528.  
  529.  
  530. if __name__ == "__main__":
  531. main()
  532.  
Advertisement
Add Comment
Please, Sign In to add comment