388 lines
15 KiB
Python
Executable File
388 lines
15 KiB
Python
Executable File
#!/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)
|
|
|
|
# Read the current config file
|
|
import re
|
|
with open('/etc/speech-dispatcher/speechd.conf', 'r') as f:
|
|
content = f.read()
|
|
|
|
# Check if DefaultModule is already uncommented
|
|
if re.search(r'^\s*DefaultModule\s+', content, re.MULTILINE):
|
|
# Replace existing DefaultModule line
|
|
new_content = re.sub(
|
|
r'^(\s*)DefaultModule\s+\S+',
|
|
f'\\1DefaultModule {module}',
|
|
content,
|
|
flags=re.MULTILINE
|
|
)
|
|
else:
|
|
# Uncomment and set DefaultModule line
|
|
new_content = re.sub(
|
|
r'^(\s*)#\s*DefaultModule\s+\S*',
|
|
f'\\1DefaultModule {module}',
|
|
content,
|
|
flags=re.MULTILINE
|
|
)
|
|
|
|
# Write to a temporary file
|
|
temp_file = "/tmp/speechd.conf.new"
|
|
with open(temp_file, 'w') as f:
|
|
f.write(new_content)
|
|
|
|
# Use sudo to move the file to the correct location
|
|
subprocess.run(f"sudo mv {temp_file} /etc/speech-dispatcher/speechd.conf", shell=True, check=True)
|
|
|
|
# Restart speech-dispatcher more thoroughly
|
|
subprocess.run("sudo systemctl restart speech-dispatcher", shell=True, check=False)
|
|
# Also kill any remaining processes
|
|
subprocess.run("sudo killall speech-dispatcher", shell=True, check=False)
|
|
|
|
# Re-initialize speech after changes
|
|
time.sleep(2) # Give more time 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 set as the default voice 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()
|