#!/usr/bin/env python3 # -*- coding: utf-8 -*- # Self-voiced Terminal Menu ROM launcher import os import sys import time import threading import signal import curses import subprocess import speechd # Python bindings for Speech Dispatcher import configparser import pathlib import re class VoicedMenu: def __init__(self, title="Stormux Game Menu"): self.title = title self.menuSections = {} # Dictionary to hold sections and their items self.sectionNames = [] # List to maintain section order self.currentSection = 0 # Index of current section self.currentItemIndices = {} # Current item index for each section self.stdscr = None self.curses_initialized = False # Flag to track if curses has been initialized self.has_items = False # Flag to track if any section has items # Config settings self.configDir = os.path.expanduser("~/.config/stormux") self.configFile = os.path.join(self.configDir, "game_launcher.conf") self.config = configparser.ConfigParser() # Default settings self.speechRate = 0 # Normal speech rate (0 is default in speechd) # Load settings self.load_settings() # Initialize speech client self.speechClient = None self.init_speech() def init_speech(self): """Initialize the speech client""" try: # Use a fixed client ID self.speechClient = speechd.SSIPClient("rom_menu") self.speechClient.set_priority(speechd.Priority.IMPORTANT) self.speechClient.set_punctuation(speechd.PunctuationMode.SOME) # Apply speech rate from settings self.speechClient.set_rate(self.speechRate) except Exception as e: print(f"Could not initialize speech: {e}") # Fallback to None - the speak method will handle this def load_settings(self): """Load settings from config file""" # Create default settings if they don't exist if not os.path.exists(self.configFile): self.save_settings() return try: self.config.read(self.configFile) # Load speech settings if 'Speech' in self.config: self.speechRate = self.config.getint('Speech', 'rate', fallback=0) except Exception as e: print(f"Error loading settings: {e}") # If loading fails, we'll use default values def save_settings(self): """Save settings to config file""" # Ensure config directory exists os.makedirs(self.configDir, exist_ok=True) # Update config object if 'Speech' not in self.config: self.config['Speech'] = {} self.config['Speech']['rate'] = str(self.speechRate) # Write to file try: with open(self.configFile, 'w') as f: self.config.write(f) except Exception as e: print(f"Error saving settings: {e}") def increase_speech_rate(self): """Increase speech rate""" self.speechRate = min(100, self.speechRate + 10) # Max is 100 if self.speechClient: try: self.speechClient.set_rate(self.speechRate) self.speak(f"Speech rate: {self.speechRate}") except Exception as e: print(f"Error adjusting speech rate: {e}") # Save the new setting self.save_settings() def decrease_speech_rate(self): """Decrease speech rate""" self.speechRate = max(-100, self.speechRate - 10) # Min is -100 if self.speechClient: try: self.speechClient.set_rate(self.speechRate) self.speak(f"Speech rate: {self.speechRate}") except Exception as e: print(f"Error adjusting speech rate: {e}") # Save the new setting self.save_settings() def add_section(self, sectionName): """Add a new section to the menu""" if sectionName not in self.menuSections: self.menuSections[sectionName] = [] self.sectionNames.append(sectionName) self.currentItemIndices[sectionName] = 0 def add_item(self, sectionName, name, command): """Add a menu item to a specific section""" # Create section if it doesn't exist if sectionName not in self.menuSections: self.add_section(sectionName) self.menuSections[sectionName].append((name, command)) self.has_items = True # Mark that we have at least one item def speak(self, text, interrupt=True): """Speak the given text with option to interrupt existing speech""" if self.speechClient is None: return try: if interrupt: self.stop_speech() self.speechClient.speak(text) except Exception as e: # If speech fails, try to reinitialize and try once more try: self.init_speech() if self.speechClient: self.speechClient.speak(text) except: # If reinitializing fails, just give up silently pass def stop_speech(self): """Stop any ongoing speech""" if self.speechClient is None: return try: self.speechClient.cancel() except Exception as e: # If cancel fails, try to reinitialize self.init_speech() def get_current_items(self): """Get items from the current section""" if not self.sectionNames: return [] currentSectionName = self.sectionNames[self.currentSection] return self.menuSections[currentSectionName] def get_current_item_index(self): """Get the current item index in the current section""" if not self.sectionNames: return 0 currentSectionName = self.sectionNames[self.currentSection] return self.currentItemIndices[currentSectionName] def set_current_item_index(self, index): """Set the current item index for the current section""" if not self.sectionNames: return currentSectionName = self.sectionNames[self.currentSection] self.currentItemIndices[currentSectionName] = index def announce_current_section(self, interrupt=True): """Announce the currently selected section""" if 0 <= self.currentSection < len(self.sectionNames): sectionName = self.sectionNames[self.currentSection] self.speak(sectionName, interrupt=interrupt) def announce_current_item(self, interrupt=True): """Announce the currently selected menu item""" if len(self.sectionNames) > 0: items = self.get_current_items() index = self.get_current_item_index() if 0 <= index < len(items): name = items[index][0] self.speak(name, interrupt=interrupt) def execute_current_item(self): """Execute the currently selected menu item""" if len(self.sectionNames) > 0: items = self.get_current_items() index = self.get_current_item_index() if 0 <= index < len(items): name, command = items[index] # Clean up resources before executing the command self.cleanup(full_cleanup=True) # This handles curses properly # Execute the command and exit os.system(command) sys.exit(0) # Now safe to exit def speak_help(self): """Speak help information""" helpText = """ Navigation controls: Up arrow: Previous menu item. Down arrow: Next menu item. Left arrow: Previous section. Right arrow: Next section. Enter: Launch selected item. H key: Hear these instructions again. Left bracket: Decrease speech rate. Right bracket: Increase speech rate. Escape or Q: Exit the menu. Any key will interrupt speech. """ self.speak(helpText) def draw_menu(self): """Draw the menu on the screen""" self.stdscr.clear() h, w = self.stdscr.getmaxyx() # Draw title title = f" {self.title} " x = max(0, w // 2 - len(title) // 2) self.stdscr.addstr(1, x, title, curses.A_BOLD) # Draw help line helpText = "Ãavigate | Enter: Select | H: Help | [ ] Rate | Q/Esc: Quit" x = max(0, w // 2 - len(helpText) // 2) self.stdscr.addstr(3, x, helpText) # Draw current section if len(self.sectionNames) > 0: currentSectionName = self.sectionNames[self.currentSection] sectionText = f"== {currentSectionName} ==" x = max(0, w // 2 - len(sectionText) // 2) self.stdscr.addstr(5, x, sectionText, curses.A_BOLD) # Draw menu items for current section items = self.get_current_items() currentItemIndex = self.get_current_item_index() if not items: # Display a message if the section is empty emptyMsg = "No items in this section" x = max(0, w // 2 - len(emptyMsg) // 2) self.stdscr.addstr(7, x, emptyMsg, curses.A_DIM) else: for i, (name, _) in enumerate(items): y = i + 7 # Start items 2 lines below section header if y < h - 1: # Ensure we don't draw outside the window # Highlight the selected item if i == currentItemIndex: text = f" > {name} " attr = curses.A_REVERSE else: text = f" {name} " attr = curses.A_NORMAL x = max(0, w // 2 - len(text) // 2) self.stdscr.addstr(y, x, text, attr) # Draw speech rate indicator rateText = f"Speech Rate: {self.speechRate}" self.stdscr.addstr(h-2, 2, rateText) self.stdscr.refresh() def cleanup(self, full_cleanup=False): """Clean up resources before exiting or executing a command Args: full_cleanup: If True, also close curses. Used when exiting or running a command. """ # Stop any speech self.stop_speech() # Close speech client if self.speechClient: try: self.speechClient.close() except: pass self.speechClient = None # Restore terminal settings if curses was initialized if full_cleanup and self.curses_initialized: try: curses.nocbreak() self.stdscr.keypad(False) curses.echo() curses.endwin() except: # If there's an error, just try a simple endwin try: curses.endwin() except: pass # Last resort, just continue def load_roms_from_directory(self, directoryPath): """Load ROMs from the specified directory and add them to the menu""" # Expand the path (in case it contains ~) directoryPath = os.path.expanduser(directoryPath) # Check if directory exists if not os.path.exists(directoryPath) or not os.path.isdir(directoryPath): print(f"Directory {directoryPath} does not exist or is not a directory") return # Get all subdirectories in the roms directory try: subdirs = [d for d in os.listdir(directoryPath) if os.path.isdir(os.path.join(directoryPath, d))] # For each subdirectory, create a section for subdir in subdirs: sectionPath = os.path.join(directoryPath, subdir) # Get all files in the subdirectory files = [f for f in os.listdir(sectionPath) if os.path.isfile(os.path.join(sectionPath, f))] # If the directory has files, add it as a section if files: # Add the section self.add_section(subdir) # Add files as menu items for file in files: # Get full path to the file filePath = os.path.join(sectionPath, file) # Create display name - remove extension displayName = os.path.splitext(file)[0] # Replace underscores with spaces for better readability displayName = displayName.replace('_', ' ') # Properly escape special characters in file path escapedPath = filePath.replace('"', '\\"') # Add the item to the section - use double quotes for the GAME variable self.add_item(subdir, displayName, f'export GAME="{escapedPath}" && startx') except Exception as e: print(f"Error loading ROMs directory: {e}") def run(self): """Run the menu system""" # Check if menu is completely empty if not self.sectionNames: message = "No games found." print(message) # Speak the message self.init_speech() # Make sure speech is initialized if self.speechClient: self.speak(message) # Wait for speech to finish (rough estimate) time.sleep(3) # Clean up and exit properly self.cleanup(full_cleanup=True) sys.exit(0) # Check if any sections have items if not self.has_items: message = "No ROMs found in any sections. Exiting." print(message) # Speak the message self.init_speech() # Make sure speech is initialized if self.speechClient: self.speak(message) # Wait for speech to finish (rough estimate) time.sleep(3) # Clean up and exit properly self.cleanup(full_cleanup=True) sys.exit(0) try: # Initialize curses self.stdscr = curses.initscr() self.curses_initialized = True # Mark curses as initialized curses.noecho() # Don't echo keypresses curses.cbreak() # React to keys instantly self.stdscr.keypad(True) # Enable special keys # Initial draw self.draw_menu() # Welcome message - don't interrupt this initial speech self.speak("Roms menu") # Wait for initial speech to finish before announcing section time.sleep(1) # Announce initial section and item without interrupting welcome speech self.announce_current_section(interrupt=False) time.sleep(0.5) # Wait before announcing first item self.announce_current_item(interrupt=False) # Main loop while True: key = self.stdscr.getch() # Stop any speech when a key is pressed self.stop_speech() # Handle navigation if key == curses.KEY_UP: # Move to previous item in current section items = self.get_current_items() if items: index = self.get_current_item_index() self.set_current_item_index((index - 1) % len(items)) self.draw_menu() self.announce_current_item() elif key == curses.KEY_DOWN: # Move to next item in current section items = self.get_current_items() if items: index = self.get_current_item_index() self.set_current_item_index((index + 1) % len(items)) self.draw_menu() self.announce_current_item() elif key == curses.KEY_LEFT: # Move to previous section if self.sectionNames: self.currentSection = (self.currentSection - 1) % len(self.sectionNames) self.draw_menu() # Announce section and current item without interruption between them self.announce_current_section() time.sleep(0.5) # Brief pause between section and item announcement self.announce_current_item(interrupt=False) # Don't interrupt the section announcement elif key == curses.KEY_RIGHT: # Move to next section if self.sectionNames: self.currentSection = (self.currentSection + 1) % len(self.sectionNames) self.draw_menu() # Announce section and current item without interruption between them self.announce_current_section() time.sleep(0.5) # Brief pause between section and item announcement self.announce_current_item(interrupt=False) # Don't interrupt the section announcement elif key == curses.KEY_ENTER or key == 10 or key == 13: # Enter key items = self.get_current_items() if items: # Only execute if there are items self.execute_current_item() # This now handles cleanup and exit elif key == ord('h') or key == ord('H'): # Help self.speak_help() elif key == ord('['): # Decrease speech rate self.decrease_speech_rate() self.draw_menu() elif key == ord(']'): # Increase speech rate self.increase_speech_rate() self.draw_menu() elif key == 27 or key == ord('q') or key == ord('Q'): # Esc or Q break except Exception as e: # End curses in case of error if self.curses_initialized: try: curses.endwin() except: pass print(f"An error occurred: {e}") finally: # Clean up - safe to call even if curses wasn't initialized self.cleanup(full_cleanup=True) # Example usage if __name__ == "__main__": # Create the menu with sections menu = VoicedMenu(title="") # Load ROMs from the ~/Roms directory menu.load_roms_from_directory("~/Roms") # Run the menu menu.run()