Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- #!/usr/bin/env python3
- """
- cs2_shot_latency.py - measure click -> gunshot-sound latency in CS2 / CS:GO, per weapon.
- How it works
- * Records what your PC is playing (WASAPI loopback) or a microphone.
- * Fires the weapon itself with SendInput at random intervals (or records your
- own clicks in --manual mode), timestamping each click with the
- high-resolution performance counter.
- * Maps every audio sample onto the same clock (drift-corrected), finds the
- onset of each gunshot, and logs click->sound latency to results/shots.csv.
- * --report compares weapons (median, spread, bootstrap 95% CI of the gap).
- Absolute numbers include a constant Windows-audio pipeline offset; the
- DIFFERENCE between weapons is the interesting part (e.g. AK-47 vs Deagle).
- Usage (Windows, game in an offline practice match):
- python cs2_shot_latency.py --label deagle --shots 40 # press F8 in game to start
- python cs2_shot_latency.py --label ak47 --shots 40
- python cs2_shot_latency.py --report --plot
- CS:GO vs CS2 (use the same weapon labels in both games):
- python cs2_shot_latency.py --game csgo --label ak47 --shots 40
- python cs2_shot_latency.py --game cs2 --label ak47 --shots 40
- python cs2_shot_latency.py --report --plot # per-weapon gap + dumbbell chart
- Testing a fix (e.g. snd_delay_sound_ms_shift 0 under -dev):
- python cs2_shot_latency.py --game cs2fix --label ak47 --shots 40
- Other:
- --manual record your own clicks instead of auto-firing (F8 start/stop)
- --list-devices show audio devices; --device N pick one (e.g. a mic)
- --analyze STEM re-run detection on a saved session with new settings
- --selftest verify the detector on synthetic audio (works on any OS)
- F9 aborts a run.
- """
- import argparse
- import csv
- import json
- import random
- import sys
- import threading
- import time
- import wave
- from pathlib import Path
- import numpy as np
- OUT = Path("results")
- SHOTS_CSV = OUT / "shots.csv"
- GAME_NAMES = {"csgo": "CS:GO", "cs2": "CS2", "cs2fix": "CS2 fix"}
- GAME_ORDER = ["csgo", "cs2", "cs2fix"]
- # ============================================================================
- # Analysis (pure numpy/scipy - runs on any OS)
- # ============================================================================
- def highpass(x, sr, fc=200.0):
- """Remove rumble/ambience below fc so the gunshot transient stands out."""
- try:
- from scipy.signal import butter, sosfilt
- sos = butter(4, fc, btype="highpass", fs=sr, output="sos")
- return sosfilt(sos, x)
- except ImportError:
- return np.concatenate([[0.0], np.diff(x)]) # crude pre-emphasis fallback
- def envelope(x, sr, win_ms=0.5):
- w = max(1, int(sr * win_ms / 1000))
- return np.sqrt(np.convolve(x * x, np.ones(w) / w, mode="same"))
- def detect_onsets(mono, sr, click_idx, pre_ms=80, search_ms=300, k=6.0,
- peak_frac=0.05, hp=200.0):
- """For each click sample index, return (latency_ms | None, baseline, peak).
- Threshold = max(k * 99th-percentile of the pre-click envelope,
- peak_frac * loudest envelope value in the search window).
- The relative-to-peak part makes loud and quiet guns comparable.
- """
- env = envelope(highpass(mono.astype(np.float64), sr, hp), sr)
- pre, srch, guard = (int(sr * v / 1000) for v in (pre_ms, search_ms, 2))
- out = []
- for c in click_idx:
- c = int(round(c))
- if c - pre < 0 or c + srch > len(env):
- out.append((None, 0.0, 0.0))
- continue
- base = float(np.percentile(env[c - pre:c - guard], 99))
- seg = env[c:c + srch]
- peak = float(seg.max())
- thr = max(base * k, peak * peak_frac)
- hits = np.nonzero(seg > thr)[0]
- if peak < base * k or len(hits) == 0:
- out.append((None, base, peak)) # no clear shot found
- continue
- out.append((hits[0] * 1000.0 / sr, base, peak))
- return out
- def fit_clock(cb_log, sr):
- """cb_log: [(perf_ns_at_callback, total_frames_after_block), ...].
- Callbacks only ever arrive late, so the lower envelope of
- (callback_time - frames/sr) is the true start-of-stream time. A line fit
- through per-second minima also corrects audio-clock vs CPU-clock drift.
- Returns f(perf_ns) -> fractional sample index.
- """
- a = np.asarray(cb_log, dtype=np.float64)
- t, f = a[:, 0], a[:, 1]
- off = t - f * 1e9 / sr
- bins = ((t - t[0]) // 1e9).astype(int)
- fx, fy = [], []
- for b in np.unique(bins):
- m = bins == b
- j = np.argmin(off[m])
- fx.append(f[m][j]); fy.append(off[m][j])
- if len(fx) >= 3:
- slope, icpt = np.polyfit(fx, fy, 1)
- else:
- slope, icpt = 0.0, float(off.min())
- return lambda t_ns: (t_ns - icpt) / (slope + 1e9 / sr)
- # ============================================================================
- # Windows capture + input
- # ============================================================================
- def win_setup():
- import ctypes
- ctypes.windll.winmm.timeBeginPeriod(1) # 1 ms sleep resolution
- k32 = ctypes.windll.kernel32
- k32.SetPriorityClass(k32.GetCurrentProcess(), 0x80) # HIGH_PRIORITY_CLASS
- def make_clicker():
- import ctypes
- from ctypes import wintypes
- class MOUSEINPUT(ctypes.Structure):
- _fields_ = [("dx", wintypes.LONG), ("dy", wintypes.LONG),
- ("mouseData", wintypes.DWORD), ("dwFlags", wintypes.DWORD),
- ("time", wintypes.DWORD), ("dwExtraInfo", ctypes.c_size_t)]
- class _U(ctypes.Union):
- _fields_ = [("mi", MOUSEINPUT), ("_pad", ctypes.c_byte * 32)]
- class INPUT(ctypes.Structure):
- _anonymous_ = ("u",)
- _fields_ = [("type", wintypes.DWORD), ("u", _U)]
- send = ctypes.windll.user32.SendInput
- def press(flag):
- inp = INPUT(type=0)
- inp.mi.dwFlags = flag
- send(1, ctypes.byref(inp), ctypes.sizeof(INPUT))
- return lambda: press(0x0002), lambda: press(0x0004) # LEFTDOWN, LEFTUP
- def open_audio(device_index=None):
- import pyaudiowpatch as pyaudio
- p = pyaudio.PyAudio()
- wasapi = p.get_host_api_info_by_type(pyaudio.paWASAPI)
- if device_index is None:
- dev = p.get_device_info_by_index(wasapi["defaultOutputDevice"])
- if not dev.get("isLoopbackDevice"):
- for lb in p.get_loopback_device_info_generator():
- if dev["name"] in lb["name"]:
- dev = lb
- break
- else:
- sys.exit("Couldn't find a loopback device for your default output.")
- else:
- dev = p.get_device_info_by_index(device_index)
- return p, pyaudio, dev, wasapi
- def list_devices():
- import pyaudiowpatch as pyaudio
- p = pyaudio.PyAudio()
- for i in range(p.get_device_count()):
- d = p.get_device_info_by_index(i)
- if d["maxInputChannels"] > 0:
- api = p.get_host_api_info_by_index(d["hostApi"])["name"]
- tag = " [loopback]" if d.get("isLoopbackDevice") else ""
- print(f"{i:3d} {d['name']}{tag} ({api}, {int(d['defaultSampleRate'])} Hz)")
- p.terminate()
- def record_session(args):
- from pynput import keyboard, mouse
- win_setup()
- p, pyaudio, dev, wasapi = open_audio(args.device)
- sr, ch = int(dev["defaultSampleRate"]), max(1, dev["maxInputChannels"])
- print(f"Audio: {dev['name']} {sr} Hz, {ch} ch")
- blocks, cb_log, frames = [], [], [0]
- def cb(data, n, _ti, _st):
- t = time.perf_counter_ns()
- blocks.append(np.frombuffer(data, dtype=np.float32).copy())
- frames[0] += n
- cb_log.append((t, frames[0]))
- return None, pyaudio.paContinue
- # A silent output stream keeps WASAPI loopback delivering data in quiet moments.
- keep = p.open(format=pyaudio.paFloat32, channels=2, rate=sr, output=True,
- output_device_index=wasapi["defaultOutputDevice"],
- stream_callback=lambda *_: (bytes(8 * 256), pyaudio.paContinue),
- frames_per_buffer=256)
- stream = p.open(format=pyaudio.paFloat32, channels=ch, rate=sr, input=True,
- input_device_index=dev["index"], frames_per_buffer=128,
- stream_callback=cb)
- start_ev, stop_ev, abort_ev = threading.Event(), threading.Event(), threading.Event()
- def on_key(key):
- if key == keyboard.Key.f8:
- (stop_ev if start_ev.is_set() else start_ev).set()
- elif key == keyboard.Key.f9:
- abort_ev.set(); stop_ev.set(); start_ev.set()
- klis = keyboard.Listener(on_press=on_key); klis.start()
- clicks = []
- mode = "MANUAL: fire as you like, F8 again to stop" if args.manual else \
- f"AUTO: {args.shots} shots, {args.min_gap}-{args.max_gap} ms apart"
- print(f"[{args.label}] {mode}.\nSwitch to CS2 and press F8 to start (F9 aborts).")
- start_ev.wait()
- if abort_ev.is_set():
- sys.exit("Aborted.")
- if args.manual:
- def on_click(_x, _y, button, pressed):
- if pressed and button == mouse.Button.left and start_ev.is_set():
- clicks.append(time.perf_counter_ns())
- mlis = mouse.Listener(on_click=on_click); mlis.start()
- print("Recording... F8 to stop.")
- stop_ev.wait()
- mlis.stop()
- else:
- down, up = make_clicker()
- time.sleep(1.5)
- for i in range(args.shots):
- if abort_ev.wait(random.uniform(args.min_gap, args.max_gap) / 1000):
- break
- t = time.perf_counter_ns()
- down()
- time.sleep(args.hold / 1000)
- up()
- clicks.append(t)
- print(f"\r shot {i + 1}/{args.shots}", end="", flush=True)
- print()
- time.sleep(0.6) # catch the last shot's sound
- stream.stop_stream(); stream.close(); keep.stop_stream(); keep.close()
- p.terminate(); klis.stop()
- if abort_ev.is_set() and not clicks:
- sys.exit("Aborted.")
- audio = np.concatenate(blocks).reshape(-1, ch)
- to_sample = fit_clock(cb_log, sr)
- click_idx = [float(to_sample(t)) for t in clicks]
- OUT.mkdir(exist_ok=True)
- stem = f"{args.game}_{args.label}_{time.strftime('%Y%m%d_%H%M%S')}"
- with wave.open(str(OUT / f"{stem}.wav"), "wb") as w:
- w.setnchannels(ch); w.setsampwidth(2); w.setframerate(sr)
- w.writeframes((np.clip(audio, -1, 1) * 32767).astype("<i2").tobytes())
- (OUT / f"{stem}.json").write_text(json.dumps(
- {"label": args.label, "game": args.game, "sr": sr, "click_samples": click_idx,
- "mode": "manual" if args.manual else "auto"}, indent=1))
- analyze_and_log(stem, args.game, args.label, audio, sr, click_idx, args)
- def analyze_and_log(stem, game, label, audio, sr, click_idx, args):
- mono = audio[:, :2].mean(axis=1) # front L/R
- res = detect_onsets(mono, sr, click_idx, k=args.k, peak_frac=args.peak_frac,
- hp=args.hp)
- lat = np.array([r[0] for r in res if r[0] is not None])
- new = not SHOTS_CSV.exists()
- with open(SHOTS_CSV, "a", newline="") as fh:
- w = csv.writer(fh)
- if new:
- w.writerow(["session", "game", "label", "shot", "latency_ms", "baseline", "peak"])
- for i, (l, b, pk) in enumerate(res):
- w.writerow([stem, game, label, i, "" if l is None else f"{l:.3f}",
- f"{b:.6f}", f"{pk:.6f}"])
- miss = len(res) - len(lat)
- print(f"\n{stem} [{GAME_NAMES.get(game, game)} / {label}]: {len(lat)}/{len(res)} shots detected"
- + (f" ({miss} missed - quieter game/other sounds?)" if miss else ""))
- if len(lat):
- print(f" median {np.median(lat):.1f} ms | mean {lat.mean():.1f} | "
- f"sd {lat.std():.1f} | p10 {np.percentile(lat, 10):.1f} | "
- f"p90 {np.percentile(lat, 90):.1f}")
- print(f" saved {OUT / stem}.wav (open in Audacity to eyeball it) + .json, "
- f"appended to {SHOTS_CSV}")
- def reanalyze(args):
- meta = json.loads((OUT / f"{args.analyze}.json").read_text())
- with wave.open(str(OUT / f"{args.analyze}.wav")) as w:
- ch, sr = w.getnchannels(), w.getframerate()
- a = np.frombuffer(w.readframes(w.getnframes()), "<i2").reshape(-1, ch) / 32767
- # drop this session's old rows, then log fresh ones
- if SHOTS_CSV.exists():
- rows = list(csv.reader(open(SHOTS_CSV)))
- with open(SHOTS_CSV, "w", newline="") as fh:
- csv.writer(fh).writerows([r for r in rows if r[0] != args.analyze])
- analyze_and_log(args.analyze, meta.get("game", "cs2"), meta["label"], a, sr,
- meta["click_samples"], args)
- # ============================================================================
- # Report
- # ============================================================================
- def retag(old, game, new):
- if not SHOTS_CSV.exists():
- sys.exit("No results yet.")
- rows = list(csv.DictReader(open(SHOTS_CSV)))
- n = 0
- for r in rows:
- if r["label"] == old:
- r["game"], r["label"] = game, new
- n += 1
- with open(SHOTS_CSV, "w", newline="") as fh:
- w = csv.DictWriter(fh, fieldnames=list(rows[0].keys()))
- w.writeheader(); w.writerows(rows)
- print(f"Retagged {n} shots: '{old}' -> {GAME_NAMES.get(game, game)} / '{new}'")
- def boot_ci(a, b, rng, n=4000):
- """95% CI of median(a) - median(b)."""
- d = [np.median(rng.choice(a, len(a))) - np.median(rng.choice(b, len(b)))
- for _ in range(n)]
- return np.percentile(d, [2.5, 97.5])
- def report(args):
- if not SHOTS_CSV.exists():
- sys.exit("No results yet - record some sessions first.")
- data = {} # (game, weapon) -> latencies
- for r in csv.DictReader(open(SHOTS_CSV)):
- if r["latency_ms"]:
- key = (r.get("game") or "cs2", r["label"])
- data.setdefault(key, []).append(float(r["latency_ms"]))
- data = {k: np.array(v) for k, v in data.items()}
- rng = np.random.default_rng(0)
- games = sorted({g for g, _ in data},
- key=lambda g: (GAME_ORDER.index(g) if g in GAME_ORDER else 99, g))
- gn = lambda g: GAME_NAMES.get(g, g)
- # ---- per-run table ----
- print(f"\n{'game':<9}{'weapon':<14}{'n':>5}{'median':>9}{'mean':>8}{'sd':>7}"
- f"{'p10':>8}{'p90':>8}")
- for (g, w), v in sorted(data.items(), key=lambda kv: (games.index(kv[0][0]), np.median(kv[1]))):
- print(f"{gn(g):<9}{w:<14}{len(v):>5}{np.median(v):>9.1f}{v.mean():>8.1f}"
- f"{v.std():>7.1f}{np.percentile(v, 10):>8.1f}{np.percentile(v, 90):>8.1f}")
- # ---- each game vs the baseline game (CS:GO if present), per weapon ----
- base = games[0]
- rows = {} # weapon -> {game: median}
- for (g, w), v in data.items():
- rows.setdefault(w, {})[g] = float(np.median(v))
- for g in games[1:]:
- common = sorted(w for w in rows if base in rows[w] and g in rows[w])
- if not common:
- continue
- print(f"\n{gn(base)} vs {gn(g)} (median, gap = {gn(g)} - {gn(base)}, 95% CI)")
- print(f"{'weapon':<14}{gn(base):>9}{gn(g):>9}{'gap':>8} CI")
- for w in common:
- va, vb = data[(base, w)], data[(g, w)]
- lo, hi = boot_ci(vb, va, rng)
- print(f"{w:<14}{np.median(va):>9.1f}{np.median(vb):>9.1f}"
- f"{np.median(vb) - np.median(va):>+8.1f} [{lo:+.1f}, {hi:+.1f}]")
- single = sorted(w for w in rows if len(rows[w]) == 1)
- if len(games) > 1 and single:
- print(f"(only tested in one game so far: {', '.join(single)})")
- pairs = [w for w in rows if base in rows[w] and len(rows[w]) > 1]
- # ---- within each game, vs a reference weapon ----
- for g in games:
- ws = {w: v for (gg, w), v in data.items() if gg == g}
- if len(ws) < 2:
- continue
- ref = args.ref if args.ref in ws else min(ws, key=lambda k: np.median(ws[k]))
- print(f"\n{gn(g)}: each weapon vs {ref} (median gap, 95% CI)")
- for w, v in sorted(ws.items(), key=lambda kv: np.median(kv[1])):
- if w == ref:
- continue
- lo, hi = boot_ci(v, ws[ref], rng)
- print(f" {w:<14}{np.median(v) - np.median(ws[ref]):+6.1f} ms [{lo:+.1f}, {hi:+.1f}]")
- print("\nAll values in ms. If a CI doesn't include 0, the gap is real, not noise.")
- if args.plot:
- import matplotlib
- matplotlib.use("Agg")
- import matplotlib.pyplot as plt
- colors = {"csgo": "#5B9BD5", "cs2": "#ED7D31", "cs2fix": "#4CAF50"}
- if pairs: # dumbbell chart, like the Reddit one
- main = "cs2" if "cs2" in games else games[1]
- pairs.sort(key=lambda w: rows[w].get(main, 0) - rows[w][base])
- gs = [g for g in games if any(g in rows[w] for w in pairs)]
- fig, ax = plt.subplots(figsize=(8, 0.45 * len(pairs) + 1.8))
- for i, w in enumerate(pairs):
- vals = [rows[w][g] for g in gs if g in rows[w]]
- ax.plot([min(vals), max(vals)], [i, i], color="#bbbbbb", lw=1.5, zorder=1)
- for g in gs:
- if g in rows[w]:
- ax.scatter(rows[w][g], i, color=colors.get(g, "C3"), zorder=2, s=40)
- if main in rows[w]:
- gap = rows[w][main] - rows[w][base]
- ax.annotate(f"{gap:+.0f}", (max(vals), i), xytext=(10, 0),
- textcoords="offset points", va="center", fontsize=9)
- ax.set_yticks(range(len(pairs)), pairs)
- for g in gs:
- ax.scatter([], [], color=colors.get(g, "C3"), label=gn(g))
- ax.legend(loc="lower center", bbox_to_anchor=(0.5, 1.0), ncol=len(gs), frameon=False)
- ax.set_title("Sound delay by weapon", pad=28, fontweight="bold")
- lo_x, hi_x = ax.get_xlim(); ax.set_xlim(lo_x, hi_x + 0.06 * (hi_x - lo_x))
- else: # single game: box + dots
- keys = sorted(data, key=lambda k: np.median(data[k]))
- fig, ax = plt.subplots(figsize=(8, 0.5 * len(keys) + 1.5))
- ax.boxplot([data[k] for k in keys], orientation="horizontal",
- tick_labels=[f"{gn(g)} {w}" for g, w in keys], showfliers=False)
- for i, k in enumerate(keys, 1):
- ax.scatter(data[k], i + rng.uniform(-0.15, 0.15, len(data[k])), s=8,
- alpha=0.5, color=colors.get(k[0], "C0"))
- ax.set_title("Shot sound latency by weapon")
- ax.set_xlabel("click -> gunshot sound onset (ms)")
- ax.grid(axis="x", alpha=0.3)
- fig.tight_layout()
- fig.savefig(OUT / "latency_report.png", dpi=150)
- print(f"Plot saved to {OUT / 'latency_report.png'}")
- # ============================================================================
- # Self-test: synthetic audio with known delays + jittery, drifting callbacks
- # ============================================================================
- def selftest(args):
- rng = np.random.default_rng(1)
- sr, dur = 48000, 60.0
- n = int(sr * dur)
- x = rng.normal(0, 0.002, n) # noise floor
- x += 0.01 * np.sin(2 * np.pi * 90 * np.arange(n) / sr) # low ambience hum
- true_lat, clicks_ns = [], []
- t0_ns, drift = 5_000_000_000, 40e-6 # audio clock 40 ppm fast
- t = 1.0
- i = 0
- while t < dur - 1:
- lat = (0.040 if i % 2 == 0 else 0.065) + rng.normal(0, 0.002) # 40 vs 65 ms
- s = int((t + lat) * sr)
- L = int(0.25 * sr)
- tt = np.arange(L) / sr
- burst = rng.normal(0, 1, L) * (1 - np.exp(-tt / 0.0008)) * np.exp(-tt / 0.06) * 0.4
- x[s:s + L] += burst[:max(0, min(L, n - s))]
- true_lat.append(lat * 1000)
- clicks_ns.append(t0_ns + t * 1e9 / (1 + drift)) # CPU clock vs audio clock
- t += rng.uniform(0.6, 1.0)
- i += 1
- # simulate callbacks every 128 frames, arriving 0-6 ms late
- cb, f = [], 0
- while f < n:
- f += 128
- cb.append((t0_ns + f * 1e9 / sr / (1 + drift) + rng.exponential(1.5e6), f))
- to_sample = fit_clock(cb, sr)
- idx = [to_sample(c) for c in clicks_ns]
- res = detect_onsets(x, sr, idx)
- got = np.array([r[0] if r[0] is not None else np.nan for r in res])
- err = got - np.array(true_lat)
- print(f"detected {np.sum(~np.isnan(got))}/{len(got)} shots")
- print(f"error vs truth: median {np.nanmedian(err):+.2f} ms, "
- f"max |err| {np.nanmax(np.abs(err)):.2f} ms")
- gap = np.nanmedian(got[1::2]) - np.nanmedian(got[0::2])
- print(f"recovered A/B gap: {gap:.2f} ms (truth 25.00 ms)")
- ok = np.nanmax(np.abs(err - np.nanmedian(err))) < 1.5 and abs(gap - 25) < 1.0
- print("SELFTEST", "PASSED" if ok else "FAILED")
- return 0 if ok else 1
- # ============================================================================
- def main():
- ap = argparse.ArgumentParser(description=__doc__,
- formatter_class=argparse.RawDescriptionHelpFormatter)
- ap.add_argument("--label", help="weapon name for this run, e.g. ak47, deagle")
- ap.add_argument("--game", choices=["cs2", "csgo", "cs2fix"], default="cs2",
- help="which game this run is in: cs2 (default), csgo, or cs2fix "
- "(CS2 with a test setting, e.g. snd_delay_sound_ms_shift 0)")
- ap.add_argument("--retag", nargs=3, metavar=("OLD_LABEL", "GAME", "NEW_LABEL"),
- help="move saved results, e.g. --retag ak47_shift0 cs2fix ak47")
- ap.add_argument("--shots", type=int, default=40)
- ap.add_argument("--min-gap", type=int, default=700, help="ms between auto shots (min)")
- ap.add_argument("--max-gap", type=int, default=1100, help="ms between auto shots (max)")
- ap.add_argument("--hold", type=int, default=25, help="ms the button is held per shot")
- ap.add_argument("--manual", action="store_true")
- ap.add_argument("--device", type=int, help="capture device index (see --list-devices)")
- ap.add_argument("--list-devices", action="store_true")
- ap.add_argument("--k", type=float, default=6.0, help="onset threshold x baseline")
- ap.add_argument("--peak-frac", type=float, default=0.05,
- help="onset threshold as fraction of the shot's peak")
- ap.add_argument("--hp", type=float, default=200.0, help="high-pass cutoff Hz")
- ap.add_argument("--report", action="store_true")
- ap.add_argument("--ref", help="reference weapon for the report")
- ap.add_argument("--plot", action="store_true")
- ap.add_argument("--analyze", metavar="STEM")
- ap.add_argument("--selftest", action="store_true")
- a = ap.parse_args()
- if a.selftest:
- sys.exit(selftest(a))
- if a.list_devices:
- return list_devices()
- if a.retag:
- return retag(*a.retag)
- if a.report:
- return report(a)
- if a.analyze:
- return reanalyze(a)
- if not a.label:
- ap.error("--label is required for a recording run")
- if sys.platform != "win32":
- sys.exit("Recording needs Windows (WASAPI + SendInput). --selftest/--report work anywhere.")
- record_session(a)
- if __name__ == "__main__":
- main()
Advertisement
Add Comment
Please, Sign In to add comment