#!/usr/bin/env python3 """Live scene detection latency benchmark (M4). Measures time from a triggered visual change on the sender to a new JPEG appearing in the receiver's frames/ directory. Usage (run on receiver, sender accessible via SSH): python ctrl/bench_delay.py --session-dir data/sessions/CURRENT --sender mariano@sender python ctrl/bench_delay.py --frames-dir data/sessions/CURRENT/frames --sender mariano@sender How it works: 1. Records the current frame count in frames/index.json 2. SSH to sender, triggers a visual change (xdotool workspace switch) 3. Polls frames/index.json for a new entry (or watches via mtime) 4. Measures wall-clock difference = scene detection latency For repeated measurements, use --repeat N with --interval S between triggers. """ import argparse import json import logging import os import subprocess import sys import time from pathlib import Path log = logging.getLogger("bench_delay") def get_frame_count(frames_dir: Path) -> int: index = frames_dir / "index.json" if not index.exists(): return 0 try: return len(json.loads(index.read_text())) except (json.JSONDecodeError, ValueError): return 0 def get_latest_frame_mtime(frames_dir: Path) -> float: index = frames_dir / "index.json" if not index.exists(): return 0.0 return index.stat().st_mtime def trigger_scene_change(sender: str, method: str = "workspace") -> float: """Trigger a visual change on the sender. Returns wall-clock time of trigger.""" if method == "workspace": # xdotool switch workspace — causes a full-screen visual change cmd = ["ssh", sender, "DISPLAY=:0 xdotool key super+Right"] elif method == "color": # Flash a fullscreen color using xterm (more dramatic change) cmd = ["ssh", sender, "DISPLAY=:0 bash -c 'xterm -fullscreen -bg red -e sleep 0.5 &'"] else: log.error("Unknown trigger method: %s", method) sys.exit(1) t = time.monotonic() wall = time.time() try: subprocess.run(cmd, timeout=5, capture_output=True) except subprocess.TimeoutExpired: log.warning("SSH trigger timed out") return wall def wait_for_new_frame(frames_dir: Path, initial_count: int, timeout: float = 15.0, poll_interval: float = 0.1) -> float | None: """Wait for a new frame to appear. Returns wall-clock time when detected, or None.""" deadline = time.monotonic() + timeout while time.monotonic() < deadline: count = get_frame_count(frames_dir) if count > initial_count: return time.time() time.sleep(poll_interval) return None def run_measurement(frames_dir: Path, sender: str, method: str) -> dict: initial_count = get_frame_count(frames_dir) trigger_wall = trigger_scene_change(sender, method) detected_wall = wait_for_new_frame(frames_dir, initial_count) if detected_wall is None: return {"trigger_wall": trigger_wall, "latency_s": None, "timed_out": True} latency = detected_wall - trigger_wall return { "trigger_wall": trigger_wall, "detected_wall": detected_wall, "latency_s": round(latency, 3), "timed_out": False, } def main(): parser = argparse.ArgumentParser(description="Scene detection latency benchmark") parser.add_argument("--frames-dir", type=Path, help="Path to frames/ directory") parser.add_argument("--session-dir", type=Path, help="Path to session directory") parser.add_argument("--sender", required=True, help="SSH target for sender (user@host)") parser.add_argument("--method", default="workspace", choices=["workspace", "color"], help="How to trigger visual change") parser.add_argument("--repeat", type=int, default=3, help="Number of measurements") parser.add_argument("--interval", type=float, default=5.0, help="Seconds between triggers") parser.add_argument("--json", action="store_true", help="Output JSON") args = parser.parse_args() logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)-7s %(name)s: %(message)s", datefmt="%H:%M:%S", ) if args.session_dir: frames_dir = args.session_dir / "frames" elif args.frames_dir: frames_dir = args.frames_dir else: parser.error("Provide --frames-dir or --session-dir") return if not frames_dir.exists(): log.error("Frames dir not found: %s", frames_dir) sys.exit(1) results = [] for i in range(args.repeat): if i > 0: time.sleep(args.interval) log.info("Trigger %d/%d...", i + 1, args.repeat) r = run_measurement(frames_dir, args.sender, args.method) if r["timed_out"]: log.warning("TIMEOUT (no frame in 15s)") else: log.info(" latency: %ss", r["latency_s"]) results.append(r) latencies = [r["latency_s"] for r in results if r["latency_s"] is not None] if args.json: print(json.dumps({"measurements": results, "summary": { "count": len(latencies), "avg_s": round(sum(latencies) / len(latencies), 3) if latencies else None, "min_s": round(min(latencies), 3) if latencies else None, "max_s": round(max(latencies), 3) if latencies else None, "timeouts": sum(1 for r in results if r["timed_out"]), }}, indent=2)) else: log.info("M4 Scene detection latency:") if latencies: log.info(" avg: %.1fs", sum(latencies) / len(latencies)) log.info(" min: %.1fs", min(latencies)) log.info(" max: %.1fs", max(latencies)) timeouts = sum(1 for r in results if r["timed_out"]) if timeouts: log.warning(" timeouts: %d/%d", timeouts, len(results)) if __name__ == "__main__": main()