#!/usr/bin/env python3 # -*- coding: utf-8 -*- # Self-voiced Terminal Menu System for Stormux game image import os import platform 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 import simpleaudio as sa import json 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 # Check if system is installed or running from USB self.is_installed = os.path.exists("/home/stormux/.baremetal") # System services dictionary - maps friendly names to service names self.systemMenuServices = { 'Braille': 'brltty.path', 'D L N A Server': 'minidlna.service', 'Fenrir Screen Reader': 'fenrirscreenreader-tty.service', 'Bluetooth': 'bluetooth.service', 'SSH': 'sshd.service', 'Battery Monitoring': 'battery-monitor.service' } # 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) self.speechPitch = 0 # Normal speech pitch self.volume = 50 # Default volume level # Load settings self.load_settings() # Initialize speech client self.speechClient = None self.init_speech() # Track playing sound self.currentSound = None # Load downloadable games registry self.downloadable_games = self.load_downloadable_games() def load_downloadable_games(self): """Load downloadable games registry from JSON file""" registry_path = "/usr/share/stormux/downloadable_games.json" try: if os.path.exists(registry_path): with open(registry_path, 'r') as f: data = json.load(f) return data.get('downloadable_games', {}) return {} except Exception as e: print(f"Error loading downloadable games registry: {e}") return {} def is_game_installed(self, game_id): """Check if a downloadable game is installed""" if game_id not in self.downloadable_games: return True # Not a downloadable game, assume installed game_info = self.downloadable_games[game_id] game_dir = os.path.expanduser(f"~/.local/games/{game_info['directory']}") return os.path.exists(game_dir) and os.path.exists(os.path.join(game_dir, game_info['executable'])) def get_display_name(self, game_name, game_id=None): """Get display name for menu item, adding (not installed) if needed""" if game_id and game_id in self.downloadable_games and not self.is_game_installed(game_id): return f"{game_name} (not installed)" return game_name def init_speech(self): """Initialize the speech client""" try: self.speechClient = speechd.SSIPClient('stormux_menu') self.speechClient.set_priority(speechd.Priority.IMPORTANT) self.speechClient.set_punctuation(speechd.PunctuationMode.SOME) # Apply speech settings from saved values self.speechClient.set_rate(self.speechRate) self.speechClient.set_pitch(self.speechPitch) 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) self.speechPitch = self.config.getint('Speech', 'pitch', fallback=0) # Load volume settings if 'Volume' in self.config: self.volume = self.config.getint('Volume', 'level', fallback=50) 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) self.config['Speech']['pitch'] = str(self.speechPitch) # Save volume settings if 'Volume' not in self.config: self.config['Volume'] = {} self.config['Volume']['level'] = str(self.volume) # 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 increase_speech_pitch(self): """Increase speech pitch""" self.speechPitch = min(100, self.speechPitch + 10) # Max is 100 if self.speechClient: try: self.speechClient.set_pitch(self.speechPitch) self.speak(f"Speech pitch: {self.speechPitch}") except Exception as e: print(f"Error adjusting speech pitch: {e}") # Save the new setting self.save_settings() def decrease_speech_pitch(self): """Decrease speech pitch""" self.speechPitch = max(-100, self.speechPitch - 10) # Min is -100 if self.speechClient: try: self.speechClient.set_pitch(self.speechPitch) self.speak(f"Speech pitch: {self.speechPitch}") except Exception as e: print(f"Error adjusting speech pitch: {e}") # Save the new setting self.save_settings() def get_current_volume(self): """Get the current system volume percentage""" try: result = subprocess.run( ['pactl', 'get-sink-volume', '@DEFAULT_SINK@'], capture_output=True, text=True, check=True ) output = result.stdout # Extract percentage from output like "Volume: front-left: 27111 / 41% / -23.00 dB" match = re.search(r'(\d+)%', output) if match: return int(match.group(1)) return self.volume # Return saved volume if parsing fails except Exception as e: print(f"Error getting volume: {e}") return self.volume # Return saved volume if command fails def set_volume(self, volumePercent): """Set the system volume to the specified percentage""" # Ensure volume is between 0 and 150% volumePercent = max(0, min(150, volumePercent)) try: subprocess.run( ['pactl', 'set-sink-volume', '@DEFAULT_SINK@', f'{volumePercent}%'], check=True ) self.volume = volumePercent self.save_settings() return True except Exception as e: print(f"Error setting volume: {e}") return False def increase_volume(self): """Increase the system volume by 5%""" currentVolume = self.get_current_volume() newVolume = min(150, currentVolume + 5) # Max 150% if self.set_volume(newVolume): self.speak(f"Volume {newVolume} percent") self.draw_menu() def decrease_volume(self): """Decrease the system volume by 5%""" currentVolume = self.get_current_volume() newVolume = max(0, currentVolume - 5) # Min 0% if self.set_volume(newVolume): self.speak(f"Volume {newVolume} percent") self.draw_menu() def play_sound(self, soundName): """Play a sound effect using simpleaudio with ability to cancel previous sounds""" try: # Cancel any currently playing sound self.stop_sound() soundsDir = "/usr/share/sounds/stormux" soundFile = os.path.join(soundsDir, f"{soundName}.wav") # Check if the file exists if not os.path.exists(soundFile): print(f"Sound file not found: {soundFile}") return False # Play the sound and store the play_obj waveObj = sa.WaveObject.from_wave_file(soundFile) self.currentSound = waveObj.play() return True except Exception as e: print(f"Error playing sound {soundName}: {e}") return False def stop_sound(self): """Stop any currently playing sound""" if self.currentSound is not None and self.currentSound.is_playing(): self.currentSound.stop() self.currentSound = None def check_service_status(self, serviceName): """Check if a system service is active""" try: result = subprocess.run( ['systemctl', 'is-active', serviceName], capture_output=True, text=True ) return result.stdout.strip() == 'active' except Exception as e: print(f"Error checking {serviceName} status: {e}") return False def toggle_service(self, friendlyName): """Toggle a system service on/off""" # Get the actual service name from the dictionary serviceName = self.systemMenuServices.get(friendlyName) if not serviceName: print(f"Unknown service: {friendlyName}") return isActive = self.check_service_status(serviceName) # Clean up curses before running the command with sudo curses.endwin() # Clean up speech client if self.speechClient: self.speechClient.close() self.speechClient = None # Execute the appropriate command based on current status action = "" if serviceName not in ["fenrirscreenreader-tty.service", "minidlna.service"]: action = "disable" if isActive else "enable" else: action = "stop" if isActive else "start" command = f"sudo systemctl {action} {serviceName} --now" try: os.system(command) print(f"{friendlyName} {action}d successfully") except Exception as e: print(f"Error {action}ing {friendlyName}: {e}") # Restart speech client self.init_speech() # Restart curses self.stdscr = curses.initscr() curses.noecho() curses.cbreak() self.stdscr.keypad(True) # Announce the action self.speak(f"{friendlyName} {action}d") # Update the menu with the new service status self.update_service_menu_items() # Update Bluetooth menu items if Bluetooth service was toggled if friendlyName == "Bluetooth": self.update_bluetooth_menu_items() # Update Media menu items if DLNA Server was toggled if friendlyName == "D L N A Server": self.update_media_menu_items() # Redraw the menu self.draw_menu() def toggle_screen(self, screenType): """Toggle between screen output and headless mode""" try: # Platform-specific handling is_x86 = platform.machine() == "x86_64" if screenType == "headless": if is_x86: # On x86, remove all 10-* files for best compatibility os.system("sudo rm -f /etc/X11/xorg.conf.d/10-*.conf") else: # On Pi, only remove non-essential files, preserve fbdev os.system("sudo rm -f /etc/X11/xorg.conf.d/10-screen.conf") configFile = "/home/stormux/.local/files/10-headless.conf" message = "Screen disabled." else: # For enabling screen, remove any existing configuration first os.system("sudo rm -f /etc/X11/xorg.conf.d/10-*.conf") configFile = "/home/stormux/.local/files/10-screen.conf" message = "Screen enabled." # Copy the configuration file os.system(f"sudo cp {configFile} /etc/X11/xorg.conf.d/") self.speak(message, interrupt=False) except Exception as e: message = f"Error changing screen configuration: {e}" print(message) self.speak(message) def report_battery_status(self): """Report current battery status""" try: import pathlib power_supply_dir = pathlib.Path('/sys/class/power_supply') # Check if power supply directory exists if not power_supply_dir.exists(): self.speak("No battery found.") return battery_found = False battery_level = None is_charging = False # Look for battery for item in power_supply_dir.iterdir(): type_file = item / 'type' if type_file.exists(): try: if type_file.read_text().strip() == 'Battery': battery_found = True # Get battery level capacity_file = item / 'capacity' if capacity_file.exists(): battery_level = int(capacity_file.read_text().strip()) # Check charging status status_file = item / 'status' if status_file.exists(): status = status_file.read_text().strip() is_charging = status in ['Charging', 'Full'] break except Exception: continue # Check for AC power ac_connected = False for item in power_supply_dir.iterdir(): type_file = item / 'type' online_file = item / 'online' if type_file.exists() and online_file.exists(): try: device_type = type_file.read_text().strip() if device_type in ['ADP', 'Mains', 'AC']: online = int(online_file.read_text().strip()) if online == 1: ac_connected = True break except Exception: continue if not battery_found: self.speak("No battery found.") return # Build status message if battery_level is not None: message = f"Battery level: {battery_level} percent" if is_charging: message += ", charging" elif ac_connected: message += ", plugged in" else: message += ", on battery power" # Add warning context for low levels if battery_level <= 5: message += ". Critical level!" elif battery_level <= 10: message += ". Low battery!" else: message = "Battery detected but unable to read level" self.speak(message) except Exception as e: self.speak(f"Error reading battery status: {e}") def update_service_menu_items(self): """Update all service-related menu items based on their current status""" # Remove any existing service items if "System" in self.menuSections: # Find and remove any service-related items self.menuSections["System"] = [item for item in self.menuSections["System"] if not any(service in item[0] for service in self.systemMenuServices.keys())] # Add the appropriate items based on current status for each service for friendlyName, serviceName in self.systemMenuServices.items(): isActive = self.check_service_status(serviceName) if isActive: self.add_item("System", f"Disable {friendlyName}", lambda fn=friendlyName: self.toggle_service(fn)) else: self.add_item("System", f"Enable {friendlyName}", lambda fn=friendlyName: self.toggle_service(fn)) def install_and_launch(self, executable_name, launch_mode="gui"): """Launch application via xinitrc (which handles installation if needed)""" try: # Launch the application - xinitrc will handle installation if launch_mode == "gui": command = f"GAME='{executable_name}' startx" else: # cli mode command = f"GAME='{executable_name}' /home/stormux/.clirc" # Use the existing execute_current_item infrastructure by temporarily setting command # Save current state original_sections = self.sectionNames.copy() original_current_section = self.currentSection original_items = {} for section in self.menuSections: original_items[section] = self.menuSections[section].copy() # Create temporary item to execute temp_section = "temp_install_launch" self.add_section(temp_section) self.add_item(temp_section, f"Launch {executable_name}", command) # Set to the temporary section and item self.currentSection = len(self.sectionNames) - 1 self.currentItemIndices[temp_section] = 0 # Execute the command using existing infrastructure self.execute_current_item() # Restore original state self.sectionNames = original_sections self.currentSection = original_current_section self.menuSections = original_items except Exception as e: error_msg = f"Error launching {executable_name}: {e}" self.speak(error_msg, interrupt=False) def update_bluetooth_menu_items(self): """Update Bluetooth-related menu items in Accessories section""" if "Accessories" in self.menuSections: # Remove any existing Bluetooth-related items self.menuSections["Accessories"] = [item for item in self.menuSections["Accessories"] if "Bluetooth" not in item[0]] # Add Bluetooth management item only if Bluetooth is enabled if self.check_service_status('bluetooth.service'): self.add_item("Accessories", "Manage Bluetooth Devices", "GAME=blueman-manager startx") def update_media_menu_items(self): """Update Media menu items including DLNA server toggle""" if "Media" in self.menuSections: # Remove any existing DLNA-related items self.menuSections["Media"] = [item for item in self.menuSections["Media"] if "D L N A" not in item[0]] # Check DLNA server status and add appropriate toggle isActive = self.check_service_status('minidlna.service') if isActive: self.add_item("Media", "Disable D L N A Server", lambda: self.toggle_service('D L N A Server')) else: self.add_item("Media", "Enable D L N A Server", lambda: self.toggle_service('D L N A Server')) def scan_documentation_files(self): """Scan Documents directory for .md files and add them to help menu""" docs_dir = os.path.expanduser("~/Documents") if not os.path.exists(docs_dir): return try: # Get all .md files in Documents directory md_files = [] for file in os.listdir(docs_dir): if file.endswith('.md'): file_path = os.path.join(docs_dir, file) if os.path.isfile(file_path): # Create a friendly display name from filename # Remove .md extension and replace underscores with spaces display_name = file[:-3].replace('_', ' ').title() md_files.append((display_name, file)) # Sort files alphabetically by display name md_files.sort(key=lambda x: x[0]) # Add each markdown file to the help section for display_name, filename in md_files: file_path = f"~/Documents/{filename}" self.add_item("Help and Documentation", display_name, f"GAME={file_path} /home/stormux/.clirc") except Exception as e: print(f"Error scanning documentation files: {e}") 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)) 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""" currentSectionName = self.sectionNames[self.currentSection] return self.menuSections[currentSectionName] def get_current_item_index(self): """Get the current item index in the current section""" 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""" 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] # Announce we're launching the program self.speak(f"Launching {name}") # Check if command is a function (for service toggles and other functions) if callable(command): command() return # Save current terminal state before any changes savedTerminalState = None try: result = subprocess.run(['stty', '-g'], capture_output=True, text=True, check=True) savedTerminalState = result.stdout.strip() except Exception as e: print(f"Warning: Could not save terminal state: {e}") # Cleanup before running the command self.stop_speech() # Close speech client if self.speechClient: try: self.speechClient.close() except: pass self.speechClient = None # Curses cleanup - be thorough if self.stdscr: try: curses.nocbreak() self.stdscr.keypad(False) curses.echo() curses.endwin() except: pass self.stdscr = None # Complete terminal reset to ensure clean state for games try: subprocess.run(['reset'], check=False, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) except: pass # Ensure we're in a new process group to avoid signal conflicts # Execute the command with proper terminal isolation try: # For applications that need dialog/curses, we need to ensure they # become the controlling process with full terminal access # Use os.system with proper terminal restoration as it gives # the subprocess complete control over the terminal # First, save our current process group original_pgrp = os.getpgrp() # Execute using os.system which gives the subprocess complete terminal control exit_code = os.system(command) # Try to regain terminal control after the subprocess exits try: # Get the current terminal file descriptor tty_fd = os.open('/dev/tty', os.O_RDWR) # Try to make our process group the foreground group again os.tcsetpgrp(tty_fd, original_pgrp) os.close(tty_fd) except: # If we can't regain control, that's okay pass except Exception as e: print(f"Error launching {name}: {e}") # Another terminal reset after game exits to clean up any changes try: subprocess.run(['reset'], check=False, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) except: pass # Restore the saved terminal state if we have it if savedTerminalState: try: subprocess.run(['stty', savedTerminalState], check=False) except Exception as e: print(f"Warning: Could not restore terminal state: {e}") # Brief delay to let terminal stabilize time.sleep(0.5) # Reinitialize speech and display self.init_speech() # Reinitialize curses with better error handling try: self.stdscr = curses.initscr() curses.noecho() curses.cbreak() self.stdscr.keypad(True) self.stdscr.clear() self.stdscr.refresh() except Exception as e: print(f"Error reinitializing display: {e}") # Try a more aggressive recovery try: curses.endwin() time.sleep(1) self.stdscr = curses.initscr() curses.noecho() curses.cbreak() self.stdscr.keypad(True) self.stdscr.clear() self.stdscr.refresh() except: print("Could not recover display. Please restart the menu.") sys.exit(1) # Update service menu items and redraw self.update_service_menu_items() self.draw_menu() # Let the user know the menu is activated self.speak("Game menu activated") 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. B key: Report battery status. Left bracket: Decrease speech rate. Right bracket: Increase speech rate. Left brace: Decrease speech pitch. Right brace: Increase speech pitch. 9 key: Decrease volume. 0 key: Increase volume. Escape: Refresh the menu. 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 = "Navigate | Enter: Select | H: Help | [ ] Rate | { } Pitch | 9 0 Volume | Esc: Refresh" 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() 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) # Draw speech pitch indicator pitchText = f"Pitch: {self.speechPitch}" pitchX = max(0, w // 2 - len(pitchText) // 2) self.stdscr.addstr(h-2, pitchX, pitchText) # Draw volume indicator volumeText = f"Volume: {self.get_current_volume()}%" self.stdscr.addstr(h-2, w-len(volumeText)-2, volumeText) self.stdscr.refresh() def cleanup(self): """Clean up resources before exiting""" # Stop any speech self.stop_speech() # Close speech client if self.speechClient: try: self.speechClient.close() except: pass self.speechClient = None # Restore terminal settings curses.nocbreak() self.stdscr.keypad(False) curses.echo() curses.endwin() def run(self): """Run the menu system""" if not self.sectionNames: print("Menu is empty. Exiting.") return try: # Initialize curses self.stdscr = curses.initscr() curses.noecho() # Don't echo keypresses curses.cbreak() # React to keys instantly self.stdscr.keypad(True) # Enable special keys # Update all service menu items based on current status self.update_service_menu_items() # Update Bluetooth menu items based on current status self.update_bluetooth_menu_items() # Update Media menu items based on current status self.update_media_menu_items() # Initial draw self.draw_menu() # Welcome message - don't interrupt this initial speech boot_source = "internal disk" if self.is_installed else "USB drive" self.speak(f"Welcome to {self.title}, booted from {boot_source}. Use left and right arrows to navigate sections. Up and down for items. Press H for help.") # 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: prevIndex = self.get_current_item_index() self.set_current_item_index((prevIndex - 1) % len(items)) newIndex = self.get_current_item_index() self.draw_menu() # Play sound if selection actually changed if newIndex != prevIndex: self.play_sound("menu_move") self.announce_current_item() elif key == curses.KEY_DOWN: # Move to next item in current section items = self.get_current_items() if items: prevIndex = self.get_current_item_index() self.set_current_item_index((prevIndex + 1) % len(items)) newIndex = self.get_current_item_index() self.draw_menu() # Play sound if selection actually changed if newIndex != prevIndex: self.play_sound("menu_move") self.announce_current_item() elif key == curses.KEY_LEFT: # Move to previous section if self.sectionNames: prevSection = self.currentSection self.currentSection = (self.currentSection - 1) % len(self.sectionNames) self.draw_menu() # Play category change sound if section actually changed if prevSection != self.currentSection: self.play_sound("menu_category") # 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: prevSection = self.currentSection self.currentSection = (self.currentSection + 1) % len(self.sectionNames) self.draw_menu() # Play category change sound if section actually changed if prevSection != self.currentSection: self.play_sound("menu_category") # 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 self.play_sound("menu_select") self.execute_current_item() 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 == ord('{'): # Decrease speech pitch self.decrease_speech_pitch() self.draw_menu() elif key == ord('}'): # Increase speech pitch self.increase_speech_pitch() self.draw_menu() elif key == ord('9'): # Decrease volume self.decrease_volume() elif key == ord('0'): # Increase volume self.increase_volume() elif key == ord('b') or key == ord('B'): # Battery status self.report_battery_status() elif key == 27: # Esc key - restart the application try: # Announce restart first while speech still works self.speak("Restarting menu") # Wait for speech to complete time.sleep(1) # Now clean up resources self.cleanup() # Try to kill speech-dispatcher if needed subprocess.run(["sudo", "killall", "speech-dispatcher"], check=False) # Restart the application os.execv(sys.argv[0], sys.argv) except Exception as e: print(f"Error during restart: {e}") # If restart fails, we need to recover the UI self.stdscr = curses.initscr() curses.noecho() curses.cbreak() self.stdscr.keypad(True) self.draw_menu() except Exception as e: # End curses in case of error curses.endwin() print(f"An error occurred: {e}") finally: # Clean up self.cleanup() # Example usage if __name__ == "__main__": # Create the menu with sections menu = VoicedMenu(title="Stormux Gaming Menu") # Add arcade section menu.add_section("Arcade") menu.add_item("Arcade", "Audio Disc", "GAME='Audio Disc' startx") menu.add_item("Arcade", "BallBouncer", "GAME=BallBouncer startx") menu.add_item("Arcade", "Battle of the Hunter", "GAME='Battle of the Hunter' startx") menu.add_item("Arcade", "Challenge of the Horse", "GAME='Challenge of the Horse' startx") menu.add_item("Arcade", "Clashes of the Sky", "GAME='Clashes of the Sky' startx") menu.add_item("Arcade", "Constant Motion", "GAME='Constant Motion' startx") menu.add_item("Arcade", "Crazy Party", "GAME='Crazy Party' startx") menu.add_item("Arcade", "Doom", "GAME=Doom startx") menu.add_item("Arcade", "Haunted House", "GAME='Haunted House' startx") menu.add_item("Arcade", "Haunted Party", "GAME='Haunted Party' startx") menu.add_item("Arcade", "kaskade", "GAME=Kaskade startx") # Add Kitchen's Sink only on x86_64 (alphabetically positioned) if platform.machine() == "x86_64": menu.add_item("Arcade", "Kitchensinc", "GAME=Kitchensinc startx") menu.add_item("Arcade", "Oh Shitt", "GAME='Oh Shit' startx") menu.add_item("Arcade", "Q9 Action Game", "GAME='Q9 Action Game' startx") menu.add_item("Arcade", "River Raiders", "GAME='River Raiders' startx") menu.add_item("Arcade", "Scramble", "GAME=Scramble startx") menu.add_item("Arcade", "Screaming Strike 2", "GAME='Screaming Strike 2' startx") menu.add_item("Arcade", "Scrolling Battles", "GAME='Scrolling Battles' startx") menu.add_item("Arcade", "Shooter", "GAME=Shooter startx") menu.add_item("Arcade", "Side Party", "GAME='Side Party' startx") menu.add_item("Arcade", "Skateboarder Pro", "GAME='Skateboarder Pro' startx") menu.add_item("Arcade", "Sketchbook (Your World)", "GAME='Sketchbook (Your World)' startx") menu.add_item("Arcade", "Super Egg Hunt", "GAME='Super Egg Hunt' startx") menu.add_item("Arcade", "Super Liam", "GAME='Super Liam' startx") # Add Swamp only on x86_64 (alphabetically positioned) if platform.machine() == "x86_64": menu.add_item("Arcade", "Swamp", "GAME='Swamp' startx") menu.add_item("Arcade", "The Blind Swordsman", "GAME='The Blind Swordsman' startx") menu.add_item("Arcade", "The Great Toy Robbery", "GAME='The Great Toy Robbery' startx") menu.add_item("Arcade", "The Tornado Chicken", "GAME='The Tornado Chicken' startx") menu.add_item("Arcade", "Toy Mania", "GAME='Toy Mania' startx") menu.add_item("Arcade", "Villains From Beyond", "GAME='Villains From Beyond' startx") menu.add_item("Arcade", "Wicked Quest", "GAME='Wicked Quest' startx") menu.add_item("Arcade", "Wreckingball", "GAME='Wreckingball' startx") menu.add_item("Arcade", "Wreckingball (Pulped)", "GAME='Wreckingball (Pulped)' startx") menu.add_item("Arcade", "Zombowl", "GAME='Zombowl' startx") # Add board and card games section menu.add_section("Board and Card Games") menu.add_item("Board and Card Games", "RS Games", "GAME='RS Games' startx") # Add emulators section menu.add_section("Emulators") menu.add_item("Emulators", "Apple 2e", "/usr/local/bin/apple_2e.py") menu.add_item("Emulators", "Bop It", "GAME='Bop It' startx") menu.add_item("Emulators", "Dosbox", "GAME=Dosbox startx") menu.add_item("Emulators", "Game Console Menu", "/usr/local/bin/rom_launcher.py") menu.add_item("Emulators", "Retro Arch", "GAME='Retro Arch' startx") # Add Steam only on x86_64 if platform.machine() == "x86_64": menu.add_item("Emulators", "Steam", "GAME='Steam' startx") # Add MUD section menu.add_section("MUDs") menu.add_item("MUDs", "Alter Aeon", "GAME='Alter Aeon' /home/stormux/.clirc") menu.add_item("MUDs", "Empire MUD", "GAME='Empire MUD' /home/stormux/.clirc") menu.add_item("MUDs", "End of Time", "GAME='End of Time' /home/stormux/.clirc") menu.add_item("MUDs", "Kallisti MUD", "GAME='Kallisti MUD' /home/stormux/.clirc") # Add racing section menu.add_section("Racing") menu.add_item("Racing", "Mach1", "GAME=Mach1 startx") menu.add_item("Racing", "Mine Racer", "GAME='Mine Racer' startx") menu.add_item("Racing", "Top Speed 3", "GAME=\"Top Speed 3\" startx") menu.add_item("Racing", "Wheels of Prio", "GAME=\"Wheels of Prio\" startx") # Add rpg section menu.add_section("RPG") menu.add_item("RPG", "Bokurano Daibouken", "GAME='Bokurano Daibouken' startx") menu.add_item("RPG", "Bokurano Daibouken 2", "GAME='Bokurano Daibouken 2' startx") menu.add_item("RPG", "Bokurano Daibouken 3", "GAME='Bokurano Daibouken 3' startx") menu.add_item("RPG", "Fantasy Story 2", "GAME='Fantasy Story 2' startx") menu.add_item("RPG", "Manamon 2", "GAME='Manamon 2' startx") menu.add_item("RPG", "Shadow Line", "GAME='Shadow Line' startx") # Add sports section menu.add_section("Sports") menu.add_item("Sports", "Golf", "GAME=Golf startx") menu.add_item("Sports", "Horseshoes", "GAME=Horseshoes startx") menu.add_item("Sports", "Pong", "GAME=Pong startx") # Add strategy section menu.add_section("Strategy") menu.add_item("Strategy", "SoundRTS", "GAME=SoundRTS startx") menu.add_item("Strategy", "Warsim", "/home/stormux/.Warsim") # Add text games section menu.add_section("Text Games") menu.add_item("Text Games", "BPG", "GAME=BPG /home/stormux/.clirc") menu.add_item("Text Games", "Colossal Cave Adventure", "GAME=/usr/bin/adventure /home/stormux/.clirc") menu.add_item("Text Games", "Go Fish", "GAME=/usr/bin/gofish /home/stormux/.clirc") menu.add_item("Text Games", "RS Games", "GAME=\"RS Games\" /home/stormux/.clirc") menu.add_item("Text Games", "Slay the Text", "GAME=\"Slay the Text\" /home/stormux/.clirc") menu.add_item("Text Games", "Stationfall", "GAME=Stationfall /home/stormux/.clirc") menu.add_item("Text Games", "Planetfall", "GAME=Planetfall /home/stormux/.clirc") menu.add_item("Text Games", "The Hitchhiker's Guide to the Galaxy", "GAME=\"The Hitchhiker's Guide to the Galaxy\" /home/stormux/.clirc") menu.add_item("Text Games", "Upheaval", "GAME=Upheaval /home/stormux/.Upheaval") menu.add_item("Text Games", "Zork 1", "GAME='Zork 1' /home/stormux/.clirc") menu.add_item("Text Games", "Zork 2", "GAME='Zork 2' /home/stormux/.clirc") menu.add_item("Text Games", "Zork 3", "GAME='Zork 3' /home/stormux/.clirc") # Add web section menu.add_section("Web") menu.add_item("Web", "Aliens", "GAME=https://files.jantrid.net/aliens// startx") menu.add_item("Web", "Echo Commander", "GAME=https://echo-commander.vercel.app/ startx") menu.add_item("Web", "Periphery Synthetic EP", "GAME=https://shiftbacktick.itch.io/periphery-synthetic-ep startx") menu.add_item("Web", "Pontoon", "GAME='https://oneswitch.org.uk/jsbeeb/?autotype=CHAIN%22PONTOON%22%0A&disc=/Blind_Access/Pontoon_1983_2025.dsd' startx") menu.add_item("Web", "QuentinC Play Room", "GAME=https://qcsalon.net/ startx") menu.add_item("Web", "soundStrider", "GAME=https://shiftbacktick.itch.io/soundstrider startx") # Add help and documentation section menu.add_section("Help and Documentation") # Dynamically scan and add all .md files from Documents directory menu.scan_documentation_files() # Add the IRC help item menu.add_item("Help and Documentation", "Get help on IRC", "GAME=IRC /home/stormux/.clirc") # Add media section menu.add_section("Media") menu.add_item("Media", "Music Player", "/usr/local/bin/music_player.py") menu.add_item("Media", "BookStorm", "GAME=BookStorm startx") menu.add_item("Media", "Upload Files", "/home/stormux/.local/upload_server/uploader.py") # Add accessories section menu.add_section("Accessories") menu.add_item("Accessories", "Local IP Address", "/usr/local/bin/ip_info.py local") menu.add_item("Accessories", "Remote IP Address", "/usr/local/bin/ip_info.py remote") menu.add_item("Accessories", "Sound and Volume", "/usr/local/bin/audio_manager.py") menu.add_item("Accessories", "Web Browser", "GAME=Brave startx") menu.add_item("Accessories", "LibreOffice", lambda: menu.install_and_launch("libreoffice", "gui")) menu.add_item("Accessories", "Thunderbird", lambda: menu.install_and_launch("thunderbird", "gui")) # Add system section menu.add_section("System") # Add installer only on x86_64 and if not already installed (no .baremetal file) if platform.machine() == "x86_64" and not os.path.exists("/home/stormux/.baremetal"): menu.add_item("System", "Install System to Hard Drive", "GAME='Install to Disk' /home/stormux/.clirc") menu.add_item("System", "Internet Configuration", "GAME=\"Network Configuration\" /home/stormux/.clirc") menu.add_item("System", "Enable Screen", lambda: menu.toggle_screen("screen")) menu.add_item("System", "Disable Screen", lambda: menu.toggle_screen("headless")) menu.add_item("System", "Set System Speech Settings", "/usr/local/bin/speechd_rate.py") menu.add_item("System", "Set Default Voice", "/usr/local/bin/set-voice.py") menu.add_item("System", "Set Timezone", "GAME='Set Timezone' /home/stormux/.clirc") menu.add_item("System", "Download Files", "/home/stormux/.local/download_server.py") menu.add_item("System", "Update System", "sudo /usr/local/bin/live-update.sh") menu.add_item("System", "Restart: Can Take Several Minutes", "sudo reboot") menu.add_item("System", "Power Off: Wait 2 Minutes Before Disconnecting Power", "sudo poweroff") # Service menu items will be added dynamically in run() method via update_service_menu_items() menu.add_item("System", "Resize to fill empty space on disk", "sudo growpartfs $(df --output='source' / | tail -1)") # Run the menu menu.run()