
Building a High-Precision Mouse Macro Recorder in Python (GUI + Hotkeys + Repeat + Speed)
This post documents a practical “desktop macro” tool in Python that can:
- Record global mouse movement + clicks
- Replay the same macro with:
- Repeat N times
- Speed multiplier (faster/slower playback)
- Smooth movement via time-based interpolation at a configurable Playback Hz
- Provide a simple Tkinter UI
- Support global hotkeys:
- F8 → start/stop recording
- F9 → play
- ESC → stop playback
This is a lab-style implementation intended for personal automation where you have permission to automate the target UI. The code is transparent (not hidden), includes a clear failsafe, and saves macros as JSON for portability.
What makes this “more precise” than a naive recorder?
A naive recorder usually replays events “as they were recorded” using sleep(dt) between raw events. On Windows, small sleeps can be imprecise, and recorded mouse movement can be too sparse, producing jumps.
This implementation improves precision by:
- Recording at a small interval (default 2ms) to capture more movement detail.
- Replaying movement using interpolation at a fixed tick rate (default 240 Hz), creating smooth and consistent cursor motion.
- Using
time.perf_counter()and a busy-wait synchronization loop during playback ticks for tighter timing (best-effort). - Including a failsafe: moving the mouse to the top-left corner aborts playback (PyAutoGUI feature).
Requirements
Install dependencies:
pip install pynput pyautogui
Works best on Windows. (Tkinter is typically included with Python on Windows.)
Usage
- Run the script.
- Press F8 to start recording.
- Move the mouse and click as needed.
- Press F8 again to stop.
- Press F9 to play.
- Press ESC to stop playback at any time.
- Optionally:
- Change Speed to play faster (e.g., 2.0x)
- Increase Playback Hz for smoother movement at higher speed
- Set Repeat to replay multiple times
- Save/load macros using JSON.
Full Code (single file)
Everything below is copy/paste ready. All comments are in English.
import jsonimport timeimport threadingimport queuefrom dataclasses import dataclass, asdictfrom typing import List, Literal, Optional, Tupleimport pyautoguifrom pynput import mouse, keyboardimport tkinter as tkfrom tkinter import filedialog, messagebox# Safety: move mouse to top-left to abort playbackpyautogui.FAILSAFE = TrueEventType = Literal["move", "click"]@dataclassclass MacroEvent: """ A recorded macro event. - t: time in seconds since recording started - type: "move" or "click" - x, y: screen coordinates - button: left/right/middle for click events - pressed: True for mouse down, False for mouse up """ t: float type: EventType x: int y: int button: Optional[str] = None pressed: Optional[bool] = Nonedef precise_sleep(seconds: float) -> None: """ Best-effort sleep with improved precision for small intervals. On Windows, time.sleep() can be coarse for sub-millisecond or low-millisecond sleeps. This method sleeps most of the time using time.sleep(), then busy-waits the final slice. """ if seconds <= 0: return end = time.perf_counter() + seconds if seconds > 0.003: time.sleep(seconds - 0.002) while time.perf_counter() < end: passclass MouseRecorderApp: """ High-precision mouse macro recorder with GUI + global hotkeys. Hotkeys (global): F8 -> Start/Stop Recording (toggle) F9 -> Play (repeat N) ESC -> Stop Playback (safety) Playback: - Movement is replayed using time-based interpolation at a fixed tick rate (Playback Hz). - Clicks are replayed at (approx) their recorded times. - Speed multiplier adjusts the macro timeline (2.0x = twice as fast). """ def __init__(self, root: tk.Tk): self.root = root self.root.title("Mouse Macro Recorder (High Precision + Hotkeys)") # State self.recording = False self.playing = False self.start_time = 0.0 self.events: List[MacroEvent] = [] self.mouse_listener: Optional[mouse.Listener] = None self.kb_listener: Optional[keyboard.Listener] = None self.last_file = "macro.json" # Ignore UI/control events window # This helps avoid capturing accidental UI interactions (e.g., clicking the GUI buttons) self.ignore_until = 0.0 self.ui_click_guard_ms = 450 # Recording precision self.last_move_time = 0.0 self.last_move_pos: Optional[Tuple[int, int]] = None self.move_interval = 0.002 # 2ms default (high precision) self.min_move_pixels = 0 # 0 means record even tiny moves # Playback precision self.playback_hz = 240 # interpolation tick rate self.speed = 1.0 # 1.0 = normal speed # Thread-safe log self.log_q: "queue.Queue[str]" = queue.Queue() # Hotkey debounce (avoid double-trigger when key repeats) self._last_hotkey_ts = 0.0 self._hotkey_debounce_s = 0.25 # UI self._build_ui() self._poll_log() # Start listeners (global) self.start_kb_listener() self.start_mouse_listener() # ---------------- UI ---------------- def _build_ui(self): frm = tk.Frame(self.root, padx=10, pady=10) frm.pack(fill="both", expand=True) self.status_var = tk.StringVar(value="Idle") self.count_var = tk.StringVar(value="0 events") self.file_var = tk.StringVar(value=f"File: {self.last_file}") top = tk.Frame(frm) top.pack(fill="x") tk.Label(top, textvariable=self.status_var, font=("Segoe UI", 11, "bold")).pack(side="left") tk.Label(top, textvariable=self.count_var).pack(side="left", padx=12) tk.Label(top, textvariable=self.file_var).pack(side="right") btns = tk.Frame(frm, pady=8) btns.pack(fill="x") self.btn_record = tk.Button(btns, text="Start Recording (F8)", width=18, command=self.toggle_record_from_ui) self.btn_record.pack(side="left") self.btn_play = tk.Button(btns, text="Play (F9)", width=10, command=self.play_from_ui) self.btn_play.pack(side="left", padx=6) tk.Button(btns, text="Save...", width=10, command=self.save_as).pack(side="left", padx=6) tk.Button(btns, text="Load...", width=10, command=self.load_from).pack(side="left", padx=6) tk.Button(btns, text="Clear", width=10, command=self.clear).pack(side="right") opt = tk.Frame(frm) opt.pack(fill="x", pady=(0, 8)) tk.Label(opt, text="Record interval (ms):").pack(side="left") self.interval_ms = tk.IntVar(value=int(self.move_interval * 1000)) tk.Spinbox( opt, from_=1, to=50, width=5, textvariable=self.interval_ms, command=self._update_record_interval ).pack(side="left", padx=6) tk.Label(opt, text="Min move (px):").pack(side="left") self.minpx_var = tk.IntVar(value=self.min_move_pixels) tk.Spinbox( opt, from_=0, to=20, width=5, textvariable=self.minpx_var, command=self._update_minpx ).pack(side="left", padx=6) tk.Label(opt, text="Playback Hz:").pack(side="left", padx=(12, 0)) self.hz_var = tk.IntVar(value=self.playback_hz) tk.Spinbox( opt, from_=60, to=1000, width=6, textvariable=self.hz_var, command=self._update_hz ).pack(side="left", padx=6) tk.Label(opt, text="Speed:").pack(side="left", padx=(12, 0)) self.speed_var = tk.DoubleVar(value=self.speed) tk.Spinbox( opt, from_=0.25, to=10.0, increment=0.25, width=6, textvariable=self.speed_var, command=self._update_speed ).pack(side="left", padx=6) tk.Label(opt, text="Repeat:").pack(side="left", padx=(12, 0)) self.repeat_var = tk.IntVar(value=1) tk.Spinbox(opt, from_=1, to=999, width=5, textvariable=self.repeat_var).pack(side="left", padx=6) self.log = tk.Text(frm, height=14, width=95) self.log.pack(fill="both", expand=True) self._log("Hotkeys: F8 start/stop recording | F9 play | ESC stop playback") self.root.protocol("WM_DELETE_WINDOW", self.on_close) def _update_record_interval(self): try: ms = int(self.interval_ms.get()) self.move_interval = max(0.001, ms / 1000.0) self._log(f"[CFG] record interval = {ms} ms") except Exception: pass def _update_minpx(self): try: self.min_move_pixels = int(self.minpx_var.get()) self._log(f"[CFG] min move = {self.min_move_pixels} px") except Exception: pass def _update_hz(self): try: self.playback_hz = max(60, int(self.hz_var.get())) self._log(f"[CFG] playback Hz = {self.playback_hz}") except Exception: pass def _update_speed(self): try: self.speed = max(0.1, float(self.speed_var.get())) self._log(f"[CFG] speed = {self.speed}x") except Exception: pass # ---------------- Logging ---------------- def _log(self, msg: str): self.log_q.put(msg) def _poll_log(self): try: while True: msg = self.log_q.get_nowait() self.log.insert("end", msg + "\n") self.log.see("end") except queue.Empty: pass self.root.after(80, self._poll_log) def _set_status(self, text: str): self.status_var.set(text) def _set_count(self): self.count_var.set(f"{len(self.events)} events") def now(self) -> float: return time.perf_counter() def _debounced(self) -> bool: t = self.now() if (t - self._last_hotkey_ts) < self._hotkey_debounce_s: return True self._last_hotkey_ts = t return False # ---------------- Listeners ---------------- def start_mouse_listener(self): if self.mouse_listener is not None: return self.mouse_listener = mouse.Listener(on_move=self.on_move, on_click=self.on_click) self.mouse_listener.start() def stop_mouse_listener(self): if self.mouse_listener is None: return try: self.mouse_listener.stop() except Exception: pass self.mouse_listener = None def start_kb_listener(self): if self.kb_listener is not None: return self.kb_listener = keyboard.Listener(on_press=self.on_key_press) self.kb_listener.start() def stop_kb_listener(self): if self.kb_listener is None: return try: self.kb_listener.stop() except Exception: pass self.kb_listener = None # ---------------- Hotkeys ---------------- def on_key_press(self, key): try: if key == keyboard.Key.f8: if self._debounced(): return self.root.after(0, self.toggle_record_from_hotkey) elif key == keyboard.Key.f9: if self._debounced(): return self.root.after(0, self.play_from_hotkey) elif key == keyboard.Key.esc: if self.playing: self.playing = False self._log("[PLAY] Stopped by ESC.") except Exception: pass # ---------------- Record ---------------- def toggle_record_from_ui(self): # Ignore the click on the UI button itself self.ignore_until = self.now() + (self.ui_click_guard_ms / 1000.0) self.toggle_record() def toggle_record_from_hotkey(self): # Hotkey does not generate a UI click, but we still guard briefly self.ignore_until = self.now() + (self.ui_click_guard_ms / 1000.0) self.toggle_record() def toggle_record(self): if self.playing: messagebox.showinfo("Info", "Stop playback before recording.") return if not self.recording: # Start recording self.recording = True self.events = [] self.start_time = self.now() self.last_move_time = 0.0 self.last_move_pos = None self._set_status("Recording...") self.btn_record.config(text="Stop Recording (F8)") self._log("[REC] Started (F8 to stop).") else: # Stop recording self.recording = False # Trim clicks that are very close to the stop moment cutoff = self.ui_click_guard_ms / 1000.0 if self.events: last_t = self.events[-1].t self.events = [ e for e in self.events if not (e.type == "click" and e.t >= (last_t - cutoff)) ] self._set_status("Idle") self.btn_record.config(text="Start Recording (F8)") self._set_count() self._log(f"[REC] Stopped. Captured {len(self.events)} events.") def on_move(self, x, y): if not self.recording: return if self.now() < self.ignore_until: return t = self.now() - self.start_time if (t - self.last_move_time) < self.move_interval: return ix, iy = int(x), int(y) # Optional min pixel threshold to reduce noise/volume if self.last_move_pos is not None and self.min_move_pixels > 0: lx, ly = self.last_move_pos if abs(ix - lx) < self.min_move_pixels and abs(iy - ly) < self.min_move_pixels: return self.last_move_time = t self.last_move_pos = (ix, iy) self.events.append(MacroEvent(t=t, type="move", x=ix, y=iy)) self._set_count() def on_click(self, x, y, button, pressed): if not self.recording: return if self.now() < self.ignore_until: return t = self.now() - self.start_time btn = str(button).replace("Button.", "") self.events.append( MacroEvent( t=t, type="click", x=int(x), y=int(y), button=btn, pressed=bool(pressed) ) ) self._set_count() # ---------------- Play ---------------- def play_from_ui(self): self.ignore_until = self.now() + (self.ui_click_guard_ms / 1000.0) self.play() def play_from_hotkey(self): self.play() def play(self): if self.recording: messagebox.showinfo("Info", "Stop recording before playback.") return if self.playing: messagebox.showinfo("Info", "Already playing. Press ESC to stop.") return if len(self.events) < 2: messagebox.showinfo("Info", "Not enough events. Record something first.") return repeat = max(1, int(self.repeat_var.get())) self.playback_hz = max(60, int(self.hz_var.get())) self.speed = max(0.1, float(self.speed_var.get())) self.playing = True self._set_status("Playing...") self._log( f"[PLAY] Start. Hz={self.playback_hz} speed={self.speed}x repeat={repeat}. " f"ESC stops. (Failsafe: top-left corner)" ) th = threading.Thread(target=self._play_worker, args=(repeat,), daemon=True) th.start() def _play_worker(self, repeat: int): try: for r in range(repeat): if not self.playing: break self._log(f"[PLAY] Run {r + 1}/{repeat}") self._play_once_interpolated() self._log("[PLAY] Done.") except pyautogui.FailSafeException: self._log("[PLAY] Aborted by FAILSAFE (top-left corner).") except Exception as ex: self._log(f"[ERR] Playback error: {ex}") finally: self.playing = False self._set_status("Idle") def _play_once_interpolated(self): events = self.events total_time = events[-1].t / self.speed tick = 1.0 / self.playback_hz # Convert move events into a timeline suitable for interpolation moves = [(e.t / self.speed, e.x, e.y) for e in events if e.type == "move"] clicks = [e for e in events if e.type == "click"] if not moves: self._play_clicks_only(clicks) return ci = 0 start = time.perf_counter() next_t = 0.0 mi = 0 def interp_pos(tsec: float) -> Tuple[int, int]: nonlocal mi while mi + 1 < len(moves) and moves[mi + 1][0] <= tsec: mi += 1 if mi + 1 >= len(moves): return moves[-1][1], moves[-1][2] t0, x0, y0 = moves[mi] t1, x1, y1 = moves[mi + 1] if t1 <= t0: return x1, y1 a = (tsec - t0) / (t1 - t0) return int(x0 + (x1 - x0) * a), int(y0 + (y1 - y0) * a) while self.playing and next_t <= total_time + 1e-9: target = start + next_t while self.playing and time.perf_counter() < target: pass # best-effort tight sync x, y = interp_pos(next_t) pyautogui.moveTo(x, y) # Fire clicks scheduled up to this tick (with small tolerance) while ci < len(clicks) and (clicks[ci].t / self.speed) <= next_t + (tick * 0.5): e = clicks[ci] pyautogui.moveTo(e.x, e.y) if e.pressed: pyautogui.mouseDown(button=e.button) else: pyautogui.mouseUp(button=e.button) ci += 1 next_t += tick def _play_clicks_only(self, clicks: List[MacroEvent]): prev = 0.0 for e in clicks: if not self.playing: break t = e.t / self.speed precise_sleep(t - prev) pyautogui.moveTo(e.x, e.y) if e.pressed: pyautogui.mouseDown(button=e.button) else: pyautogui.mouseUp(button=e.button) prev = t # ---------------- Save/Load ---------------- def save_as(self): if self.recording or self.playing: messagebox.showinfo("Info", "Stop recording/playback before saving.") return if not self.events: messagebox.showinfo("Info", "Nothing to save.") return path = filedialog.asksaveasfilename( defaultextension=".json", filetypes=[("JSON", "*.json")], initialfile=self.last_file ) if not path: return self._save(path) def _save(self, path: str): payload = [asdict(e) for e in self.events] with open(path, "w", encoding="utf-8") as f: json.dump(payload, f, ensure_ascii=False, indent=2) self.last_file = path self.file_var.set(f"File: {self.last_file}") self._log(f"[SAVE] Saved to: {path}") def load_from(self): if self.recording or self.playing: messagebox.showinfo("Info", "Stop recording/playback before loading.") return path = filedialog.askopenfilename(filetypes=[("JSON", "*.json")]) if not path: return try: with open(path, "r", encoding="utf-8") as f: raw = json.load(f) self.events = [MacroEvent(**item) for item in raw] self.last_file = path self.file_var.set(f"File: {self.last_file}") self._set_count() self._log(f"[LOAD] Loaded {len(self.events)} events from: {path}") except Exception as ex: messagebox.showerror("Load error", str(ex)) def clear(self): if self.recording or self.playing: messagebox.showinfo("Info", "Stop recording/playback before clearing.") return self.events = [] self._set_count() self._log("[CLR] Events cleared.") def on_close(self): self.recording = False self.playing = False self.stop_mouse_listener() self.stop_kb_listener() self.root.destroy()def main(): root = tk.Tk() app = MouseRecorderApp(root) root.mainloop()if __name__ == "__main__": main()
Recommended “precision presets”
If you want “more precision”, these settings usually help:
- Record interval:
2 ms(or1 msif your CPU can handle it) - Min move:
0 px(highest fidelity; set to1 pxto reduce noise) - Playback Hz:
240for normal use500if you increase speed (2x–5x) and want smoother motion
- Speed:
2.0or3.0for faster playback
Notes and limitations (important for real-world automation)
- This is “best-effort precision” using Python and user-mode libraries.
- Some applications may behave differently under automation (focus changes, UI latency, animations).
- For extremely strict timing or low-level input fidelity, the next step is a Windows-native approach (e.g.,
SendInputvia C#/WinAPI).