Projects, downloads, code, and modular showcase data.
Ghost focuses on replacing repetitive desktop routines with fast, replayable automations and reliable execution controls.
Teams and solo operators lose hours each week to repetitive click and keyboard workflows that are easy to mis-execute.
Build a sequence recorder with channel-based playback, configurable delays, and hotkey triggers for repeatable runtime behavior.
Stabilizing multi-profile execution behavior and reducing setup friction for first-time users.
Users recover consistent time blocks by turning repeat tasks into one-trigger automations.
import os
import sys
import json
import time
import math
import random
import threading
import tkinter as tk
from tkinter import ttk, messagebox, colorchooser
import ctypes
from ctypes import wintypes
from datetime import datetime
import traceback
from collections import deque
# =========================
# Debug function
# =========================
def debug_print(msg):
"""Print debug messages"""
print(f"[DEBUG] {msg}")
# =========================
# Version and Paths
# =========================
APP_NAME = "Ghost"
APP_VERSION = "2.4.0" # Updated version
CONFIG_VERSION = "2.4"
def is_frozen():
return getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS")
BASE_DIR = os.path.dirname(sys.executable if is_frozen() else os.path.abspath(__file__))
CONFIG_PATH = os.path.join(BASE_DIR, "config.json")
debug_print(f"Base directory: {BASE_DIR}")
debug_print(f"Config path: {CONFIG_PATH}")
# =========================
# Win32 Primitives with Relative Movement for Games
# =========================
if not hasattr(wintypes, "ULONG_PTR"):
wintypes.ULONG_PTR = ctypes.c_ulonglong if ctypes.sizeof(ctypes.c_void_p) == 8 else ctypes.c_ulong
user32 = ctypes.WinDLL("user32", use_last_error=True)
# Mouse event constants
MOUSEEVENTF_MOVE = 0x0001
MOUSEEVENTF_LEFTDOWN = 0x0002
MOUSEEVENTF_LEFTUP = 0x0004
MOUSEEVENTF_RIGHTDOWN = 0x0008
MOUSEEVENTF_RIGHTUP = 0x0010
MOUSEEVENTF_MIDDLEDOWN = 0x0020
MOUSEEVENTF_MIDDLEUP = 0x0040
MOUSEEVENTF_ABSOLUTE = 0x8000
MOUSEEVENTF_VIRTUALDESK = 0x4000
# Max steps before cutoff - CENTRALIZED CONSTANT
MAX_SEQUENCE_STEPS = 50000
# Keyboard constants
KEYEVENTF_KEYUP = 0x0002
# Original functions we still need
GetAsyncKeyState = user32.GetAsyncKeyState
GetAsyncKeyState.argtypes = [wintypes.INT]
GetAsyncKeyState.restype = wintypes.SHORT
GetCursorPos = user32.GetCursorPos
GetCursorPos.argtypes = [ctypes.POINTER(wintypes.POINT)]
GetCursorPos.restype = wintypes.BOOL
SetCursorPos = user32.SetCursorPos
SetCursorPos.argtypes = [wintypes.INT, wintypes.INT]
SetCursorPos.restype = wintypes.BOOL
MapVirtualKeyW = user32.MapVirtualKeyW
MapVirtualKeyW.argtypes = [wintypes.UINT, wintypes.UINT]
MapVirtualKeyW.restype = wintypes.UINT
VkKeyScanW = user32.VkKeyScanW
VkKeyScanW.argtypes = [wintypes.WCHAR]
VkKeyScanW.restype = wintypes.SHORT
mouse_event = user32.mouse_event
mouse_event.argtypes = [wintypes.DWORD, wintypes.DWORD, wintypes.DWORD, wintypes.DWORD, wintypes.ULONG_PTR]
mouse_event.restype = None
keybd_event = user32.keybd_event
keybd_event.argtypes = [wintypes.BYTE, wintypes.BYTE, wintypes.DWORD, wintypes.ULONG_PTR]
keybd_event.restype = None
# Virtual key codes (minimal set for UI display and hotkey parsing)
VK = {
"BACKSPACE": 0x08, "TAB": 0x09, "ENTER": 0x0D,
"SHIFT": 0x10, "CTRL": 0x11, "ALT": 0x12,
"PAUSE": 0x13, "CAPSLOCK": 0x14,
"ESC": 0x1B, "SPACE": 0x20,
"PAGEUP": 0x21, "PAGEDOWN": 0x22,
"END": 0x23, "HOME": 0x24,
"LEFT": 0x25, "UP": 0x26, "RIGHT": 0x27, "DOWN": 0x28,
"INSERT": 0x2D, "DELETE": 0x2E,
"LWIN": 0x5B, "RWIN": 0x5C,
"NUMPAD0": 0x60, "NUMPAD1": 0x61, "NUMPAD2": 0x62, "NUMPAD3": 0x63,
"NUMPAD4": 0x64, "NUMPAD5": 0x65, "NUMPAD6": 0x66, "NUMPAD7": 0x67,
"NUMPAD8": 0x68, "NUMPAD9": 0x69,
"MULTIPLY": 0x6A, "ADD": 0x6B, "SEPARATOR": 0x6C, "SUBTRACT": 0x6D,
"DECIMAL": 0x6E, "DIVIDE": 0x6F,
"F1": 0x70, "F2": 0x71, "F3": 0x72, "F4": 0x73, "F5": 0x74,
"F6": 0x75, "F7": 0x76, "F8": 0x77, "F9": 0x78, "F10": 0x79,
"F11": 0x7A, "F12": 0x7B, "F13": 0x7C, "F14": 0x7D, "F15": 0x7E,
"F16": 0x7F, "F17": 0x80, "F18": 0x81, "F19": 0x82, "F20": 0x83,
"F21": 0x84, "F22": 0x85, "F23": 0x86, "F24": 0x87,
"NUMLOCK": 0x90, "SCROLL": 0x91,
"LSHIFT": 0xA0, "RSHIFT": 0xA1, "LCONTROL": 0xA2, "RCONTROL": 0xA3,
"LMENU": 0xA4, "RMENU": 0xA5,
"SEMICOLON": 0xBA, "PLUS": 0xBB, "COMMA": 0xBC, "MINUS": 0xBD,
"PERIOD": 0xBE, "SLASH": 0xBF, "TILDE": 0xC0,
"LBRACKET": 0xDB, "BACKSLASH": 0xDC, "RBRACKET": 0xDD,
"QUOTE": 0xDE,
}
for i in range(1, 25):
VK[f"F{i}"] = 0x6F + i
for d in range(10):
VK[str(d)] = 0x30 + d
for c in range(ord("A"), ord("Z") + 1):
VK[chr(c)] = c
# =========================
# Mouse Functions for Games (Relative Movement)
# =========================
def get_cursor_pos():
point = wintypes.POINT()
GetCursorPos(ctypes.byref(point))
return point.x, point.y
def is_vk_down(vk: int) -> bool:
return (GetAsyncKeyState(vk) & 0x8000) != 0
def tap_keybd_event(vk: int, hold_ms: int):
"""Tap a key using keybd_event"""
scan = MapVirtualKeyW(vk, 0) & 0xFF
keybd_event(vk, scan, 0, 0)
if hold_ms > 0:
time.sleep(hold_ms / 1000.0)
keybd_event(vk, scan, KEYEVENTF_KEYUP, 0)
def mouse_click(button: str, hold_ms: int):
"""Click using mouse_event"""
if button == "left":
down_flag = MOUSEEVENTF_LEFTDOWN
up_flag = MOUSEEVENTF_LEFTUP
elif button == "right":
down_flag = MOUSEEVENTF_RIGHTDOWN
up_flag = MOUSEEVENTF_RIGHTUP
elif button == "middle":
down_flag = MOUSEEVENTF_MIDDLEDOWN
up_flag = MOUSEEVENTF_MIDDLEUP
else:
return
mouse_event(down_flag, 0, 0, 0, 0)
if hold_ms > 0:
time.sleep(hold_ms / 1000.0)
mouse_event(up_flag, 0, 0, 0, 0)
def mouse_move_relative(dx: int, dy: int):
"""Relative movement using mouse_event (works in games like Fallout 76)"""
if dx != 0 or dy != 0:
mouse_event(MOUSEEVENTF_MOVE, dx, dy, 0, 0)
def mouse_move_to(x: int, y: int, speed_px_per_sec: int = 500, smooth: bool = True, steps: int = 20, app=None):
"""
Move mouse to specified coordinates using relative movements with fluid motion
"""
if not smooth:
# Fast movement - still use chunks but with minimal delay
current_x, current_y = get_cursor_pos()
dx = x - current_x
dy = y - current_y
distance = math.sqrt(dx * dx + dy * dy)
if distance < 1:
return
# Use larger chunks for faster movement
chunk_size = 50
num_chunks = max(1, int(distance / chunk_size))
# Calculate chunk movements
chunk_dx = dx / num_chunks
chunk_dy = dy / num_chunks
for i in range(num_chunks):
if app and app.stop_event.is_set():
return
# Move in chunks
if i < num_chunks - 1:
move_dx = int(chunk_dx)
move_dy = int(chunk_dy)
else:
# Last chunk - go exactly to target
curr_x, curr_y = get_cursor_pos()
move_dx = x - curr_x
move_dy = y - curr_y
if abs(move_dx) > 0 or abs(move_dy) > 0:
mouse_move_relative(move_dx, move_dy)
# Very small delay between chunks
time.sleep(0.001)
return
# Smooth, fluid movement
current_x, current_y = get_cursor_pos()
dx = x - current_x
dy = y - current_y
distance = math.sqrt(dx * dx + dy * dy)
if distance < 1:
return
# Calculate optimal number of steps for fluid motion
# More steps for longer distances, but cap it
base_steps = max(20, min(100, int(distance / 2)))
if steps > 0:
num_steps = min(base_steps, steps)
else:
num_steps = base_steps
# Use a more natural easing curve (smooth start, smooth stop)
for i in range(1, num_steps + 1):
if app and app.stop_event.is_set():
return
# Smoother easing: sin^2 for very smooth acceleration/deceleration
t = i / num_steps
# Easing function: sin^2 gives smooth start and stop
eased_t = math.sin(t * math.pi / 2) ** 2
target_x = int(current_x + dx * eased_t)
target_y = int(current_y + dy * eased_t)
# Get current position
curr_x, curr_y = get_cursor_pos()
move_dx = target_x - curr_x
move_dy = target_y - curr_y
# Only move if needed
if abs(move_dx) > 0 or abs(move_dy) > 0:
mouse_move_relative(move_dx, move_dy)
# Dynamic delay based on speed and distance
if speed_px_per_sec > 0 and distance > 0:
move_time = distance / speed_px_per_sec
# Vary delay slightly for more natural feel
base_delay = move_time / num_steps
# Add tiny random variation (0.9-1.1x)
delay = base_delay * (0.9 + random.random() * 0.2)
time.sleep(max(0.001, delay))
# Final position adjustment
final_x, final_y = get_cursor_pos()
final_dx = x - final_x
final_dy = y - final_y
if abs(final_dx) > 1 or abs(final_dy) > 1:
mouse_move_relative(final_dx, final_dy)
def key_to_vk(key_str: str):
"""Convert key name to VK code (for UI and hotkey parsing only)"""
ks = (key_str or "").strip().upper()
if not ks:
return None
if ks in VK:
return VK[ks]
if len(ks) == 1:
vks = VkKeyScanW(ks)
if vks == -1:
return None
return vks & 0xFF
return None
def normalize_hotkey_string(s: str) -> str:
s = (s or "").strip().upper().replace("-", "+")
s = "+".join([p.strip() for p in s.split("+") if p.strip()])
return s
def parse_hotkey(s: str):
s = normalize_hotkey_string(s)
if not s:
return None, None
parts = s.split("+")
mods = set()
key_part = parts[-1]
for p in parts[:-1]:
if p in ("CTRL", "CONTROL"):
mods.add("CTRL")
elif p == "SHIFT":
mods.add("SHIFT")
elif p == "ALT":
mods.add("ALT")
elif p in ("WIN", "WINDOWS"):
mods.add("WIN")
vk = key_to_vk(key_part)
return mods, vk
def current_mods_down():
mods = set()
if is_vk_down(VK["CTRL"]):
mods.add("CTRL")
if is_vk_down(VK["SHIFT"]):
mods.add("SHIFT")
if is_vk_down(VK["ALT"]):
mods.add("ALT")
if is_vk_down(VK["LWIN"]) or is_vk_down(VK["RWIN"]):
mods.add("WIN")
return mods
def get_human_interval(base_ms: int, state: dict):
lvl = float(state.get("level", 0.0))
up = int(state.get("up", 20))
down = int(state.get("down", 10))
step = float(state.get("step", 0.1))
if random.randrange(0, 100) < up:
lvl = min(1.0, lvl + step)
if random.randrange(0, 100) < down:
lvl = max(0.0, lvl - step)
state["level"] = lvl
use_wide = (random.random() < lvl)
variation = random.uniform(0.8, 1.2) if not use_wide else random.uniform(0.5, 2.0)
return max(1, int(base_ms * variation))
# =========================
# Theme Presets (Immutable)
# =========================
THEME_PRESETS = {
"Midnight": {
"bg": "#0F1115",
"panel": "#141824",
"panel2": "#10131C",
"entry_bg": "#111827",
"general_fg": "#D5DBE7",
"entry_fg": "#EAF0FF",
"button_fg": "#D5DBE7",
"log_fg": "#D5DBE7",
"muted": "#A9B0BE",
"border": "#2B3245",
"accent": "#1B2235",
"sel": "#223052",
"button_bg": "#1B2235",
"log_bg": "#111827",
},
"Slate": {
"bg": "#111318",
"panel": "#171A21",
"panel2": "#141723",
"entry_bg": "#121521",
"general_fg": "#E4E7EF",
"entry_fg": "#F3F6FF",
"button_fg": "#E4E7EF",
"log_fg": "#E4E7EF",
"muted": "#B6BDCA",
"border": "#32384A",
"accent": "#1E2536",
"sel": "#2A3758",
"button_bg": "#1E2536",
"log_bg": "#121521",
},
"OLED": {
"bg": "#000000",
"panel": "#0A0A0A",
"panel2": "#070707",
"entry_bg": "#0B0B0B",
"general_fg": "#EDEDED",
"entry_fg": "#FFFFFF",
"button_fg": "#EDEDED",
"log_fg": "#EDEDED",
"muted": "#B0B0B0",
"border": "#2A2A2A",
"accent": "#101010",
"sel": "#1C1C1C",
"button_bg": "#101010",
"log_bg": "#0B0B0B",
},
"Light": {
"bg": "#F5F7FB",
"panel": "#FFFFFF",
"panel2": "#F0F2F7",
"entry_bg": "#FFFFFF",
"general_fg": "#1B1F2A",
"entry_fg": "#111111",
"button_fg": "#1B1F2A",
"log_fg": "#1B1F2A",
"muted": "#5A6375",
"border": "#D0D6E3",
"accent": "#E8ECF6",
"sel": "#DCE5FF",
"button_bg": "#E8ECF6",
"log_bg": "#FFFFFF",
},
}
# =========================
# Default Config
# =========================
def get_default_config():
"""Get the default configuration"""
return {
"version": CONFIG_VERSION,
"app_name": APP_NAME,
"created": datetime.now().isoformat(),
"last_modified": datetime.now().isoformat(),
"last_loaded": None,
"load_count": 0,
# Core settings
"dark_mode": True,
"start_maximized": True,
# Window settings
"window": {
"state": "zoomed",
"x": None,
"y": None,
"width": 1400,
"height": 900,
"maximized": True
},
# Hotkeys
"hotkeys": {
"toggle": "F8",
"stop": "ESC",
"lock": "F9",
"record": "F7",
"pause": "F6"
},
# Timing defaults
"timing": {
"global_start_delay_ms": 0,
"global_end_delay_ms": 0,
"default_hold_ms": 70,
"loop_sequence": True,
"max_loops": 0
},
# Mouse settings
"mouse": {
"default_move_speed": 500,
"default_click_hold_ms": 50,
"return_to_original": False,
"smooth_movement": True,
"movement_steps": 20,
"mouse_acceleration": False,
"acceleration_factor": 1.5
},
# Theme settings
"theme": {
"preset": "Midnight",
"colors": {},
"per_channel": [{
"general_fg": None,
"entry_bg": None,
"entry_fg": None,
"button_bg": None,
"button_fg": None
} for _ in range(16)],
"custom_presets": {}
},
# Recording settings - NEW FIELDS
"recording": {
"mode": "balanced", # "full", "balanced", "lightweight"
"capture_mouse_movement": True,
"mouse_sample_rate_ms": 10,
"capture_modifiers": True,
"filter_duplicates": True, # Now default True
"filter_jitter": True, # NEW: filter mouse jitter
"jitter_threshold": 3, # NEW: pixels of movement to ignore as jitter
"min_gap_ms": 2,
"movement_compression": True, # NEW: compress nearby movements
"compression_threshold": 5, # NEW: pixels threshold for compression
"max_sequence_steps": 50000, # NEW: configurable max steps
},
# Channels (16 channels)
"channels": [{
"enabled": False,
"key": "E",
"times": 1,
"interval_ms": 1000,
"start_delay_ms": 0,
"end_delay_ms": 0,
"humanize": False,
"sequenced": False,
"sequence": [],
"sequence_name": "",
"notes": "",
"created": None,
"modified": None
} for _ in range(16)],
# Saved sequences library
"saved_sequences": {},
# Advanced settings
"advanced": {
"thread_priority": "normal",
"process_priority": "normal",
"debug_mode": False,
"log_level": "info",
"max_undo_steps": 50,
"concurrent_execution": True
},
# UI state
"ui_state": {
"last_tab": 0,
"panel_sash_pos": None,
"channel_order": list(range(16)),
"hidden_channels": [],
"expanded_sections": []
},
# Custom data
"custom_data": {}
}
# =========================
# Simple Config Manager
# =========================
class SimpleConfigManager:
"""Simple config manager that just saves and loads directly"""
def __init__(self, config_path=CONFIG_PATH):
self.config_path = config_path
self.config = None
self.lock = threading.RLock()
debug_print(f"SimpleConfigManager initialized with path: {config_path}")
def load_config(self):
"""Load configuration - returns config dict"""
with self.lock:
debug_print(f"Loading config from: {self.config_path}")
if not os.path.exists(self.config_path):
debug_print("Config file not found, creating default")
self.config = get_default_config()
self._save_config()
return self._deepcopy(self.config)
try:
with open(self.config_path, 'r', encoding='utf-8') as f:
loaded_config = json.load(f)
debug_print(f"Config loaded successfully")
if "version" not in loaded_config:
loaded_config["version"] = CONFIG_VERSION
loaded_config["last_loaded"] = datetime.now().isoformat()
loaded_config["load_count"] = loaded_config.get("load_count", 0) + 1
self.config = loaded_config
self._save_config()
return self._deepcopy(self.config)
except Exception as e:
debug_print(f"Error loading config: {e}")
traceback.print_exc()
if os.path.exists(self.config_path):
try:
backup_path = self.config_path + ".backup"
os.rename(self.config_path, backup_path)
debug_print(f"Backed up corrupted file to {backup_path}")
except:
pass
self.config = get_default_config()
self._save_config()
return self._deepcopy(self.config)
def save_config(self):
"""Save current configuration"""
with self.lock:
if self.config is None:
debug_print("Cannot save: config is None")
return False
debug_print(f"Saving config to: {self.config_path}")
self.config["last_modified"] = datetime.now().isoformat()
self.config["version"] = CONFIG_VERSION
return self._save_config()
def save_external_config(self, config):
"""Save an external config (from the app)"""
with self.lock:
self.config = self._deepcopy(config)
self.config["last_modified"] = datetime.now().isoformat()
self.config["version"] = CONFIG_VERSION
return self._save_config()
def _save_config(self):
"""Internal save method - directly overwrites file"""
try:
os.makedirs(os.path.dirname(self.config_path), exist_ok=True)
with open(self.config_path, 'w', encoding='utf-8') as f:
json.dump(self.config, f, indent=2, sort_keys=True)
debug_print(f"Config saved successfully to {self.config_path}")
return True
except Exception as e:
debug_print(f"Save failed: {e}")
traceback.print_exc()
return False
def update_config(self, updates):
"""Update specific parts of config"""
with self.lock:
if self.config is None:
return False
for key, value in updates.items():
self.config[key] = value
return self.save_config()
def _deepcopy(self, obj):
"""Safe deep copy"""
try:
return json.loads(json.dumps(obj))
except:
return obj
def export_channel(self, channel_index):
"""Export a single channel configuration"""
if self.config and 0 <= channel_index < 16:
channel = self._deepcopy(self.config["channels"][channel_index])
channel["exported"] = datetime.now().isoformat()
return channel
return None
def import_channel(self, channel_data, channel_index):
"""Import a channel configuration"""
if not self.config or channel_index < 0 or channel_index >= 16:
return False
self.config["channels"][channel_index] = channel_data
self.config["channels"][channel_index]["imported"] = datetime.now().isoformat()
return self.save_config()
def save_sequence_to_library(self, sequence, name, description=""):
"""Save a sequence to the library"""
if not self.config:
return False
if "saved_sequences" not in self.config:
self.config["saved_sequences"] = {}
sequence_id = f"seq_{int(time.time())}_{len(self.config['saved_sequences'])}"
self.config["saved_sequences"][sequence_id] = {
"name": name,
"description": description,
"sequence": self._deepcopy(sequence),
"created": datetime.now().isoformat(),
"modified": datetime.now().isoformat(),
"use_count": 0
}
return self.save_config()
def load_sequence_from_library(self, sequence_id):
"""Load a sequence from the library"""
if self.config and "saved_sequences" in self.config:
if sequence_id in self.config["saved_sequences"]:
seq_data = self.config["saved_sequences"][sequence_id]
seq_data["use_count"] = seq_data.get("use_count", 0) + 1
seq_data["last_used"] = datetime.now().isoformat()
self.save_config()
return self._deepcopy(seq_data["sequence"])
return None
# =========================
# Theme Helpers (UPDATED)
# =========================
def _valid_hex_color(s: str) -> bool:
"""Validate hex color string"""
if not isinstance(s, str):
return False
s = s.strip()
if len(s) != 7 or not s.startswith("#"):
return False
try:
int(s[1:], 16)
return True
except Exception:
return False
def resolve_theme(cfg: dict):
"""Resolve theme from config with overrides"""
theme_cfg = cfg.get("theme", {}) if isinstance(cfg, dict) else {}
preset_name = theme_cfg.get("preset", "Midnight")
if preset_name not in THEME_PRESETS and preset_name != "Custom":
preset_name = "Midnight"
base = THEME_PRESETS.get(preset_name, THEME_PRESETS["Midnight"]).copy()
overrides = theme_cfg.get("colors", {})
if isinstance(overrides, dict):
for k, v in overrides.items():
if isinstance(v, str) and _valid_hex_color(v):
base[k] = v
return base, preset_name
def apply_theme(style: ttk.Style, root: tk.Tk, theme: dict):
"""Apply all theme settings to the UI"""
bg = theme["bg"]
panel = theme["panel"]
panel2 = theme["panel2"]
entry_bg = theme["entry_bg"]
general_fg = theme["general_fg"]
entry_fg = theme["entry_fg"]
button_fg = theme["button_fg"]
muted = theme["muted"]
border = theme["border"]
accent = theme["accent"]
sel = theme["sel"]
button_bg = theme.get("button_bg", accent)
log_bg = theme.get("log_bg", entry_bg)
log_fg = theme.get("log_fg", general_fg)
# Root window background
root.configure(bg=bg)
# ===== Basic ttk styles =====
style.configure(".", background=bg, foreground=general_fg)
# Frames
style.configure("TFrame", background=bg)
style.configure("Card.TFrame", background=panel, borderwidth=1, relief="solid")
style.configure("SubCard.TFrame", background=panel2, borderwidth=1, relief="solid")
# Labels
style.configure("TLabel", background=bg, foreground=general_fg)
style.configure("Muted.TLabel", background=bg, foreground=muted)
style.configure("Bold.TLabel", background=bg, foreground=general_fg, font=("Arial", 10, "bold"))
# LabelFrames
style.configure("TLabelframe", background=bg, foreground=general_fg, bordercolor=border)
style.configure("TLabelframe.Label", background=bg, foreground=general_fg)
# Separator
style.configure("TSeparator", background=border)
# Buttons
style.configure("TButton",
background=button_bg,
foreground=button_fg,
bordercolor=border,
focuscolor=sel,
padding=(12, 7))
style.map("TButton",
background=[("active", sel), ("pressed", sel), ("disabled", panel)],
foreground=[("disabled", muted)])
# Checkbuttons
style.configure("TCheckbutton",
background=bg,
foreground=general_fg,
padding=(6, 2))
style.map("TCheckbutton",
foreground=[("disabled", muted)],
background=[("active", bg)])
# Radiobuttons
style.configure("TRadiobutton",
background=bg,
foreground=general_fg,
padding=(6, 2))
style.map("TRadiobutton",
foreground=[("disabled", muted)],
background=[("active", bg)])
# Entries
style.configure("TEntry",
fieldbackground=entry_bg,
foreground=entry_fg,
background=bg,
bordercolor=border,
padding=(8, 6))
style.map("TEntry",
fieldbackground=[("disabled", panel), ("readonly", panel)],
foreground=[("disabled", muted)])
# Combobox
style.configure("TCombobox",
fieldbackground=entry_bg,
foreground=entry_fg,
background=bg,
bordercolor=border,
padding=(8, 6))
style.map("TCombobox",
fieldbackground=[("disabled", panel), ("readonly", panel)],
foreground=[("disabled", muted)])
# Treeview
style.configure("Treeview",
background=panel,
fieldbackground=panel,
foreground=general_fg,
bordercolor=border,
rowheight=26)
style.configure("Treeview.Heading",
background=accent,
foreground=general_fg,
relief="flat",
bordercolor=border)
style.map("Treeview",
background=[("selected", sel)],
foreground=[("selected", general_fg)])
# Scrollbar
style.configure("TScrollbar",
background=panel2,
bordercolor=border,
troughcolor=bg,
arrowcolor=general_fg)
style.map("TScrollbar",
background=[("active", sel), ("pressed", sel)])
# Progressbar
style.configure("TProgressbar",
background=sel,
troughcolor=panel)
# Panedwindow
style.configure("TPanedwindow", background=bg)
# Notebook (tabs)
style.configure("TNotebook", background=bg, bordercolor=border)
style.configure("TNotebook.Tab",
background=panel,
foreground=general_fg,
bordercolor=border,
padding=(12, 4))
style.map("TNotebook.Tab",
background=[("selected", panel2), ("active", accent)],
foreground=[("selected", general_fg)])
def apply_theme_to_widgets(root: tk.Tk, theme: dict):
"""Recursively apply theme colors to all widgets that don't use ttk styles"""
def apply_to_widget(widget):
# Get widget class
w_class = widget.winfo_class()
# Apply based on widget type
if w_class in ("Frame", "Labelframe", "Toplevel"):
try:
widget.configure(bg=theme["bg"])
except:
pass
elif w_class == "Canvas":
try:
widget.configure(bg=theme["bg"], highlightbackground=theme["border"])
except:
pass
elif w_class == "Text":
try:
widget.configure(
bg=theme.get("log_bg", theme["entry_bg"]),
fg=theme.get("log_fg", theme["general_fg"]),
insertbackground=theme.get("log_fg", theme["general_fg"]),
highlightbackground=theme["border"],
highlightcolor=theme["sel"]
)
except:
pass
elif w_class == "Listbox":
try:
widget.configure(
bg=theme["entry_bg"],
fg=theme["entry_fg"],
selectbackground=theme["sel"],
selectforeground=theme["general_fg"],
highlightbackground=theme["border"]
)
except:
pass
elif w_class == "Menu":
try:
widget.configure(
bg=theme["panel"],
fg=theme["general_fg"],
activebackground=theme["sel"],
activeforeground=theme["general_fg"]
)
except:
pass
# Recursively process children
try:
for child in widget.winfo_children():
apply_to_widget(child)
except:
pass
apply_to_widget(root)
def apply_channel_styles(style: ttk.Style, cfg: dict, theme: dict):
"""Apply per-channel styles"""
pc = (((cfg or {}).get("theme", {}) or {}).get("per_channel", []))
if not isinstance(pc, list):
pc = []
for i in range(16):
ch = pc[i] if i < len(pc) and isinstance(pc[i], dict) else {}
general_fg = ch.get("general_fg") if _valid_hex_color(str(ch.get("general_fg"))) else theme["general_fg"]
entry_bg = ch.get("entry_bg") if _valid_hex_color(str(ch.get("entry_bg"))) else theme["entry_bg"]
entry_fg = ch.get("entry_fg") if _valid_hex_color(str(ch.get("entry_fg"))) else theme["entry_fg"]
btn_bg = ch.get("button_bg") if _valid_hex_color(str(ch.get("button_bg"))) else theme.get("button_bg", theme["accent"])
btn_fg = ch.get("button_fg") if _valid_hex_color(str(ch.get("button_fg"))) else theme["button_fg"]
# Channel-specific label style
style.configure(f"Ch{i}.TLabel",
background=theme["bg"],
foreground=general_fg)
# Channel-specific entry style
style.configure(f"Ch{i}.TEntry",
fieldbackground=entry_bg,
foreground=entry_fg,
background=theme["bg"],
bordercolor=theme["border"],
padding=(8, 6))
style.map(f"Ch{i}.TEntry",
fieldbackground=[("disabled", theme["panel"]), ("readonly", theme["panel"])],
foreground=[("disabled", theme["muted"])])
# Channel-specific button style
style.configure(f"Ch{i}.TButton",
background=btn_bg,
foreground=btn_fg,
bordercolor=theme["border"],
padding=(10, 6))
style.map(f"Ch{i}.TButton",
background=[("active", theme["sel"]), ("pressed", theme["sel"]), ("disabled", theme["panel"])],
foreground=[("disabled", theme["muted"])])
# Channel-specific checkbutton style
style.configure(f"Ch{i}.TCheckbutton",
background=theme["bg"],
foreground=general_fg,
padding=(6, 2))
style.map(f"Ch{i}.TCheckbutton",
foreground=[("disabled", theme["muted"])],
background=[("active", theme["bg"])])
# Channel-specific radiobutton style
style.configure(f"Ch{i}.TRadiobutton",
background=theme["bg"],
foreground=general_fg,
padding=(6, 2))
style.map(f"Ch{i}.TRadiobutton",
foreground=[("disabled", theme["muted"])],
background=[("active", theme["bg"])])
# Channel-specific labelframe style
style.configure(f"Ch{i}.TLabelframe",
background=theme["bg"],
foreground=general_fg,
bordercolor=theme["border"])
style.configure(f"Ch{i}.TLabelframe.Label",
background=theme["bg"],
foreground=general_fg)
This project turns long meeting recordings into structured transcripts and searchable summaries for quicker follow-up execution.
Important decisions in meetings are frequently missed or forgotten because notes are incomplete and scattered.
Process audio into transcript blocks, extract speaker-level insights, and support exports for downstream workflows.
Improving speaker segmentation accuracy and speeding up large-file processing.
Teams leave meetings with cleaner records and faster action-item capture.
import os
import sys
import re
import json
import math
import queue
import shutil
import tempfile
import threading
import subprocess
from datetime import datetime
from difflib import SequenceMatcher
import multiprocessing
if __name__ == "__main__":
multiprocessing.freeze_support()
import tkinter as tk
from tkinter import filedialog, messagebox, scrolledtext, ttk
def is_frozen():
return getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS')
if is_frozen():
os.environ['PYTHONUNBUFFERED'] = '1'
if sys.platform == "win32":
import subprocess
subprocess.CREATE_NO_WINDOW = 0x08000000
# ------------------ CONFIG / PATHS ------------------ #
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
if is_frozen():
APP_DIR = os.path.dirname(sys.executable)
else:
APP_DIR = BASE_DIR
CACHE_DIR = os.path.join(APP_DIR, "cache")
CONFIG_PATH = os.path.join(APP_DIR, "config.json")
os.makedirs(CACHE_DIR, exist_ok=True)
DEFAULT_CONFIG = {
# ---- Export/UI ----
"default_format": "txt",
"dir_mode": "dialog",
"custom_dir": "",
"default_filename": "",
"theme": "dark",
"wrap_width": 860,
"text_size": 10,
"segment_spacing": 2,
# ---- Live preview ----
"live_chunk_seconds": 10.0,
"live_asr_model": "small",
"live_language": "en",
# ---- File ASR ----
"asr_model": "large-v2",
"asr_language": "auto",
"compute_type_cuda": "float16",
"compute_type_cpu": "float32",
# ---- Alignment ----
"align_enabled": True,
# ---- Segment filtering (feature extraction) ----
"segment_min_seconds": 0.25,
# ---- Feature diarization ----
"diarization_enabled": True,
"cluster_min": 2,
"cluster_max": 8,
"cluster_divisor": 6,
"yin_fmin": 80.0,
"yin_fmax": 400.0,
# ---- Cleanup repetitions ----
"cleanup_enabled": True,
"cleanup_overlap_min_words": 3,
"cleanup_overlap_max_check_words": 24,
"cleanup_duplicate_ratio": 0.94,
"cleanup_exact_dup_time_eps": 0.05,
"cleanup_keep_cross_speaker_overlap": False,
# ---- Safety / display (optional) ----
"censor_insights": True,
}
AUDIO_EXTS = {".wav", ".mp3", ".m4a", ".flac", ".ogg", ".aac", ".wma"}
VIDEO_EXTS = {".mp4", ".mkv", ".mov", ".avi", ".wmv"}
ADDRESS_PATTERN = re.compile(
r"\b\d{1,6}\s+[A-Za-z0-9.\s]+?"
r"(Street|St\.?|Road|Rd\.?|Avenue|Ave\.?|Boulevard|Blvd\.?|"
r"Lane|Ln\.?|Drive|Dr\.?|Court|Ct\.?|Way|Highway|Hwy\.?)\b",
re.IGNORECASE,
)
_CENSOR_PATTERNS = [
re.compile(r"\bnigg[aer]s?\b", re.IGNORECASE),
re.compile(r"\bfag+ot?\b", re.IGNORECASE),
re.compile(r"\bretard(ed|s)?\b", re.IGNORECASE),
]
def _sanitize_for_insights(s: str) -> str:
s = s or ""
for rx in _CENSOR_PATTERNS:
s = rx.sub("[censored]", s)
return s
if not os.path.exists(CONFIG_PATH):
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
json.dump(DEFAULT_CONFIG, f, indent=2)
def load_config():
if not os.path.exists(CONFIG_PATH):
return DEFAULT_CONFIG.copy()
try:
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
data = json.load(f)
cfg = DEFAULT_CONFIG.copy()
cfg.update(data or {})
return cfg
except Exception:
return DEFAULT_CONFIG.copy()
def save_config(cfg):
try:
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
json.dump(cfg, f, indent=2)
except Exception:
pass
def ensure_app_paths():
os.makedirs(APP_DIR, exist_ok=True)
os.makedirs(CACHE_DIR, exist_ok=True)
def bootstrap_config():
"""
Guarantee CONFIG_PATH exists on disk and contains all DEFAULT_CONFIG keys.
- If missing: write DEFAULT_CONFIG
- If present: merge in missing keys and write back
Returns: merged config dict
"""
ensure_app_paths()
if not os.path.exists(CONFIG_PATH):
cfg = DEFAULT_CONFIG.copy()
save_config(cfg)
return cfg
try:
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
data = json.load(f) or {}
except Exception:
data = {}
cfg = DEFAULT_CONFIG.copy()
if isinstance(data, dict):
cfg.update(data)
save_config(cfg)
return cfg
def preflight_runtime(log_cb=None):
"""
Ensure all required runtime assets exist:
- app dirs + config.json
- python deps
- spaCy model
"""
def log(s):
if log_cb:
log_cb(s)
bootstrap_config()
log(f"App dir: {APP_DIR}")
log(f"Cache dir: {CACHE_DIR}")
log(f"Config: {CONFIG_PATH}")
if not is_frozen():
ensure_python_deps(progress=log)
ensure_spacy_model(progress=log)
else:
log("Running in frozen mode - skipping dependency installation")
try:
init_nlp()
log("NLP initialized successfully")
except Exception as e:
log(f"Warning: Could not initialize NLP: {e}")
def get_config_fresh():
return load_config()
def cfg_get(cfg, key, default):
if not isinstance(cfg, dict):
return default
v = cfg.get(key, default)
return default if v is None else v
# ------------------ RUNTIME CHECKS ------------------ #
def _run(cmd, timeout=120):
if sys.platform == "win32" and is_frozen():
creationflags = 0x08000000
else:
creationflags = 0
return subprocess.run(
cmd,
check=False,
capture_output=True,
text=True,
timeout=timeout,
creationflags=creationflags
)
def ensure_python_deps(progress=None):
if is_frozen():
if progress:
progress("Dependencies: skipped (frozen build)")
return
def log(s):
if progress:
progress(s)
reqs = [
("numpy", "numpy"),
("torch", "torch"),
("torchaudio", "torchaudio"),
("sounddevice", "sounddevice"),
("soundfile", "soundfile"),
("moviepy", "moviepy"),
("librosa", "librosa"),
("sklearn", "scikit-learn"),
("spacy", "spacy"),
("whisperx", "whisperx"),
("speechbrain", "speechbrain"),
]
import importlib
missing = []
for import_name, pip_name in reqs:
try:
importlib.import_module(import_name)
except Exception:
missing.append((import_name, pip_name))
if not missing:
log("Dependencies: OK")
return
log("Dependencies missing: " + ", ".join([m[1] for m in missing]))
log("Attempting pip install…")
def in_venv():
return (
getattr(sys, "base_prefix", sys.prefix) != sys.prefix
or bool(os.environ.get("VIRTUAL_ENV"))
or bool(os.environ.get("CONDA_PREFIX"))
)
py = sys.executable
use_user = not in_venv()
install_attempts = []
if use_user:
install_attempts.append(["-m", "pip", "install", "--upgrade", "--user"])
install_attempts.append(["-m", "pip", "install", "--upgrade"])
else:
install_attempts.append(["-m", "pip", "install", "--upgrade"])
install_attempts.append(["-m", "pip", "install", "--upgrade", "--user"])
last_err = None
for flags in install_attempts:
try:
for _, pip_name in missing:
cmd = [py] + flags + [pip_name]
r = _run(cmd, timeout=900)
if r.returncode != 0:
raise RuntimeError(r.stderr.strip() or r.stdout.strip() or f"pip failed: {pip_name}")
log(f"Installed: {pip_name}")
log("Dependencies install: done")
return
except Exception as e:
last_err = e
log(f"pip attempt failed ({' '.join(flags)}): {e}")
raise RuntimeError(f"Failed to install dependencies via pip: {last_err}")
def ensure_spacy_model(progress=None):
if is_frozen():
if progress:
progress("spaCy model: using frozen bundle")
return
def log(s):
if progress:
progress(s)
import spacy
try:
spacy.load("en_core_web_sm")
log("spaCy model: OK (en_core_web_sm)")
return
except Exception:
pass
log("spaCy model missing: en_core_web_sm. Downloading…")
py = sys.executable
r = _run([py, "-m", "spacy", "download", "en_core_web_sm"], timeout=900)
if r.returncode != 0:
raise RuntimeError(r.stderr.strip() or r.stdout.strip() or "spaCy download failed")
log("spaCy model download: done")
def ensure_ffmpeg(progress=None):
"""
You need ffmpeg for WMA conversion and some video audio extraction.
Best-effort install on Windows if winget/choco exists.
"""
def log(s):
if progress:
progress(s)
if shutil.which("ffmpeg"):
log("ffmpeg: OK")
return
if is_frozen():
log("ffmpeg not found in PATH. Please install ffmpeg manually.")
return
log("ffmpeg not found in PATH. Attempting install…")
if os.name == "nt":
if shutil.which("winget"):
r = _run(["winget", "install", "-e", "--id", "Gyan.FFmpeg"], timeout=900)
if r.returncode == 0 and shutil.which("ffmpeg"):
log("ffmpeg installed via winget")
return
if shutil.which("choco"):
r = _run(["choco", "install", "-y", "ffmpeg"], timeout=900)
if r.returncode == 0 and shutil.which("ffmpeg"):
log("ffmpeg installed via choco")
return
if is_frozen():
log("ffmpeg not found. Some features may not work.")
else:
raise RuntimeError(
"ffmpeg not found. Install ffmpeg and ensure it's on PATH.\n"
"Windows options: winget install -e --id Gyan.FFmpeg OR choco install ffmpeg"
)