#!/usr/bin/env python3 # -*- coding: utf-8 -*- # Self-voiced Terminal Menu for Speech Dispatcher Voice Selection import os import sys import time import curses import speechd import subprocess import configparser class VoiceSelectionMenu: def __init__(self, title="Speech Dispatcher Voice Selection"): self.title = title self.voice_modules = [] # List to store available voice modules self.current_index = 0 # Index of current selection self.stdscr = None self.curses_initialized = False # Flag to track if curses has been initialized # Initialize speech client self.speech_client = None self.init_speech() # Load available voice modules self.load_voice_modules() def init_speech(self): """Initialize the speech client""" try: self.speech_client = speechd.SSIPClient("voice_selection_menu") self.speech_client.set_priority(speechd.Priority.IMPORTANT) self.speech_client.set_punctuation(speechd.PunctuationMode.SOME) except Exception as e: print(f"Could not initialize speech: {e}") # Fallback to None - the speak method will handle this def load_voice_modules(self): """Load available speech-dispatcher modules""" try: # Execute the command to get available output modules result = subprocess.run(['spd-say', '-O'], capture_output=True, text=True, check=True) # Process the output to get the list of modules lines = result.stdout.strip().split('\n') # Skip the first line (header) modules = [line.strip() for line in lines[1:] if line.strip()] # Store the modules self.voice_modules = modules if not modules: print("No speech-dispatcher modules found.") except Exception as e: print(f"Error loading voice modules: {e}") self.voice_modules = [] def speak(self, text, interrupt=True, module=None): """Speak the given text with option to interrupt existing speech""" if self.speech_client is None: return try: if interrupt: self.stop_speech() # If a specific module is requested, try to use it if module: current_module = self.speech_client.get_output_module() self.speech_client.set_output_module(module) self.speech_client.speak(text) # Restore previous module after speaking self.speech_client.set_output_module(current_module) else: # Use default module self.speech_client.speak(text) except Exception as e: # If speech fails, try to reinitialize and try once more try: self.init_speech() if self.speech_client: self.speech_client.speak(text) except: # If reinitializing fails, just give up silently pass def stop_speech(self): """Stop any ongoing speech""" if self.speech_client is None: return try: self.speech_client.cancel() except Exception as e: # If cancel fails, try to reinitialize self.init_speech() def announce_current_item(self, interrupt=True): """Announce the currently selected voice module""" if self.voice_modules and 0 <= self.current_index < len(self.voice_modules): module = self.voice_modules[self.current_index] self.speak(f"Module {module}", interrupt=interrupt) def test_selected_module(self): """Test the currently selected voice module""" if self.voice_modules and 0 <= self.current_index < len(self.voice_modules): module = self.voice_modules[self.current_index] test_message = f"This is a test of the {module} speech-dispatcher module. If you can hear this message, press enter to set {module} as your default module. If enter is not pressed within 15 seconds, no changes will be made to your system." # Speak using the selected module - should not be interrupted self.speak(test_message, interrupt=False, module=module) # Draw a message instructing the user to press Enter h, w = self.stdscr.getmaxyx() confirm_msg = "Press ENTER within 15 seconds to confirm selection or any other key to cancel." x = max(0, w // 2 - len(confirm_msg) // 2) self.stdscr.addstr(h-2, x, confirm_msg, curses.A_BOLD) self.stdscr.refresh() # Wait for user confirmation with timeout self.stdscr.timeout(15000) # 15 seconds timeout key = self.stdscr.getch() self.stdscr.timeout(-1) # Reset timeout # Check if Enter was pressed if key == curses.KEY_ENTER or key == 10 or key == 13: return True else: self.speak("Confirmation not received, no changes made to your speech-dispatcher configuration.", interrupt=False) return False return False def set_default_module(self): """Set the selected module as the default speech-dispatcher module""" if self.voice_modules and 0 <= self.current_index < len(self.voice_modules): module = self.voice_modules[self.current_index] # Test the module first if not self.test_selected_module(): return try: # Clean up before executing system commands self.cleanup(full_cleanup=False) # Use sed to update the DefaultModule in speechd.conf sed_cmd = f"sudo sed -i '/^\\s*#\\?\\s*DefaultModule\\s\\+/c\\DefaultModule {module}' /etc/speech-dispatcher/speechd.conf" subprocess.run(sed_cmd, shell=True, check=True) # Restart speech-dispatcher subprocess.run("sudo killall speech-dispatcher", shell=True, check=False) # Re-initialize speech after changes time.sleep(1) # Give a moment for the service to restart self.init_speech() # Notify the user that the change is complete - should not be interrupted self.speak(f"The {module} module is now being used for this system.", interrupt=False) # Return to the menu after speech finishes # No sleep here - the next UI repaint doesn't depend on speech finishing self.draw_menu() except Exception as e: print(f"Error setting default module: {e}") self.speak("An error occurred while attempting to set the default module.", interrupt=False) def speak_help(self): """Speak help information""" helpText = """ Navigation controls: Up arrow: Previous voice module. Down arrow: Next voice module. Enter: Test and set the selected voice module. H key: Hear these instructions again. Escape or Q: Exit the menu. Any key will interrupt speech. """ self.speak(helpText, interrupt=False) 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 = "Up/Down: Navigate | Enter: Test & Set | H: Help | Q/Esc: Quit" x = max(0, w // 2 - len(helpText) // 2) self.stdscr.addstr(3, x, helpText) # Check if we have items if not self.voice_modules: message = "No speech-dispatcher modules found." x = max(0, w // 2 - len(message) // 2) self.stdscr.addstr(5, x, message, curses.A_DIM) else: # Show a limited number of items, centered around the current selection max_display = min(h - 7, len(self.voice_modules)) # Max number of items to display # Calculate starting index for display half_display = max_display // 2 if self.current_index < half_display: start_idx = 0 elif self.current_index >= len(self.voice_modules) - half_display: start_idx = max(0, len(self.voice_modules) - max_display) else: start_idx = self.current_index - half_display # Draw visible menu items for i in range(start_idx, min(start_idx + max_display, len(self.voice_modules))): y = (i - start_idx) + 5 # Start items at line 5 # Highlight the selected item module = self.voice_modules[i] if i == self.current_index: text = f" > {module} " attr = curses.A_REVERSE else: text = f" {module} " attr = curses.A_NORMAL x = max(0, w // 2 - len(text) // 2) self.stdscr.addstr(y, x, text, attr) 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. """ # Stop any speech self.stop_speech() # Close speech client if self.speech_client: try: self.speech_client.close() except: pass self.speech_client = 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 run(self): """Run the menu system""" # Check if menu is empty or only has one item if not self.voice_modules: message = "No speech-dispatcher modules found. Exiting." print(message) # Clean up and exit properly self.cleanup(full_cleanup=True) sys.exit(0) elif len(self.voice_modules) == 1: message = f"{self.voice_modules[0]} is the only available module and is already set for this system." print(message) # Speak the message self.init_speech() if self.speech_client: # Use speech_client.speak directly with wait flag to ensure it completes self.speech_client.speak(message) # Wait for speech to complete self.speech_client.close() # Clean up and exit properly self.cleanup(full_cleanup=True) sys.exit(0) try: # Initialize curses self.stdscr = curses.initscr() self.curses_initialized = True curses.noecho() curses.cbreak() self.stdscr.keypad(True) # Initial draw self.draw_menu() # Welcome message self.speak(self.title, interrupt=False) # Announce first item after welcome finishes 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 self.current_index = (self.current_index - 1) % len(self.voice_modules) self.draw_menu() self.announce_current_item() elif key == curses.KEY_DOWN: # Move to next item self.current_index = (self.current_index + 1) % len(self.voice_modules) self.draw_menu() self.announce_current_item() elif key == curses.KEY_ENTER or key == 10 or key == 13: # Enter key self.set_default_module() elif key == ord('h') or key == ord('H'): # Help self.speak_help() 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) # Run the menu if __name__ == "__main__": # Create the menu menu = VoiceSelectionMenu() # Run the menu menu.run()