From 5d6f678e77322311d246248110188ef9ad9951bf Mon Sep 17 00:00:00 2001 From: Space-Banane <64922620+Space-Banane@users.noreply.github.com> Date: Fri, 16 Jan 2026 21:50:47 +0100 Subject: [PATCH] first commit --- .gitignore | 2 + LICENSE | 21 ++ README.md | 52 ++++ electron-main.js | 58 ++++ main.py | 765 +++++++++++++++++++++++++++++++++++++++++++++++ renderer.html | 168 +++++++++++ renderer.js | 247 +++++++++++++++ requirements.txt | 7 + 8 files changed, 1320 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 electron-main.js create mode 100644 main.py create mode 100644 renderer.html create mode 100644 renderer.js create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..014c313 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +replays +temp_mic_audio.wav \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1366950 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Space-Banane + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..983fd3e --- /dev/null +++ b/README.md @@ -0,0 +1,52 @@ +# Instant Replay (With OBS) +This funny project allows you to have a instant replay from the last X seconds by just saying "replay that". + +## Why +Why not? Try it out. It's fun. + +Did not see how you died in that round? Just say "replay that" and see the last 30 seconds of your gameplay in a little overlay box that does not disturb your game. + +## Start the thing + +1. Clone this repository. +2. Install Electron globally (if you don't have it): + ```sh + npm i -g electron + ``` +3. Open multiple terminals and run the following commands: + 1. **Terminal 1:** Start the Python server that listens for commands and communicates with OBS & the Electron app: + ```sh + python main.py + ``` + 2. **Terminal 2:** Start the Electron overlay window: + ```sh + npx electron electron-main.js + ``` + 3. **Terminal 3:** Serve the replays folder on port 8000: + ```sh + npx serve ./replays -p 8000 + ``` + +## Usage +1. Make sure OBS is running with a scene that has your game or desktop. +2. Make sure the everything is running. +3. Make sure you have the WebSocket server configured correctly, check the code or change the port in OBS to 4455 and disable authentication. +4. Make sure the Replay Buffer is enabled in OBS and set to the same length as in `main.py` (default 30 seconds), else ffmpeg will clip to the duration set in the `main.py` script. +5. Say "replay that" (you can change the hotword in `main.py`) to save the last X seconds of gameplay. + +## Other commands +- **Activation:** Say "instant replay" to save the last X seconds. +- **Stop:** Say "stop replay" to hide the replay overlay. +- **Replay Last:** Say "replay last" to show the most recent replay. +- **Clear Replays:** Say "clear replays" to delete all saved clips. + +## Requirements +- OBS Studio with a working Websocket Server +- Python3 with the required packages (see `requirements.txt`) +- NodeJS with Electron installed globally +- A microphone for hotword detection + +## Thank You +You're done reading. Yay. Thanks for trying if you did. Enjoy! + +Mit license and all of that. \ No newline at end of file diff --git a/electron-main.js b/electron-main.js new file mode 100644 index 0000000..81483d7 --- /dev/null +++ b/electron-main.js @@ -0,0 +1,58 @@ +const { app, BrowserWindow, ipcMain } = require('electron'); +const path = require('path'); + +function createWindow() { + const mainWindow = new BrowserWindow({ + webPreferences: { + nodeIntegration: true, + contextIsolation: false, + enableRemoteModule: true + }, + width: 1920, + height: 1080, + transparent: true, + frame: false, + alwaysOnTop: true, + skipTaskbar: false, + autoHideMenuBar: true, + title: 'Instant Replay Viewer', + fullscreenable: true, + focusable: false // Prevent stealing focus from games + }); + + mainWindow.loadFile('renderer.html'); + + mainWindow.webContents.on('did-finish-load', () => { + console.log('Electron renderer loaded and connected.'); + // mainWindow.webContents.openDevTools(); // Always open DevTools for debugging + }); + + // Listen for log messages from renderer + ipcMain.on('ui-log', (event, { level, args }) => { + if (Array.isArray(args)) { + console[level]('[UI]', ...args); + } else { + console[level]('[UI]', args); + } + }); + + // Make window click-through by default + mainWindow.setIgnoreMouseEvents(true, { forward: true }); + + // Ensure window stays above fullscreen games (like Valorant) + mainWindow.setAlwaysOnTop(true, 'screen-saver'); +} + +app.whenReady().then(createWindow); + +app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { + app.quit(); + } +}); + +app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) { + createWindow(); + } +}); diff --git a/main.py b/main.py new file mode 100644 index 0000000..125d76c --- /dev/null +++ b/main.py @@ -0,0 +1,765 @@ +# Imports +import os +import time +import threading +import shutil +from obswebsocket import obsws +from obswebsocket import requests as rq +import websockets +import asyncio +import json +import ctypes +import subprocess +from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler + +# Import live display handler (user can customize livedisplay.py) +try: + from livedisplay import handle_update as live_display_handler + LIVE_DISPLAY_AVAILABLE = True +except ImportError: + print("Warning: livedisplay.py not found. Live display feature disabled.") + LIVE_DISPLAY_AVAILABLE = False + live_display_handler = None + +# Try to import psutil early for better performance +try: + import psutil + PSUTIL_AVAILABLE = True +except ImportError: + PSUTIL_AVAILABLE = False + psutil = None + + +def is_obs_running(): + """Check if OBS Studio is running.""" + if not PSUTIL_AVAILABLE: + return True # Assume running if cannot check + + try: + for proc in psutil.process_iter(["name"]): + if proc.info["name"] and "obs64.exe" in proc.info["name"].lower(): + return True + return False + except Exception: + return True # Assume running if check fails + + +def set_process_priority(): + """Elevate process priority on Windows to reduce missed events under heavy load.""" + if os.name != "nt": + return + + try: + # Try using psutil when available for clarity. + import psutil # type: ignore + + process = psutil.Process(os.getpid()) + process.nice(psutil.HIGH_PRIORITY_CLASS) + print("Process priority elevated to HIGH_PRIORITY_CLASS") + return + except ImportError: + print("psutil not installed; falling back to ctypes priority raise") + except Exception as exc: + print(f"Failed to elevate priority with psutil: {exc}; falling back to ctypes") + + try: + kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined] + handle = kernel32.GetCurrentProcess() + HIGH_PRIORITY_CLASS = 0x00000080 + if not kernel32.SetPriorityClass(handle, HIGH_PRIORITY_CLASS): + raise OSError("SetPriorityClass returned 0") + print("Process priority elevated via ctypes SetPriorityClass") + except Exception as exc: + print(f"Unable to adjust process priority: {exc}") + + +# Directories +WATCH_DIR = r"E:\OBS" +MOVETO_DIR = os.path.abspath("./replays") + +# Server Ports +SERVER_PORT = 8000 +WEBSOCKET_PORT = 8001 + +# Replay Settings +REPLAY_LENGTH_SECONDS = 20 +DEFAULT_LOCATION = "center" # options: 'top_left', 'bottom_left', 'top_right', 'center' +DEFAULT_SIZE = 5 # 1 (smallest) to 10 (largest) + +# Voice Recognition Settings +VOICE_ENABLED = False +SAMPLE_RATE = 16000 +DURATION = 4 # seconds to record per command attempt +CALIBRATION_DURATION = 2 # seconds for ambient calibration + +# Live Display Settings +LIVE_DISPLAY_ENABLED = False # Enable periodic status updates during replay +LIVE_DISPLAY_INTERVAL = 0.5 # Seconds between each update (e.g., 0.1 = 10 updates per second) + +# Commands +ACTIVATION_COMMANDS = ["instant replay", "save replay", "capture replay", "replay that"] +STOP_COMMANDS = ["stop replay", "stop video", "hide replay"] +REPLAY_LAST_COMMANDS = [ + "replay last", + "show last replay", + "play last", + "replay it again", +] +CLEAR_REPLAYS_COMMANDS = [ + "clear replays", + "delete all replays", + "nuke replays", + "remove all clips", +] + +# Hotkey Settings +HOTKEY = { + "enabled": True, + "key": "pagedown", # Change to your preferred key +} + +# Replay state tracking +is_replaying = threading.Event() + +last_replay_filename = None +active_replay_threads = {} # Track active live display threads by filename + +# Ensure the move-to directory exists +os.makedirs(MOVETO_DIR, exist_ok=True) + +# Check if OBS is running before connecting +if not is_obs_running(): + print("ERROR: OBS Studio is not running. Please start OBS and try again.") + exit(1) + +# Setup OBS +ws = obsws("localhost", 4455, "") +ws.connect() + +# Global WebSocket connections +websocket_clients = set() + + +async def websocket_handler(websocket, path): + """Handle WebSocket connections from Electron app""" + websocket_clients.add(websocket) + print(f"Electron client connected. Total clients: {len(websocket_clients)}") + print("Electron WebSocket connection established!") + try: + await websocket.wait_closed() + finally: + websocket_clients.remove(websocket) + print(f"Electron client disconnected. Total clients: {len(websocket_clients)}") + + +async def send_video_url(filename, location=DEFAULT_LOCATION, size=DEFAULT_SIZE, duration=None): + """Send video URL and display settings to connected Electron clients""" + if websocket_clients: + video_url = f"http://localhost:{SERVER_PORT}/{filename}" + message = json.dumps( + { + "type": "play_video", + "url": video_url, + "filename": filename, + "location": location, + "size": size, + "duration": duration, # provided by backend + } + ) + + # Send to all connected clients + disconnected = set() + for client in websocket_clients: + try: + await client.send(message) + print(f"Sent video URL to Electron: {video_url} (duration={duration})") + except websockets.exceptions.ConnectionClosed: + disconnected.add(client) + + # Clean up disconnected clients + websocket_clients.difference_update(disconnected) + + +async def send_stop_command(): + """Send stop command to connected Electron clients""" + # Stop all active live display threads + for stop_event in list(active_replay_threads.values()): + stop_event.set() + + if websocket_clients: + message = json.dumps({"type": "stop_video"}) + + # Send to all connected clients + disconnected = set() + for client in websocket_clients: + try: + await client.send(message) + print("Sent stop command to Electron") + except websockets.exceptions.ConnectionClosed: + disconnected.add(client) + + # Clean up disconnected clients + websocket_clients.difference_update(disconnected) + + +async def send_status_update(status): + """Send status update to Electron clients for HUD loading screen.""" + if websocket_clients: + message = json.dumps({"type": "status_update", "status": status}) + disconnected = set() + for client in websocket_clients: + try: + await client.send(message) + except websockets.exceptions.ConnectionClosed: + disconnected.add(client) + websocket_clients.difference_update(disconnected) + + +def get_media_duration(filepath): + """Return media duration in seconds (float) using ffprobe, or None on failure.""" + try: + result = subprocess.run( + [ + "ffprobe", + "-v", "error", + "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", + filepath, + ], + capture_output=True, + text=True, + check=True, + ) + dur = float(result.stdout.strip()) + return round(dur, 3) + except Exception as e: + print(f"Error getting duration for {filepath}: {e}") + return None + + +def live_display_update_thread(filename, duration): + """Thread that periodically calls the live display handler with current replay state.""" + if not LIVE_DISPLAY_ENABLED or not LIVE_DISPLAY_AVAILABLE or live_display_handler is None: + return + + start_time = time.time() + stop_event = active_replay_threads.get(filename) + next_update_time = start_time # Track when next update should happen + + try: + while not stop_event.is_set(): + current_time = time.time() + elapsed = current_time - start_time + + # Stop if we've exceeded the duration + if duration and elapsed >= duration: + break + + # Only send update if we've reached the next scheduled update time + if current_time >= next_update_time: + # Call the handler with current state + try: + live_display_handler("playing", elapsed) + except Exception as handler_error: + # Don't let handler errors stop the update loop + print(f"Error in live_display_handler: {handler_error}") + + # Schedule next update at a fixed interval from start, not from now + # This prevents drift from slow handler execution + next_update_time += LIVE_DISPLAY_INTERVAL + + # If we're falling behind, catch up to current time + if next_update_time < current_time: + next_update_time = current_time + LIVE_DISPLAY_INTERVAL + + # Sleep for a short time or until stopped (use smaller sleep to reduce latency) + sleep_time = min(0.01, max(0.001, next_update_time - time.time())) + if sleep_time > 0: + stop_event.wait(timeout=sleep_time) + + # Send final "stopped" update + final_timestamp = time.time() - start_time + try: + live_display_handler("stopped", final_timestamp) + except Exception as handler_error: + print(f"Error in live_display_handler (stopped): {handler_error}") + + except Exception as e: + print(f"Error in live display update thread: {e}") + finally: + # Clean up the thread tracking + if filename in active_replay_threads: + del active_replay_threads[filename] + + +def show_replay(filename, location=DEFAULT_LOCATION, size=DEFAULT_SIZE): + global last_replay_filename + filepath = os.path.join(MOVETO_DIR, filename) + if os.path.exists(filepath): + # Ensure file is ready before sending to Electron (faster check) + if not wait_for_file_ready(filepath, timeout=5, poll_interval=0.1): + print(f"Replay file not ready for playback: {filepath}") + log_file_locks(filepath) + return + # Compute duration once, send to Electron + duration = get_media_duration(filepath) + + # Prepare live display BEFORE sending video to Electron for perfect sync + if LIVE_DISPLAY_ENABLED and LIVE_DISPLAY_AVAILABLE: + # Stop any existing live display thread for this filename + if filename in active_replay_threads: + active_replay_threads[filename].set() + + # Create a new stop event for this replay + stop_event = threading.Event() + active_replay_threads[filename] = stop_event + + # Send initial "playing" state at timestamp 0 IMMEDIATELY + try: + live_display_handler("playing", 0.0) + except Exception as handler_error: + print(f"Error in initial live_display_handler call: {handler_error}") + + # Start the live display update thread + threading.Thread( + target=live_display_update_thread, + args=(filename, duration), + daemon=True + ).start() + + # Now send video to Electron (live display already started) + asyncio.run_coroutine_threadsafe( + send_video_url(filename, location, size, duration), websocket_loop + ) + last_replay_filename = filename + print(f"Sent replay to Electron: {filepath} (location={location}, size={size}, duration={duration})") + is_replaying.set() + + threading.Thread(target=auto_stop_replay, args=(REPLAY_LENGTH_SECONDS,), daemon=True).start() + else: + print(f"Replay file not found: {filepath}") + +def auto_stop_replay(duration): + """Automatically stop replay after duration if not stopped manually.""" + time.sleep(duration) + # Only auto-stop if replaying is still active (not stopped manually) + if is_replaying.is_set(): + print(f"Auto-stopping replay after {duration} seconds.") + # Stop all active live display threads + for stop_event in list(active_replay_threads.values()): + stop_event.set() + asyncio.run_coroutine_threadsafe(send_stop_command(), websocket_loop) + is_replaying.clear() + +def delete_old_files(directory, max_age_seconds=3 * 60 * 60): + """Delete files older than max_age_seconds in the given directory.""" + now = time.time() + for filename in os.listdir(directory): + filepath = os.path.join(directory, filename) + if os.path.isfile(filepath): + file_age = now - os.path.getmtime(filepath) + if file_age > max_age_seconds: + try: + os.remove(filepath) + print(f"Deleted old replay: {filepath}") + except Exception as e: + print(f"Error deleting {filepath}: {e}") + + +def clear_all_replays(directory): + """Delete all files in the given directory.""" + for filename in os.listdir(directory): + filepath = os.path.join(directory, filename) + if os.path.isfile(filepath): + try: + os.remove(filepath) + print(f"Deleted replay: {filepath}") + except Exception as e: + print(f"Error deleting {filepath}: {e}") + + +def trim_replay_with_ffmpeg(filepath, length_seconds): + """Trim the replay file to the specified length using ffmpeg, only if needed.""" + import subprocess + + # Get video duration using ffprobe (single call) + try: + result = subprocess.run( + [ + "ffprobe", + "-v", + "error", + "-show_entries", + "format=duration", + "-of", + "default=noprint_wrappers=1:nokey=1", + filepath, + ], + capture_output=True, + text=True, + check=True, + ) + duration = float(result.stdout.strip()) + except Exception as e: + print(f"Error getting duration for {filepath}: {e}") + return # Exit early if we can't get duration + + if duration <= length_seconds + 0.1: + print( + f"Skipping trim: video duration ({duration:.2f}s) <= target ({length_seconds}s)" + ) + return + + temp_filepath = filepath + ".trimmed.mp4" + try: + # Calculate start time to keep the last X seconds + start_time = max(0, duration - length_seconds) + # ffmpeg command: cut from start_time to end + subprocess.run( + [ + "ffmpeg", + "-y", + "-loglevel", + "error", + "-ss", + str(start_time), + "-i", + filepath, + "-t", + str(length_seconds), + "-c", + "copy", + "-movflags", + "+faststart", + temp_filepath, + ], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + # Replace original file with trimmed file + os.replace(temp_filepath, filepath) + print(f"Trimmed replay to last {length_seconds}s: {filepath}") + except Exception as e: + print(f"Error trimming replay {filepath}: {e}") + + +def log_file_locks(filepath): + """Log processes that are using the given file (Windows only).""" + if not PSUTIL_AVAILABLE: + print("psutil not available; cannot check file locks.") + return + + try: + locked_by = [] + for proc in psutil.process_iter(["pid", "name"]): + try: + for item in proc.open_files(): + if os.path.normcase(item.path) == os.path.normcase(filepath): + locked_by.append(f"PID {proc.pid} ({proc.name()})") + except (psutil.NoSuchProcess, psutil.AccessDenied): + continue + if locked_by: + print(f"File '{filepath}' is locked by: {', '.join(locked_by)}") + else: + print(f"File '{filepath}' is not locked by any process.") + except Exception as e: + print(f"Error checking file locks: {e}") + + +def wait_for_file_ready(filepath, timeout=10, poll_interval=0.1): + """Wait for a file to stop growing and release any locks.""" + deadline = time.time() + timeout + last_size = -1 + stable_reads = 0 + + while time.time() < deadline: + try: + current_size = os.path.getsize(filepath) + with open(filepath, "rb"): + pass + except (FileNotFoundError, PermissionError, OSError): + time.sleep(poll_interval) + continue + + if current_size == last_size: + stable_reads += 1 + if stable_reads >= 2: + return True + else: + last_size = current_size + stable_reads = 0 + + time.sleep(poll_interval) + + print(f"Timed out waiting for file to be ready: {filepath}") + return False + + +def monitor_directory(path): + print(f"Monitoring directory: {path}") + already_seen = set(os.listdir(path)) + cleanup_counter = 0 + + while True: + time.sleep(0.5) # Check every 0.5 seconds for faster detection + current_files = set(os.listdir(path)) + new_files = current_files - already_seen + + # Delete old files in MOVETO_DIR only every 60 cycles (30 seconds) + cleanup_counter += 1 + if cleanup_counter >= 60: + delete_old_files(MOVETO_DIR) + cleanup_counter = 0 + + for new_file in new_files: + print(f"New file detected: {new_file}") + source_path = os.path.join(path, new_file) + dest_path = os.path.join(MOVETO_DIR, new_file) + + try: + # Status: waiting for file + asyncio.run_coroutine_threadsafe( + send_status_update("Waiting for file"), websocket_loop + ) + if not wait_for_file_ready(source_path): + print(f"Skipping file that never became accessible: {source_path}") + continue + + # Status: moving file + asyncio.run_coroutine_threadsafe( + send_status_update("Moving file"), websocket_loop + ) + shutil.move(source_path, dest_path) + print(f"Moved file to: {dest_path}") + + # Status: waiting for moved file + asyncio.run_coroutine_threadsafe( + send_status_update("Waiting for moved file"), websocket_loop + ) + if not wait_for_file_ready(dest_path): + print( + f"Skipping replay because destination file stayed locked: {dest_path}" + ) + continue + + # Status: cutting replay + asyncio.run_coroutine_threadsafe( + send_status_update("Cutting replay"), websocket_loop + ) + trim_replay_with_ffmpeg(dest_path, REPLAY_LENGTH_SECONDS) + + # Status: finalizing + asyncio.run_coroutine_threadsafe( + send_status_update("Finalizing"), websocket_loop + ) + time.sleep(0.5) + + # Status: ready to play + asyncio.run_coroutine_threadsafe( + send_status_update("Ready to play"), websocket_loop + ) + + # trigger playback + show_replay(new_file) + except PermissionError as e: + print(f"PermissionError moving file '{source_path}': {e}") + log_file_locks(source_path) + except Exception as e: + print(f"Error moving file '{source_path}': {e}") + + already_seen = current_files + + +def capture_replay(): + """Trigger OBS to save a replay buffer""" + try: + ws.call(rq.SaveReplayBuffer()) + print("Replay buffer saved.") + asyncio.run_coroutine_threadsafe( + send_status_update("Replay saved"), websocket_loop + ) + except Exception as e: + print(f"Error saving replay buffer: {e}") + + +def start_mic_listener(): + """Listen for microphone input to trigger replay capture using sounddevice and whisper, with volume threshold""" + import sounddevice as sd + import numpy as np + import whisper + import scipy.io.wavfile + import concurrent.futures + + whisper_model = whisper.load_model( + "base.en" + ) # You can choose "tiny", "base", "small", etc. + + print( + "Microphone listener started. Say one of the activation, stop, or replay last commands." + ) + print(f"Activation commands: {ACTIVATION_COMMANDS}") + print(f"Stop commands: {STOP_COMMANDS}") + print(f"Replay last commands: {REPLAY_LAST_COMMANDS}") + print(f"Clear replays commands: {CLEAR_REPLAYS_COMMANDS}") + + # Calibration phase + print("Calibrating ambient volume, please be quiet...") + ambient_audio = sd.rec( + int(CALIBRATION_DURATION * SAMPLE_RATE), + samplerate=SAMPLE_RATE, + channels=1, + dtype="int16", + ) + sd.wait() + ambient_rms = np.sqrt(np.mean(np.square(ambient_audio.astype(np.float32)))) + threshold = ambient_rms * 4 # You can adjust multiplier for sensitivity + print(f"Ambient RMS: {ambient_rms:.2f}, threshold set to: {threshold:.2f}") + + while True: + print("Listening for command...") + audio = sd.rec( + int(DURATION * SAMPLE_RATE), + samplerate=SAMPLE_RATE, + channels=1, + dtype="int16", + ) + sd.wait() + rms = np.sqrt(np.mean(np.square(audio.astype(np.float32)))) + print(f"Recorded RMS: {rms:.2f}") + + if rms < threshold: + print("No speech detected (below threshold).") + continue + + wav_path = "temp_mic_audio.wav" + scipy.io.wavfile.write(wav_path, SAMPLE_RATE, audio) + + try: + print("Transcribing audio with Whisper...") + # Transcribe using Whisper with 5 second timeout + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit(whisper_model.transcribe, wav_path) + result = future.result(timeout=5) + command = result["text"].lower().strip() + print(f"Heard (Whisper): {command}") + + if any(phrase in command for phrase in ACTIVATION_COMMANDS): + capture_replay() + elif any(phrase in command for phrase in STOP_COMMANDS): + asyncio.run_coroutine_threadsafe(send_stop_command(), websocket_loop) + print("Stop replay command triggered") + is_replaying.clear() # Ensure replay state is cleared on manual stop + elif any(phrase in command for phrase in REPLAY_LAST_COMMANDS): + if last_replay_filename: + show_replay(last_replay_filename) + print(f"Replaying last replay: {last_replay_filename}") + else: + print("No replay available to replay last.") + elif any(phrase in command for phrase in CLEAR_REPLAYS_COMMANDS): + clear_all_replays(MOVETO_DIR) + print("All replays cleared.") + except concurrent.futures.TimeoutError: + print("Whisper transcription timed out (5 seconds).") + except Exception as e: + print(f"Whisper transcription error: {e}") + + +def start_websocket_server(): + """Start WebSocket server for Electron communication""" + global websocket_loop + websocket_loop = asyncio.new_event_loop() + asyncio.set_event_loop(websocket_loop) + + start_server = websockets.serve(websocket_handler, "localhost", WEBSOCKET_PORT) + print(f"WebSocket server started on ws://localhost:{WEBSOCKET_PORT}") + + websocket_loop.run_until_complete(start_server) + websocket_loop.run_forever() + + +def handle_command(command): + """Process a command string as if it was spoken.""" + global is_replaying + command = command.lower().strip() + if any(phrase in command for phrase in ACTIVATION_COMMANDS): + capture_replay() + is_replaying.set() + elif any(phrase in command for phrase in STOP_COMMANDS): + asyncio.run_coroutine_threadsafe(send_stop_command(), websocket_loop) + print("Stop replay command triggered") + is_replaying.clear() # Ensure replay state is cleared on manual stop + elif any(phrase in command for phrase in REPLAY_LAST_COMMANDS): + if last_replay_filename: + show_replay(last_replay_filename) + print(f"Replaying last replay: {last_replay_filename}") + is_replaying.set() + else: + print("No replay available to replay last.") + elif any(phrase in command for phrase in CLEAR_REPLAYS_COMMANDS): + clear_all_replays(MOVETO_DIR) + print("All replays cleared.") + + +def start_hotkey_listener(): + """Listen for hotkey globally and toggle replay/abort.""" + try: + import keyboard + except ImportError: + print("keyboard library not installed; hotkey feature disabled.") + return + + print(f"Global hotkey listener enabled. Press '{HOTKEY['key']}' anywhere to toggle replay.") + while True: + keyboard.wait(HOTKEY["key"]) + if is_replaying.is_set(): + # Always send stop command and clear state, even if already expired + print(f"Global hotkey '{HOTKEY['key']}' pressed! Aborting replay.") + asyncio.run_coroutine_threadsafe(send_stop_command(), websocket_loop) + is_replaying.clear() # Ensure replay state is cleared on manual stop + else: + print(f"Global hotkey '{HOTKEY['key']}' pressed! Simulating 'instant replay' command.") + handle_command("instant replay") + time.sleep(0.5) # Prevent rapid retrigger + + + +def main(): + set_process_priority() + + # Check if at least one input method is enabled + if not VOICE_ENABLED and not HOTKEY["enabled"]: + print( + "ERROR: Both voice recognition and hotkey are disabled. Enable at least one input method." + ) + exit(1) + + # Start WebSocket server in a separate thread + websocket_thread = threading.Thread(target=start_websocket_server, daemon=True) + websocket_thread.start() + + # Start monitoring directory (create another thread) + monitor_thread = threading.Thread( + target=monitor_directory, args=(WATCH_DIR,), daemon=True + ) + monitor_thread.start() + + # Start microphone listener in a separate thread if enabled + if VOICE_ENABLED: + mic_thread = threading.Thread(target=start_mic_listener, daemon=True) + mic_thread.start() + + # Start hotkey listener if enabled + if HOTKEY["enabled"]: + hotkey_thread = threading.Thread(target=start_hotkey_listener, daemon=True) + hotkey_thread.start() + + # Keep main thread alive, and kill child processes on exit + try: + while True: + time.sleep(1) + except KeyboardInterrupt: + print("\nShutting down...") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/renderer.html b/renderer.html new file mode 100644 index 0000000..ed4d7bc --- /dev/null +++ b/renderer.html @@ -0,0 +1,168 @@ + + +
+ +