diff --git a/tools/speechd_config.py b/tools/speechd_config.py new file mode 100755 index 00000000..e8eeaa31 --- /dev/null +++ b/tools/speechd_config.py @@ -0,0 +1,242 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# Voice configuration script +# By Storm Dragon + +import configparser +import subprocess +import os +import sys +import time +import select +import tempfile + +def check_root_access(): + """Check if script is running as root and determine available privilege escalation command""" + if os.geteuid() == 0: + return True, None + sudoPath = run_command(['which', 'sudo']) + if sudoPath: + return False, 'sudo' + doasPath = run_command(['which', 'doas']) + if doasPath: + return False, 'doas' + return False, None + +def run_command(cmd, needsRoot=False, privilegeCmd=None): + """Run a command with optional root privileges""" + try: + if needsRoot and privilegeCmd and os.geteuid() != 0: + fullCmd = [privilegeCmd] + cmd + else: + fullCmd = cmd + result = subprocess.run(fullCmd, capture_output=True, text=True) + if result.returncode != 0: + print(f"Command failed: {' '.join(fullCmd)}") + print(f"Error: {result.stderr}") + return None + return result.stdout.strip() + except Exception as e: + print(f"Error running command {' '.join(cmd)}: {e}") + return None + +def run_command_with_output(cmd, needsRoot=False, privilegeCmd=None): + """Run a command and return its content directly""" + try: + if needsRoot and privilegeCmd and os.geteuid() != 0: + cmd = [privilegeCmd] + cmd + with subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) as process: + output, error = process.communicate() + if process.returncode != 0: + print(f"Error: {error}") + return None + return output + except Exception as e: + print(f"Error running command {' '.join(cmd)}: {e}") + return None + +def get_speechd_modules(privilegeCmd): + """Get list of available speech-dispatcher modules""" + output = run_command_with_output(['spd-say', '-O'], True, privilegeCmd) + if output: + lines = output.split('\n') + if len(lines) > 1: + return [line.strip() for line in lines[1:] if line.strip()] + return [] + +def process_espeak_voice(voiceLine): + """Process espeak-ng voice line to get voice name from last two columns""" + parts = [p for p in voiceLine.split() if p] + if len(parts) < 2: + return None + langCode = parts[-2].lower() + variant = parts[-1].lower() + if variant and variant != 'none': + return f"{langCode}+{variant}" + return langCode + +def get_module_voices(moduleName, privilegeCmd): + """Get available voices for a specific module""" + output = run_command_with_output(['spd-say', '-o', moduleName, '-L'], True, privilegeCmd) + if output: + lines = output.split('\n') + if len(lines) > 1: + voices = [] + for line in lines[1:]: + if not line.strip(): + continue + if moduleName.lower() == 'espeak-ng': + processedVoice = process_espeak_voice(line) + if processedVoice: + voices.append(processedVoice) + else: + voices.append(line.strip()) + return voices + return [] + +def display_paginated_voices(voiceList): + """Display voices in pages of 30""" + pageSize = 30 + totalVoices = len(voiceList) + totalPages = (totalVoices + pageSize - 1) // pageSize + currentPage = 0 + while currentPage < totalPages: + start = currentPage * pageSize + end = min(start + pageSize, totalVoices) + print(f"\nShowing voices {start + 1}-{end} of {totalVoices} (Page {currentPage + 1} of {totalPages})") + print("Available voices:") + for i, voice in enumerate(voiceList[start:end], start + 1): + print(f"{i}. {voice}") + if currentPage < totalPages - 1: + print("\nPress Enter for next page, 'b' for previous page, or enter a number to select a voice (0 to go back): ") + else: + print("\nPress 'b' for previous page, or enter a number to select a voice (0 to go back): ") + choice = input().strip().lower() + if choice == '': + if currentPage < totalPages - 1: + currentPage += 1 + elif choice == 'b': + if currentPage > 0: + currentPage -= 1 + elif choice.isdigit(): + choice = int(choice) + if choice == 0: + return None + if 1 <= choice <= totalVoices: + return voiceList[choice - 1] + print("Invalid selection!") + else: + print("Invalid input!") + return None + +def test_voice_config(moduleName, voiceName, privilegeCmd): + """Test if the selected voice works""" + print("\nTesting voice configuration...") + testMessage = "If you hear this message, press Enter within 30 seconds to confirm. Otherwise, wait for timeout to return to the menu." + try: + cmd = ['spd-say', '-o', moduleName, '-y', voiceName, testMessage] + if privilegeCmd and os.geteuid() != 0: + cmd = [privilegeCmd] + cmd + process = subprocess.Popen(cmd) + startTime = time.time() + while time.time() - startTime < 30: + if select.select([sys.stdin], [], [], 0.0)[0]: + userInput = input() + process.terminate() + return True + time.sleep(0.1) + process.terminate() + return False + except Exception as e: + print(f"Error testing voice: {e}") + return False + +def update_fenrir_config(configPath, moduleName, voiceName, privilegeCmd): + """Update Fenrir configuration with new speech settings""" + config = configparser.ConfigParser() + try: + configContent = run_command_with_output(['cat', configPath], True, privilegeCmd) + if not configContent: + return False + with tempfile.NamedTemporaryFile(mode='w', delete=False) as tempFile: + tempPath = tempFile.name + tempFile.write(configContent) + config.read(tempPath) + if 'speech' not in config: + config['speech'] = {} + config['speech']['driver'] = 'speechdDriver' + config['speech']['module'] = moduleName + config['speech']['voice'] = voiceName + config['speech']['enabled'] = 'True' + config['speech']['rate'] = '0.25' + config['speech']['pitch'] = '0.5' + config['speech']['volume'] = '1.0' + with open(tempPath, 'w') as configFile: + config.write(configFile) + result = run_command(['cp', tempPath, configPath], True, privilegeCmd) + os.unlink(tempPath) + return result is not None + except Exception as e: + print(f"Error updating configuration: {e}") + if 'tempPath' in locals(): + try: + os.unlink(tempPath) + except: + pass + return False + +def main(): + configPath = '/etc/fenrirscreenreader/settings/settings.conf' + isRoot, privilegeCmd = check_root_access() + if not isRoot and not privilegeCmd: + print("Error: This script needs root privileges. Please run with sudo or doas.") + return + configExists = run_command(['test', '-f', configPath], True, privilegeCmd) is not None + if not configExists: + print(f"Configuration file not found: {configPath}") + return + while True: + print("\nFenrir Speech-Dispatcher Configuration") + print("=====================================") + print("\nGetting available speech-dispatcher modules...") + moduleList = get_speechd_modules(privilegeCmd) + if not moduleList: + print("No speech-dispatcher modules found!") + return + print("\nAvailable modules:") + for i, moduleName in enumerate(moduleList, 1): + print(f"{i}. {moduleName}") + while True: + try: + moduleChoice = int(input("\nSelect module number (or 0 to exit): ")) + if moduleChoice == 0: + return + if 1 <= moduleChoice <= len(moduleList): + selectedModule = moduleList[moduleChoice - 1] + break + print("Invalid selection!") + except ValueError: + print("Please enter a number!") + print(f"\nGetting voices for {selectedModule}...") + voiceList = get_module_voices(selectedModule, privilegeCmd) + if not voiceList: + print(f"No voices found for module {selectedModule}") + continue + selectedVoice = display_paginated_voices(voiceList) + if not selectedVoice: + continue + print("\nTesting selected voice configuration...") + if test_voice_config(selectedModule, selectedVoice, privilegeCmd): + print("\nVoice test successful!") + if update_fenrir_config(configPath, selectedModule, selectedVoice, privilegeCmd): + print("\nConfiguration updated successfully!") + print("Please restart Fenrir for changes to take effect") + return + else: + print("Failed to update configuration!") + else: + print("\nVoice test failed or timed out. Please try again.") + +if __name__ == "__main__": + main()