508 lines
19 KiB
Python
Executable File
508 lines
19 KiB
Python
Executable File
#!/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()
|