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:

  1. Recording at a small interval (default 2ms) to capture more movement detail.
  2. Replaying movement using interpolation at a fixed tick rate (default 240 Hz), creating smooth and consistent cursor motion.
  3. Using time.perf_counter() and a busy-wait synchronization loop during playback ticks for tighter timing (best-effort).
  4. 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

  1. Run the script.
  2. Press F8 to start recording.
  3. Move the mouse and click as needed.
  4. Press F8 again to stop.
  5. Press F9 to play.
  6. Press ESC to stop playback at any time.
  7. 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
  8. Save/load macros using JSON.

Full Code (single file)

Everything below is copy/paste ready. All comments are in English.

import json
import time
import threading
import queue
from dataclasses import dataclass, asdict
from typing import List, Literal, Optional, Tuple
import pyautogui
from pynput import mouse, keyboard
import tkinter as tk
from tkinter import filedialog, messagebox
# Safety: move mouse to top-left to abort playback
pyautogui.FAILSAFE = True
EventType = Literal["move", "click"]
@dataclass
class 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] = None
def 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:
pass
class 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 (or 1 ms if your CPU can handle it)
  • Min move: 0 px (highest fidelity; set to 1 px to reduce noise)
  • Playback Hz:
    • 240 for normal use
    • 500 if you increase speed (2x–5x) and want smoother motion
  • Speed: 2.0 or 3.0 for 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., SendInput via C#/WinAPI).

Edvaldo Guimrães Filho Avatar

Published by