#!/usr/bin/env python3 # -*- coding: utf-8 -*- # Self-voiced Speech Rate Configuration Menu import os import sys import time import curses import speechd # Python bindings for Speech Dispatcher import re import subprocess def update_speechd_config_content(content, rate, volume, pitch): """Return speechd.conf content with updated default voice parameters.""" newContent = content replacements = ( ("DefaultRate", rate), ("DefaultVolume", volume), ("DefaultPitch", pitch), ) for settingName, settingValue in replacements: activePattern = rf'^(\s*){settingName}\s+(-?\d+)' commentedPattern = rf'^(\s*)#\s*{settingName}\s+(-?\d+)' replacement = rf'\1{settingName} {settingValue}' if re.search(activePattern, newContent, re.MULTILINE): newContent = re.sub( activePattern, replacement, newContent, flags=re.MULTILINE, ) else: newContent = re.sub( commentedPattern, replacement, newContent, flags=re.MULTILINE, ) return newContent def get_writable_config_targets(configTargets): """Return existing config paths to update, preserving order.""" return [configPath for configPath in configTargets if os.path.exists(configPath)] class SpeechRateMenu: def __init__(self, title="Speech Configuration"): self.title = title self.currentRate = 0 # Default rate self.currentVolume = 100 # Default volume self.currentPitch = 0 # Default pitch self.currentMode = 0 # 0=Rate, 1=Volume, 2=Pitch self.modes = ["Rate", "Volume", "Pitch"] self.stdscr = None self.cursesInitialized = False # Flag to track if curses has been initialized self.configFile = "/etc/speech-dispatcher/speechd.conf" self.configTargets = [ self.configFile, os.path.expanduser("~/.fex-emu/RootFS/ArchLinux/etc/speech-dispatcher/speechd.conf"), ] # Load current settings from config FIRST self.load_current_settings() # Initialize speech client AFTER loading the settings self.speechClient = None self.init_speech() def init_speech(self): """Initialize the speech client""" try: self.speechClient = speechd.SSIPClient("speech_config_menu") self.speechClient.set_priority(speechd.Priority.IMPORTANT) self.speechClient.set_punctuation(speechd.PunctuationMode.SOME) # Apply the loaded settings to the speech client self.speechClient.set_rate(self.currentRate) self.speechClient.set_volume(self.currentVolume) self.speechClient.set_pitch(self.currentPitch) except Exception as e: # Fallback to None - the speak method will handle this pass def load_current_settings(self): """Load the current default settings from speechd.conf""" try: with open(self.configFile, 'r') as f: content = f.read() # Load Rate activeMatch = re.search(r'^\s*DefaultRate\s+(-?\d+)', content, re.MULTILINE) if activeMatch: self.currentRate = int(activeMatch.group(1)) else: commentedMatch = re.search(r'^\s*#\s*DefaultRate\s+(-?\d+)', content, re.MULTILINE) if commentedMatch: self.currentRate = int(commentedMatch.group(1)) # Load Volume activeMatch = re.search(r'^\s*DefaultVolume\s+(-?\d+)', content, re.MULTILINE) if activeMatch: self.currentVolume = int(activeMatch.group(1)) else: commentedMatch = re.search(r'^\s*#\s*DefaultVolume\s+(-?\d+)', content, re.MULTILINE) if commentedMatch: self.currentVolume = int(commentedMatch.group(1)) # Load Pitch activeMatch = re.search(r'^\s*DefaultPitch\s+(-?\d+)', content, re.MULTILINE) if activeMatch: self.currentPitch = int(activeMatch.group(1)) else: commentedMatch = re.search(r'^\s*#\s*DefaultPitch\s+(-?\d+)', content, re.MULTILINE) if commentedMatch: self.currentPitch = int(commentedMatch.group(1)) except Exception: # If loading fails, we'll use default values pass def save_settings_to_config(self): """Save the current settings to the speech-dispatcher config file""" try: for configPath in get_writable_config_targets(self.configTargets): with open(configPath, 'r') as f: content = f.read() newContent = update_speechd_config_content( content, self.currentRate, self.currentVolume, self.currentPitch, ) tempFile = f"/tmp/{os.path.basename(configPath)}.new" with open(tempFile, 'w') as f: f.write(newContent) subprocess.run( ["sudo", "mv", tempFile, configPath], check=True, ) return True except Exception: return False def get_current_value(self): """Get the current value for the active mode""" if self.currentMode == 0: # Rate return self.currentRate elif self.currentMode == 1: # Volume return self.currentVolume else: # Pitch return self.currentPitch def adjust_current_value(self, amount): """Adjust the current value by the given amount""" if self.currentMode == 0: # Rate # Rate should be between -50 and 100 newValue = max(-50, min(100, self.currentRate + amount)) if newValue != self.currentRate: self.currentRate = newValue if self.speechClient: try: self.speechClient.set_rate(self.currentRate) self.speak(f"Speech rate {self.currentRate}") except Exception: pass elif self.currentMode == 1: # Volume # Volume should be between -100 and 100 newValue = max(-100, min(100, self.currentVolume + amount)) if newValue != self.currentVolume: self.currentVolume = newValue if self.speechClient: try: self.speechClient.set_volume(self.currentVolume) self.speak(f"Volume {self.currentVolume}") except Exception: pass else: # Pitch # Pitch should be between -100 and 100 newValue = max(-100, min(100, self.currentPitch + amount)) if newValue != self.currentPitch: self.currentPitch = newValue if self.speechClient: try: self.speechClient.set_pitch(self.currentPitch) self.speak(f"Pitch {self.currentPitch}") except Exception: pass 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: # 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: # If cancel fails, try to reinitialize self.init_speech() def confirm_saved_settings(self): """Restart speechd, announce success, and wait for confirmation.""" subprocess.run( ["sudo", "killall", "speech-dispatcher"], check=False, ) time.sleep(1) self.init_speech() self.speak("Speech settings applied. Press Enter to continue.", interrupt=False) while True: key = self.stdscr.getch() if key == curses.KEY_ENTER or key == 10 or key == 13: break 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: Adjust | Tab: Switch Mode | Enter: Save | Q/Esc: Quit" x = max(0, w // 2 - len(helpText) // 2) self.stdscr.addstr(3, x, helpText) # Draw all current values currentMode = self.modes[self.currentMode] # Rate display rateText = f"Rate: {self.currentRate}" attr = curses.A_REVERSE if self.currentMode == 0 else curses.A_NORMAL x = max(0, w // 2 - 30) self.stdscr.addstr(5, x, rateText, attr) # Volume display volumeText = f"Volume: {self.currentVolume}" attr = curses.A_REVERSE if self.currentMode == 1 else curses.A_NORMAL x = max(0, w // 2 - 5) self.stdscr.addstr(5, x, volumeText, attr) # Pitch display pitchText = f"Pitch: {self.currentPitch}" attr = curses.A_REVERSE if self.currentMode == 2 else curses.A_NORMAL x = max(0, w // 2 + 20) self.stdscr.addstr(5, x, pitchText, attr) # Current mode indicator modeText = f"Current Mode: {currentMode}" x = max(0, w // 2 - len(modeText) // 2) self.stdscr.addstr(7, x, modeText, curses.A_BOLD) # Draw visualization bar for current parameter barWidth = 50 # Width of the visualization bar barX = max(0, w // 2 - barWidth // 2) # Get current value and range currentValue = self.get_current_value() if self.currentMode == 0: # Rate minVal, maxVal = -50, 100 totalRange = 150 normalizedValue = currentValue + 50 else: # Volume or Pitch minVal, maxVal = -100, 100 totalRange = 200 normalizedValue = currentValue + 100 position = int((normalizedValue / totalRange) * barWidth) # Draw the bar barY = 9 self.stdscr.addstr(barY, barX, "┌" + "─" * barWidth + "┐") self.stdscr.addstr(barY + 1, barX, "│" + " " * barWidth + "│") self.stdscr.addstr(barY + 2, barX, "└" + "─" * barWidth + "┘") # Draw the position marker if 0 <= position < barWidth: self.stdscr.addstr(barY + 1, barX + 1 + position, "█", curses.A_BOLD) # Add labels for min and max self.stdscr.addstr(barY + 3, barX, str(minVal)) maxLabel = str(maxVal) self.stdscr.addstr(barY + 3, barX + barWidth - len(maxLabel), maxLabel) # Note about saving note = "Press Enter to save all settings to system config" x = max(0, w // 2 - len(note) // 2) self.stdscr.addstr(h - 3, x, note, curses.A_DIM) # Warning about system config warning = "Note: Saving requires sudo privileges" x = max(0, w // 2 - len(warning) // 2) self.stdscr.addstr(h - 2, x, warning, curses.A_DIM) self.stdscr.refresh() def cleanup(self, fullCleanup=False): """Clean up resources before exiting Args: fullCleanup: If True, also close curses. Used when 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 if curses was initialized if fullCleanup and self.cursesInitialized: 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""" try: # Initialize curses self.stdscr = curses.initscr() self.cursesInitialized = True curses.noecho() curses.cbreak() self.stdscr.keypad(True) # Initial draw self.draw_menu() # Welcome message currentMode = self.modes[self.currentMode] self.speak(f"Speech configuration menu. Currently adjusting {currentMode}. Rate {self.currentRate}, Volume {self.currentVolume}, Pitch {self.currentPitch}.") # 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: # Increase current value by 10 self.adjust_current_value(10) self.draw_menu() elif key == curses.KEY_DOWN: # Decrease current value by 10 self.adjust_current_value(-10) self.draw_menu() elif key == ord('\t') or key == 9: # Tab key # Switch to next mode self.currentMode = (self.currentMode + 1) % len(self.modes) currentMode = self.modes[self.currentMode] currentValue = self.get_current_value() self.speak(f"Switching to {currentMode}. Current value: {currentValue}") self.draw_menu() elif key == curses.KEY_ENTER or key == 10 or key == 13: # Enter key # Save all settings self.speak("Saving speech settings to system configuration.") success = self.save_settings_to_config() if success: self.confirm_saved_settings() else: self.speak("Failed to save speech settings. You may need root privileges.") break # Exit the loop after saving elif key == 27 or key == ord('q') or key == ord('Q'): # Esc or Q self.speak("Speech settings discarded.") time.sleep(3) break except Exception: # End curses in case of error if self.cursesInitialized: try: curses.endwin() except: pass finally: # Clean up - safe to call even if curses wasn't initialized self.cleanup(fullCleanup=True) # Run the menu if __name__ == "__main__": # Create the menu menu = SpeechRateMenu() # Run the menu menu.run()