#!/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 class SpeechRateMenu: def __init__(self, title="Speech Rate Configuration"): self.title = title self.currentRate = 0 # Default rate self.stdscr = None self.cursesInitialized = False # Flag to track if curses has been initialized self.configFile = "/etc/speech-dispatcher/speechd.conf" # Load current rate from config FIRST self.load_current_rate() # Initialize speech client AFTER loading the rate self.speechClient = None self.init_speech() def init_speech(self): """Initialize the speech client""" try: self.speechClient = speechd.SSIPClient("speech_rate_menu") self.speechClient.set_priority(speechd.Priority.IMPORTANT) self.speechClient.set_punctuation(speechd.PunctuationMode.SOME) # Apply the loaded rate to the speech client self.speechClient.set_rate(self.currentRate) except Exception as e: # Fallback to None - the speak method will handle this pass def load_current_rate(self): """Load the current default rate from speechd.conf""" try: with open(self.configFile, 'r') as f: content = f.read() # First check for uncommented DefaultRate with flexible whitespace activeMatch = re.search(r'^\s*DefaultRate\s+(-?\d+)', content, re.MULTILINE) if activeMatch: self.currentRate = int(activeMatch.group(1)) else: # If DefaultRate is commented out, get the value from commented line commentedMatch = re.search(r'^\s*#\s*DefaultRate\s+(-?\d+)', content, re.MULTILINE) if commentedMatch: self.currentRate = int(commentedMatch.group(1)) except Exception: # If loading fails, we'll use default value 0 pass def save_rate_to_config(self): """Save the current rate to the speech-dispatcher config file""" try: # We need to use sudo to modify the system config file # This assumes the user has sudo privileges or the script is run as root # Create a temporary file with the modified content with open(self.configFile, 'r') as f: content = f.read() # Check if DefaultRate is already uncommented if re.search(r'^\s*DefaultRate\s+', content, re.MULTILINE): # Replace the existing DefaultRate line, preserving leading whitespace newContent = re.sub( r'^(\s*)DefaultRate\s+(-?\d+)', r'\1DefaultRate ' + str(self.currentRate), content, flags=re.MULTILINE ) else: # Uncomment and update the DefaultRate line, preserving leading whitespace newContent = re.sub( r'^(\s*)#\s*DefaultRate\s+(-?\d+)', r'\1DefaultRate ' + str(self.currentRate), content, flags=re.MULTILINE ) # Write to a temporary file tempFile = "/tmp/speechd.conf.new" with open(tempFile, 'w') as f: f.write(newContent) # Use sudo to move the file to the correct location cmd = f"sudo mv {tempFile} {self.configFile}" subprocess.run(cmd, shell=True, check=True) return True except Exception: return False def adjust_rate(self, amount): """Adjust the speech rate by the given amount""" # Rate should be between -50 and 100 newRate = max(-50, min(100, self.currentRate + amount)) if newRate != self.currentRate: self.currentRate = newRate if self.speechClient: try: self.speechClient.set_rate(self.currentRate) self.speak(f"Speech rate {self.currentRate}") 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 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 Rate | Enter: Save | Q/Esc: Quit" x = max(0, w // 2 - len(helpText) // 2) self.stdscr.addstr(3, x, helpText) # Draw current rate rateText = f"Current Rate: {self.currentRate}" x = max(0, w // 2 - len(rateText) // 2) self.stdscr.addstr(5, x, rateText, curses.A_REVERSE) # Draw rate visualization barWidth = 50 # Width of the visualization bar barX = max(0, w // 2 - barWidth // 2) # Map rate (-50 to 100) to bar position (0 to barWidth) rateRange = 150 # Total range (from -50 to 100) normalizedRate = self.currentRate + 50 # Shift to 0-150 range position = int((normalizedRate / rateRange) * barWidth) # Draw the bar barY = 7 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, "-50") self.stdscr.addstr(barY + 3, barX + barWidth - 3, "100") # Note about saving note = "Press Enter to save the rate 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 self.speak(f"The current rate for the default voice is {self.currentRate}.") # 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 rate by 10 self.adjust_rate(10) self.draw_menu() elif key == curses.KEY_DOWN: # Decrease rate by 10 self.adjust_rate(-10) self.draw_menu() elif key == curses.KEY_ENTER or key == 10 or key == 13: # Enter key # Save the rate self.speak("Saving speech rate to system configuration.") success = self.save_rate_to_config() if success: self.speak(f"Speech rate {self.currentRate} has been saved successfully.") else: self.speak("Failed to save speech rate. You may need root privileges.") # Wait briefly to allow speech to complete before exiting time.sleep(3) break # Exit the loop after saving elif key == 27 or key == ord('q') or key == ord('Q'): # Esc or Q self.speak("Exiting speech rate configuration.") 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()