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