diff --git a/config/keyboard/desktop.conf b/config/keyboard/desktop.conf index 29b3eb02..a4491cbd 100644 --- a/config/keyboard/desktop.conf +++ b/config/keyboard/desktop.conf @@ -114,6 +114,8 @@ KEY_FENRIR,KEY_CTRL,KEY_SHIFT,KEY_X=remove_marks KEY_FENRIR,KEY_X=set_mark KEY_FENRIR,KEY_SHIFT,KEY_X=marked_text KEY_FENRIR,KEY_F10=toggle_vmenu_mode +KEY_FENRIR,KEY_SHIFT,KEY_F10=voice_browser_safe +KEY_FENRIR,KEY_CTRL,KEY_SHIFT,KEY_F10=apply_tested_voice KEY_FENRIR,KEY_SPACE=current_quick_menu_entry KEY_FENRIR,KEY_CTRL,KEY_SPACE=current_quick_menu_value KEY_FENRIR,KEY_RIGHT=next_quick_menu_entry diff --git a/config/keyboard/laptop.conf b/config/keyboard/laptop.conf index bec25378..1927d49e 100644 --- a/config/keyboard/laptop.conf +++ b/config/keyboard/laptop.conf @@ -113,6 +113,8 @@ KEY_FENRIR,KEY_CTRL,KEY_SHIFT,KEY_X=remove_marks KEY_FENRIR,KEY_X=set_mark KEY_FENRIR,KEY_SHIFT,KEY_X=marked_text KEY_FENRIR,KEY_F10=toggle_vmenu_mode +KEY_FENRIR,KEY_SHIFT,KEY_F10=voice_browser_safe +KEY_FENRIR,KEY_CTRL,KEY_SHIFT,KEY_F10=apply_tested_voice KEY_FENRIR,KEY_SPACE=current_quick_menu_entry KEY_FENRIR,KEY_CTRL,KEY_SPACE=current_quick_menu_value KEY_FENRIR,KEY_RIGHT=next_quick_menu_entry diff --git a/config/sound/default/BrailleOff.wav b/config/sound/default/BrailleOff.wav deleted file mode 100644 index 083fc283..00000000 Binary files a/config/sound/default/BrailleOff.wav and /dev/null differ diff --git a/config/sound/default/BrailleOn.wav b/config/sound/default/BrailleOn.wav deleted file mode 100644 index 00674359..00000000 Binary files a/config/sound/default/BrailleOn.wav and /dev/null differ diff --git a/config/sound/default/ErrorBraille.wav b/config/sound/default/ErrorBraille.wav deleted file mode 100644 index 189eda41..00000000 Binary files a/config/sound/default/ErrorBraille.wav and /dev/null differ diff --git a/config/sound/default/soundicons.conf b/config/sound/default/soundicons.conf index 8ad2c0ec..9987b4be 100644 --- a/config/sound/default/soundicons.conf +++ b/config/sound/default/soundicons.conf @@ -30,9 +30,6 @@ ContentChanged='ContentChanged.wav' # Speech has turned On or Off SpeechOn='SpeechOn.wav' SpeechOff='SpeechOff.wav' -# Braille has turned On or Off -BrailleOn='BrailleOn.wav' -BrailleOff='BrailleOff.wav' # SoundIcons has turned On or Off SoundOn='SoundOn.wav' SoundOff='SoundOff.wav' @@ -44,9 +41,8 @@ PlaceEndMark='PlaceEndMark.wav' CopyToClipboard='CopyToClipboard.wav' # Pasted on the screen PasteClipboardOnScreen='PasteClipboardOnScreen.wav' -# An error accoured while speech or braille output or reading the screen +# An error accoured while speech output or reading the screen ErrorSpeech='ErrorSpeech.wav' -ErrorBraille='ErrorBraille.wav' ErrorScreen='ErrorScreen.wav' # If you cursor over an text that has attributs (like color) HasAttributes='has_attribute.wav' diff --git a/src/fenrirscreenreader/commands/commands/apply_tested_voice.py b/src/fenrirscreenreader/commands/commands/apply_tested_voice.py new file mode 100644 index 00000000..53fb91a3 --- /dev/null +++ b/src/fenrirscreenreader/commands/commands/apply_tested_voice.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 + +class command(): + def __init__(self): + pass + + def initialize(self, environment): + self.env = environment + + def shutdown(self): + pass + + def getDescription(self): + return "Apply the last tested voice from safe voice browser" + + def run(self): + try: + # Check if we have a tested voice + if ('commandBuffer' not in self.env or + 'lastTestedModule' not in self.env['commandBuffer'] or + 'lastTestedVoice' not in self.env['commandBuffer']): + self.env['runtime']['outputManager'].presentText("No voice has been tested yet", interrupt=True) + self.env['runtime']['outputManager'].presentText("Use voice browser first", interrupt=True) + return + + module = self.env['commandBuffer']['lastTestedModule'] + voice = self.env['commandBuffer']['lastTestedVoice'] + + self.env['runtime']['outputManager'].presentText(f"Applying {voice} from {module}", interrupt=True) + + # Apply to runtime settings only (temporary until saved) + settingsManager = self.env['runtime']['settingsManager'] + + # Store old values for safety + oldDriver = settingsManager.getSetting('speech', 'driver') + oldModule = settingsManager.getSetting('speech', 'module') + oldVoice = settingsManager.getSetting('speech', 'voice') + + try: + # Apply new settings to runtime only + settingsManager.settings['speech']['driver'] = 'speechdDriver' + settingsManager.settings['speech']['module'] = module + settingsManager.settings['speech']['voice'] = voice + + # Try to reinitialize speech driver + if 'speechDriver' in self.env['runtime']: + speechDriver = self.env['runtime']['speechDriver'] + speechDriver.shutdown() + speechDriver.initialize(self.env) + + self.env['runtime']['outputManager'].presentText("Voice applied successfully!", interrupt=True) + self.env['runtime']['outputManager'].presentText("Use save settings to make permanent", interrupt=True) + self.env['runtime']['outputManager'].playSound('Accept') + + except Exception as e: + # Revert on failure + settingsManager.settings['speech']['driver'] = oldDriver + settingsManager.settings['speech']['module'] = oldModule + settingsManager.settings['speech']['voice'] = oldVoice + + self.env['runtime']['outputManager'].presentText(f"Failed to apply voice, reverted: {str(e)}", interrupt=True) + self.env['runtime']['outputManager'].playSound('Error') + + except Exception as e: + self.env['runtime']['outputManager'].presentText(f"Apply voice error: {str(e)}", interrupt=True) + self.env['runtime']['outputManager'].playSound('Error') + + def setCallback(self, callback): + pass \ No newline at end of file diff --git a/src/fenrirscreenreader/commands/commands/voice_browser.py b/src/fenrirscreenreader/commands/commands/voice_browser.py new file mode 100644 index 00000000..64d7806f --- /dev/null +++ b/src/fenrirscreenreader/commands/commands/voice_browser.py @@ -0,0 +1,255 @@ +#!/usr/bin/env python3 + +import subprocess +import time + +class command(): + def __init__(self): + pass + + def initialize(self, environment): + self.env = environment + self.testMessage = "This is a voice test. The quick brown fox jumps over the lazy dog." + self.modules = [] + self.voices = [] + self.moduleIndex = 0 + self.voiceIndex = 0 + self.browserActive = False + self.originalBindings = {} + self.lastAnnounceTime = 0 + + def shutdown(self): + pass + + def getDescription(self): + return "Interactive voice browser with arrow key navigation" + + def run(self): + if self.browserActive: + self.exitVoiceBrowser() + return + + self.env['runtime']['outputManager'].presentText("Starting voice browser", interrupt=True) + + # Load modules + self.modules = self.getSpeechdModules() + if not self.modules: + self.env['runtime']['outputManager'].presentText("No speech modules found", interrupt=True) + self.env['runtime']['outputManager'].playSound('Error') + return + + # Set current module + currentModule = self.env['runtime']['settingsManager'].getSetting('speech', 'module') + if currentModule and currentModule in self.modules: + self.moduleIndex = self.modules.index(currentModule) + + # Load voices + self.loadVoicesForCurrentModule() + + # Set current voice + currentVoice = self.env['runtime']['settingsManager'].getSetting('speech', 'voice') + if currentVoice and currentVoice in self.voices: + self.voiceIndex = self.voices.index(currentVoice) + + # Enter browser mode + self.enterVoiceBrowser() + self.announceCurrentSelection() + + def enterVoiceBrowser(self): + """Enter voice browser mode with custom key bindings""" + self.browserActive = True + + # Store original bindings + self.originalBindings = self.env['bindings'].copy() + + # Override navigation keys for voice browsing + # Use lambda to capture self and create bound methods + self.env['bindings'][str([1, ['KEY_UP']])] = 'VOICE_BROWSER_PREV_VOICE' + self.env['bindings'][str([1, ['KEY_DOWN']])] = 'VOICE_BROWSER_NEXT_VOICE' + self.env['bindings'][str([1, ['KEY_LEFT']])] = 'VOICE_BROWSER_PREV_MODULE' + self.env['bindings'][str([1, ['KEY_RIGHT']])] = 'VOICE_BROWSER_NEXT_MODULE' + self.env['bindings'][str([1, ['KEY_ENTER']])] = 'VOICE_BROWSER_TEST' + self.env['bindings'][str([1, ['KEY_SPACE']])] = 'VOICE_BROWSER_APPLY' + self.env['bindings'][str([1, ['KEY_ESC']])] = 'VOICE_BROWSER_EXIT' + + # Register browser commands + self.registerBrowserCommands() + + self.env['runtime']['outputManager'].presentText("Voice browser active", interrupt=True) + self.env['runtime']['outputManager'].presentText("Up/Down=voices, Left/Right=modules, Enter=test, Space=apply, Esc=exit", interrupt=True) + + def registerBrowserCommands(self): + """Register voice browser commands with the command manager""" + # Create command objects for voice browser actions + commandManager = self.env['runtime']['commandManager'] + + # This is a hack - we'll store references to our methods in the environment + # so the key handlers can call them + if 'voiceBrowserInstance' not in self.env['runtime']: + self.env['runtime']['voiceBrowserInstance'] = self + + def exitVoiceBrowser(self): + """Exit voice browser and restore normal key bindings""" + if not self.browserActive: + return + + self.browserActive = False + + # Restore original bindings + self.env['bindings'] = self.originalBindings + + # Clean up + if 'voiceBrowserInstance' in self.env['runtime']: + del self.env['runtime']['voiceBrowserInstance'] + + self.env['runtime']['outputManager'].presentText("Voice browser exited", interrupt=True) + + def loadVoicesForCurrentModule(self): + """Load voices for current module""" + if self.moduleIndex < len(self.modules): + module = self.modules[self.moduleIndex] + self.voices = self.getModuleVoices(module) + self.voiceIndex = 0 # Reset to first voice when changing modules + + def announceCurrentSelection(self): + """Announce current module and voice""" + # Throttle announcements to avoid spam + now = time.time() + if now - self.lastAnnounceTime < 0.3: + return + self.lastAnnounceTime = now + + if not self.modules or self.moduleIndex >= len(self.modules): + return + + module = self.modules[self.moduleIndex] + if self.voices and self.voiceIndex < len(self.voices): + voice = self.voices[self.voiceIndex] + self.env['runtime']['outputManager'].presentText( + f"{module}: {voice} ({self.voiceIndex + 1}/{len(self.voices)})", interrupt=True + ) + else: + self.env['runtime']['outputManager'].presentText(f"{module}: No voices", interrupt=True) + + def nextVoice(self): + """Move to next voice""" + if not self.voices: + return + self.voiceIndex = (self.voiceIndex + 1) % len(self.voices) + self.announceCurrentSelection() + + def prevVoice(self): + """Move to previous voice""" + if not self.voices: + return + self.voiceIndex = (self.voiceIndex - 1) % len(self.voices) + self.announceCurrentSelection() + + def nextModule(self): + """Move to next module""" + self.moduleIndex = (self.moduleIndex + 1) % len(self.modules) + self.loadVoicesForCurrentModule() + self.announceCurrentSelection() + + def prevModule(self): + """Move to previous module""" + self.moduleIndex = (self.moduleIndex - 1) % len(self.modules) + self.loadVoicesForCurrentModule() + self.announceCurrentSelection() + + def testVoice(self): + """Test current voice""" + if not self.voices or self.voiceIndex >= len(self.voices): + self.env['runtime']['outputManager'].presentText("No voice selected", interrupt=True) + return + + module = self.modules[self.moduleIndex] + voice = self.voices[self.voiceIndex] + + self.env['runtime']['outputManager'].presentText("Testing...", interrupt=True) + if self.previewVoice(module, voice): + # Store for apply command + self.env['commandBuffer']['lastTestedModule'] = module + self.env['commandBuffer']['lastTestedVoice'] = voice + self.env['runtime']['outputManager'].playSound('Accept') + else: + self.env['runtime']['outputManager'].playSound('Error') + + def applyVoice(self): + """Apply current voice to Fenrir""" + if not self.voices or self.voiceIndex >= len(self.voices): + return + + module = self.modules[self.moduleIndex] + voice = self.voices[self.voiceIndex] + + try: + settingsManager = self.env['runtime']['settingsManager'] + settingsManager.settings['speech']['driver'] = 'speechdDriver' + settingsManager.settings['speech']['module'] = module + settingsManager.settings['speech']['voice'] = voice + + if 'speechDriver' in self.env['runtime']: + speechDriver = self.env['runtime']['speechDriver'] + speechDriver.shutdown() + speechDriver.initialize(self.env) + + self.env['runtime']['outputManager'].presentText("Voice applied!", interrupt=True) + self.env['runtime']['outputManager'].playSound('Accept') + + except Exception as e: + self.env['runtime']['outputManager'].presentText("Apply failed", interrupt=True) + self.env['runtime']['outputManager'].playSound('Error') + + def previewVoice(self, module, voice): + """Test voice with spd-say""" + try: + cmd = ['spd-say', '-o', module, '-y', voice, self.testMessage] + result = subprocess.run(cmd, timeout=10) + return result.returncode == 0 + except Exception: + return False + + def getSpeechdModules(self): + """Get available speech modules""" + try: + result = subprocess.run(['spd-say', '-O'], capture_output=True, text=True, timeout=10) + if result.returncode == 0: + lines = result.stdout.strip().split('\n') + return [line.strip() for line in lines[1:] if line.strip()] + except Exception: + pass + return [] + + def getModuleVoices(self, module): + """Get voices for module""" + try: + result = subprocess.run(['spd-say', '-o', module, '-L'], capture_output=True, text=True, timeout=10) + if result.returncode == 0: + lines = result.stdout.strip().split('\n') + voices = [] + for line in lines[1:]: + if not line.strip(): + continue + if module.lower() == 'espeak-ng': + voice = self.processEspeakVoice(line) + if voice: + voices.append(voice) + else: + voices.append(line.strip()) + return voices + except Exception: + pass + return [] + + def processEspeakVoice(self, voiceLine): + """Process espeak voice format""" + parts = [p for p in voiceLine.split() if p] + if len(parts) < 2: + return None + langCode = parts[-2].lower() + variant = parts[-1].lower() + return f"{langCode}+{variant}" if variant and variant != 'none' else langCode + + def setCallback(self, callback): + pass \ No newline at end of file diff --git a/src/fenrirscreenreader/commands/commands/voice_browser_apply.py b/src/fenrirscreenreader/commands/commands/voice_browser_apply.py new file mode 100644 index 00000000..1f0048d4 --- /dev/null +++ b/src/fenrirscreenreader/commands/commands/voice_browser_apply.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 + +class command(): + def __init__(self): + pass + + def initialize(self, environment): + self.env = environment + + def shutdown(self): + pass + + def getDescription(self): + return "Voice browser: apply current voice" + + def run(self): + if 'voiceBrowserInstance' in self.env['runtime']: + self.env['runtime']['voiceBrowserInstance'].applyVoice() + + def setCallback(self, callback): + pass \ No newline at end of file diff --git a/src/fenrirscreenreader/commands/commands/voice_browser_exit.py b/src/fenrirscreenreader/commands/commands/voice_browser_exit.py new file mode 100644 index 00000000..c8d5d17d --- /dev/null +++ b/src/fenrirscreenreader/commands/commands/voice_browser_exit.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 + +class command(): + def __init__(self): + pass + + def initialize(self, environment): + self.env = environment + + def shutdown(self): + pass + + def getDescription(self): + return "Voice browser: exit browser mode" + + def run(self): + if 'voiceBrowserInstance' in self.env['runtime']: + self.env['runtime']['voiceBrowserInstance'].exitVoiceBrowser() + + def setCallback(self, callback): + pass \ No newline at end of file diff --git a/src/fenrirscreenreader/commands/commands/voice_browser_next_module.py b/src/fenrirscreenreader/commands/commands/voice_browser_next_module.py new file mode 100644 index 00000000..a2dc1d01 --- /dev/null +++ b/src/fenrirscreenreader/commands/commands/voice_browser_next_module.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 + +class command(): + def __init__(self): + pass + + def initialize(self, environment): + self.env = environment + + def shutdown(self): + pass + + def getDescription(self): + return "Voice browser: next module" + + def run(self): + if 'voiceBrowserInstance' in self.env['runtime']: + self.env['runtime']['voiceBrowserInstance'].nextModule() + + def setCallback(self, callback): + pass \ No newline at end of file diff --git a/src/fenrirscreenreader/commands/commands/voice_browser_next_voice.py b/src/fenrirscreenreader/commands/commands/voice_browser_next_voice.py new file mode 100644 index 00000000..5e56b42e --- /dev/null +++ b/src/fenrirscreenreader/commands/commands/voice_browser_next_voice.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 + +class command(): + def __init__(self): + pass + + def initialize(self, environment): + self.env = environment + + def shutdown(self): + pass + + def getDescription(self): + return "Voice browser: next voice" + + def run(self): + if 'voiceBrowserInstance' in self.env['runtime']: + self.env['runtime']['voiceBrowserInstance'].nextVoice() + + def setCallback(self, callback): + pass \ No newline at end of file diff --git a/src/fenrirscreenreader/commands/commands/voice_browser_prev_module.py b/src/fenrirscreenreader/commands/commands/voice_browser_prev_module.py new file mode 100644 index 00000000..b7add887 --- /dev/null +++ b/src/fenrirscreenreader/commands/commands/voice_browser_prev_module.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 + +class command(): + def __init__(self): + pass + + def initialize(self, environment): + self.env = environment + + def shutdown(self): + pass + + def getDescription(self): + return "Voice browser: previous module" + + def run(self): + if 'voiceBrowserInstance' in self.env['runtime']: + self.env['runtime']['voiceBrowserInstance'].prevModule() + + def setCallback(self, callback): + pass \ No newline at end of file diff --git a/src/fenrirscreenreader/commands/commands/voice_browser_prev_voice.py b/src/fenrirscreenreader/commands/commands/voice_browser_prev_voice.py new file mode 100644 index 00000000..24c858f5 --- /dev/null +++ b/src/fenrirscreenreader/commands/commands/voice_browser_prev_voice.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 + +class command(): + def __init__(self): + pass + + def initialize(self, environment): + self.env = environment + + def shutdown(self): + pass + + def getDescription(self): + return "Voice browser: previous voice" + + def run(self): + if 'voiceBrowserInstance' in self.env['runtime']: + self.env['runtime']['voiceBrowserInstance'].prevVoice() + + def setCallback(self, callback): + pass \ No newline at end of file diff --git a/src/fenrirscreenreader/commands/commands/voice_browser_safe.py b/src/fenrirscreenreader/commands/commands/voice_browser_safe.py new file mode 100644 index 00000000..2f59aaba --- /dev/null +++ b/src/fenrirscreenreader/commands/commands/voice_browser_safe.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 + +import subprocess +import threading +import time + +class command(): + def __init__(self): + pass + + def initialize(self, environment): + self.env = environment + self.testMessage = "Voice test: The quick brown fox jumps over the lazy dog." + + def shutdown(self): + pass + + def getDescription(self): + return "Safe voice browser - cycles through voices without hanging" + + def run(self): + try: + self.env['runtime']['outputManager'].presentText("Starting safe voice browser", interrupt=True) + + # Get modules with timeout protection + modules = self.getSpeechdModulesWithTimeout() + if not modules: + self.env['runtime']['outputManager'].presentText("No speech modules found", interrupt=True) + return + + # Get current position from commandBuffer or start fresh + moduleIndex = self.env['commandBuffer'].get('safeBrowserModuleIndex', 0) + voiceIndex = self.env['commandBuffer'].get('safeBrowserVoiceIndex', 0) + + # Ensure valid module index + if moduleIndex >= len(modules): + moduleIndex = 0 + + currentModule = modules[moduleIndex] + self.env['runtime']['outputManager'].presentText(f"Loading voices for {currentModule}...", interrupt=True) + + # Get voices with timeout protection + voices = self.getModuleVoicesWithTimeout(currentModule) + if not voices: + self.env['runtime']['outputManager'].presentText(f"No voices in {currentModule}, trying next module", interrupt=True) + moduleIndex = (moduleIndex + 1) % len(modules) + self.env['commandBuffer']['safeBrowserModuleIndex'] = moduleIndex + self.env['commandBuffer']['safeBrowserVoiceIndex'] = 0 + return + + # Ensure valid voice index + if voiceIndex >= len(voices): + voiceIndex = 0 + + currentVoice = voices[voiceIndex] + + # Announce current selection + self.env['runtime']['outputManager'].presentText( + f"Module: {currentModule} ({moduleIndex + 1}/{len(modules)})", interrupt=True + ) + self.env['runtime']['outputManager'].presentText( + f"Voice: {currentVoice} ({voiceIndex + 1}/{len(voices)})", interrupt=True + ) + + # Test voice in background thread to avoid blocking + self.env['runtime']['outputManager'].presentText("Testing voice...", interrupt=True) + + # Use threading to prevent freezing + testThread = threading.Thread(target=self.testVoiceAsync, args=(currentModule, currentVoice)) + testThread.daemon = True + testThread.start() + + # Store tested voice for apply command + self.env['commandBuffer']['lastTestedModule'] = currentModule + self.env['commandBuffer']['lastTestedVoice'] = currentVoice + + # Advance to next voice for next run + voiceIndex += 1 + if voiceIndex >= len(voices): + voiceIndex = 0 + moduleIndex = (moduleIndex + 1) % len(modules) + + # Store position for next run + self.env['commandBuffer']['safeBrowserModuleIndex'] = moduleIndex + self.env['commandBuffer']['safeBrowserVoiceIndex'] = voiceIndex + + # Give instructions + self.env['runtime']['outputManager'].presentText("Run again for next voice, or use apply voice command", interrupt=True) + + except Exception as e: + self.env['runtime']['outputManager'].presentText(f"Voice browser error: {str(e)}", interrupt=True) + self.env['runtime']['outputManager'].playSound('Error') + + def testVoiceAsync(self, module, voice): + """Test voice in background thread to avoid blocking""" + try: + # Run with strict timeout + cmd = ['spd-say', '-o', module, '-y', voice, self.testMessage] + result = subprocess.run(cmd, timeout=5, capture_output=True) + + # Schedule success sound for main thread + if result.returncode == 0: + # We can't call outputManager from background thread safely + # So we'll just let the main thread handle feedback + pass + + except subprocess.TimeoutExpired: + # Voice test timed out - this is okay, don't crash + pass + except Exception: + # Any other error - also okay, don't crash + pass + + def getSpeechdModulesWithTimeout(self): + """Get speech modules with timeout protection""" + try: + result = subprocess.run(['spd-say', '-O'], capture_output=True, text=True, timeout=3) + if result.returncode == 0: + lines = result.stdout.strip().split('\n') + modules = [line.strip() for line in lines[1:] if line.strip()] + return modules[:10] # Limit to first 10 modules to prevent overload + except subprocess.TimeoutExpired: + self.env['runtime']['outputManager'].presentText("Module detection timed out", interrupt=True) + except Exception as e: + self.env['runtime']['outputManager'].presentText(f"Module detection failed: {str(e)}", interrupt=True) + return [] + + def getModuleVoicesWithTimeout(self, module): + """Get voices with timeout and limits""" + try: + result = subprocess.run(['spd-say', '-o', module, '-L'], capture_output=True, text=True, timeout=5) + if result.returncode == 0: + lines = result.stdout.strip().split('\n') + voices = [] + for line in lines[1:]: + if not line.strip(): + continue + if module.lower() == 'espeak-ng': + voice = self.processEspeakVoice(line) + if voice: + voices.append(voice) + else: + voices.append(line.strip()) + + # Limit voice count to prevent memory issues + if len(voices) > 1000: + self.env['runtime']['outputManager'].presentText(f"Found {len(voices)} voices, limiting to first 1000", interrupt=True) + voices = voices[:1000] + + return voices + except subprocess.TimeoutExpired: + self.env['runtime']['outputManager'].presentText(f"Voice detection for {module} timed out", interrupt=True) + except Exception as e: + self.env['runtime']['outputManager'].presentText(f"Voice detection failed: {str(e)}", interrupt=True) + return [] + + def processEspeakVoice(self, voiceLine): + """Process espeak voice format""" + try: + parts = [p for p in voiceLine.split() if p] + if len(parts) < 2: + return None + langCode = parts[-2].lower() + variant = parts[-1].lower() + return f"{langCode}+{variant}" if variant and variant != 'none' else langCode + except Exception: + return None + + def setCallback(self, callback): + pass \ No newline at end of file diff --git a/src/fenrirscreenreader/commands/commands/voice_browser_test.py b/src/fenrirscreenreader/commands/commands/voice_browser_test.py new file mode 100644 index 00000000..9c023de4 --- /dev/null +++ b/src/fenrirscreenreader/commands/commands/voice_browser_test.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 + +class command(): + def __init__(self): + pass + + def initialize(self, environment): + self.env = environment + + def shutdown(self): + pass + + def getDescription(self): + return "Voice browser: test current voice" + + def run(self): + if 'voiceBrowserInstance' in self.env['runtime']: + self.env['runtime']['voiceBrowserInstance'].testVoice() + + def setCallback(self, callback): + pass \ No newline at end of file diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/__init__.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/__init__.py new file mode 100644 index 00000000..1c602751 --- /dev/null +++ b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/__init__.py @@ -0,0 +1 @@ +# Fenrir Configuration VMenu Profile \ No newline at end of file diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/config_base.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/config_base.py new file mode 100644 index 00000000..135a1234 --- /dev/null +++ b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/config_base.py @@ -0,0 +1,302 @@ +#!/usr/bin/env python3 + +import os +import configparser +import subprocess +import tempfile +from typing import Dict, List, Optional, Tuple, Any + +class ConfigCommand: + """Base class for Fenrir configuration vmenu commands""" + + def __init__(self): + self.env = None + self.config = None + self.settingsFile = None + + # Configuration presets from original configure_fenrir.py + self.presetOptions = { + 'sound.driver': ['genericDriver', 'gstreamerDriver'], + 'speech.driver': ['speechdDriver', 'genericDriver'], + 'screen.driver': ['vcsaDriver', 'dummyDriver', 'ptyDriver', 'debugDriver'], + 'keyboard.driver': ['evdevDriver', 'dummyDriver'], + 'remote.driver': ['unixDriver', 'tcpDriver'], + 'keyboard.charEchoMode': ['0', '1', '2'], + 'general.punctuationLevel': ['none', 'some', 'most', 'all'], + 'general.debugMode': ['File', 'Print'], + 'keyboard.device': ['ALL', 'NOMICE'], + 'screen.encoding': ['auto', 'utf-8', 'cp1252', 'iso-8859-1'] + } + + # Help text for various options + self.helpText = { + 'sound.volume': 'Volume level from 0 (quietest) to 1.0 (loudest)', + 'speech.rate': 'Speech rate from 0 (slowest) to 1.0 (fastest)', + 'speech.pitch': 'Voice pitch from 0 (lowest) to 1.0 (highest)', + 'speech.capitalPitch': 'Pitch for capital letters from 0 to 1.0', + 'keyboard.charEchoMode': '0 = None, 1 = always, 2 = only while capslock', + 'keyboard.doubleTapTimeout': 'Timeout for double tap in seconds', + 'screen.screenUpdateDelay': 'Delay between screen updates in seconds', + 'general.punctuationLevel': 'none = no punctuation, some = basic, most = detailed, all = everything', + 'general.numberOfClipboards': 'Number of clipboard slots to maintain' + } + + def initialize(self, environment): + """Initialize with Fenrir environment""" + self.env = environment + self.settingsFile = self.env['runtime']['settingsManager'].settingsFile + self.config = configparser.ConfigParser(interpolation=None) + self.config.read(self.settingsFile) + + def shutdown(self): + """Cleanup resources""" + pass + + def getDescription(self): + """Return description for this configuration action""" + return "Base configuration command" + + def run(self): + """Execute the configuration action - to be overridden""" + self.presentText("Configuration base class - override run() method") + + def presentText(self, text: str, interrupt: bool = True): + """Present text using Fenrir's speech system""" + if self.env and 'runtime' in self.env and 'outputManager' in self.env['runtime']: + self.env['runtime']['outputManager'].presentText(text, interrupt=interrupt) + + def presentConfirmation(self, text: str): + """Present confirmation message that won't be interrupted by menu navigation""" + self.presentText(text, interrupt=False) + + def playSound(self, soundName: str): + """Play a sound using Fenrir's sound system (deprecated - sounds removed from vmenu)""" + # Sounds removed from vmenu commands to avoid configuration issues + pass + + def getSetting(self, section: str, option: str, default: str = "") -> str: + """Get a setting value from configuration""" + try: + if section in self.config and option in self.config[section]: + return self.config[section][option] + return default + except Exception: + return default + + def setSetting(self, section: str, option: str, value: str) -> bool: + """Set a setting value and save configuration""" + try: + if section not in self.config: + self.config[section] = {} + + oldValue = self.getSetting(section, option) + self.config[section][option] = str(value) + + # Write configuration file + with open(self.settingsFile, 'w') as configfile: + self.config.write(configfile) + + # Apply setting immediately if possible + self.applySettingImmediate(section, option, value, oldValue) + + return True + except Exception as e: + self.presentText(f"Error saving setting: {str(e)}") + return False + + def applySettingImmediate(self, section: str, option: str, newValue: str, oldValue: str): + """Apply setting immediately without restart where possible""" + try: + # Apply speech settings immediately + if section == 'speech': + if option in ['rate', 'pitch', 'volume']: + settingsManager = self.env['runtime']['settingsManager'] + settingsManager.setSetting(section, option, newValue) + + # Apply sound settings immediately + elif section == 'sound': + if option == 'volume': + settingsManager = self.env['runtime']['settingsManager'] + settingsManager.setSetting(section, option, newValue) + + except Exception as e: + # Silent fail - settings will apply on restart + pass + + def validateInput(self, section: str, option: str, value: str) -> Tuple[bool, str]: + """Validate user input based on option type and constraints""" + try: + # Validate float values (volume, rate, pitch) + if option in ['volume', 'rate', 'pitch', 'capitalPitch']: + floatVal = float(value) + if not 0 <= floatVal <= 1.0: + return False, "Value must be between 0 and 1.0" + return True, str(floatVal) + + # Validate integer values + elif option in ['numberOfClipboards', 'ignoreScreen']: + intVal = int(value) + if intVal < 0: + return False, "Value must be 0 or greater" + return True, str(intVal) + + # Validate float values with different ranges + elif option == 'doubleTapTimeout': + floatVal = float(value) + if not 0 <= floatVal <= 2.0: + return False, "Value must be between 0 and 2.0 seconds" + return True, str(floatVal) + + elif option == 'screenUpdateDelay': + floatVal = float(value) + if not 0 <= floatVal <= 1.0: + return False, "Value must be between 0 and 1.0 seconds" + return True, str(floatVal) + + # Validate boolean values + elif self.isBooleanOption(value): + if value.lower() in ['true', 'false']: + return True, value.capitalize() + return False, "Value must be True or False" + + # Validate preset options + key = f"{section}.{option}" + if key in self.presetOptions: + if value in self.presetOptions[key]: + return True, value + return False, f"Value must be one of: {', '.join(self.presetOptions[key])}" + + # Default validation - accept any string + return True, str(value).strip() + + except ValueError: + return False, "Invalid number format" + except Exception as e: + return False, f"Validation error: {str(e)}" + + def isBooleanOption(self, value: str) -> bool: + """Check if the current value is likely a boolean option""" + return value.lower() in ['true', 'false'] + + def getBooleanSetting(self, section: str, option: str, default: bool = False) -> bool: + """Get a boolean setting value""" + value = self.getSetting(section, option, str(default)).lower() + return value in ['true', '1', 'yes', 'on'] + + def setBooleanSetting(self, section: str, option: str, value: bool) -> bool: + """Set a boolean setting value""" + return self.setSetting(section, option, 'True' if value else 'False') + + def toggleBooleanSetting(self, section: str, option: str) -> bool: + """Toggle a boolean setting and return new value""" + currentValue = self.getBooleanSetting(section, option) + newValue = not currentValue + success = self.setBooleanSetting(section, option, newValue) + return newValue if success else currentValue + + def getFloatSetting(self, section: str, option: str, default: float = 0.0) -> float: + """Get a float setting value""" + try: + return float(self.getSetting(section, option, str(default))) + except ValueError: + return default + + def setFloatSetting(self, section: str, option: str, value: float) -> bool: + """Set a float setting value""" + return self.setSetting(section, option, str(value)) + + def adjustFloatSetting(self, section: str, option: str, delta: float, + minVal: float = 0.0, maxVal: float = 1.0) -> float: + """Adjust a float setting by delta amount""" + currentValue = self.getFloatSetting(section, option) + newValue = max(minVal, min(maxVal, currentValue + delta)) + success = self.setFloatSetting(section, option, newValue) + return newValue if success else currentValue + + def runCommand(self, cmd: List[str], timeout: int = 10) -> Optional[str]: + """Run a command and return output""" + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + return result.stdout.strip() if result.returncode == 0 else None + except Exception: + return None + + def getSpeechdModules(self) -> List[str]: + """Get list of available speech-dispatcher modules""" + output = self.runCommand(['spd-say', '-O']) + if output: + lines = output.split('\n') + return [line.strip() for line in lines[1:] if line.strip()] + return [] + + def getModuleVoices(self, module: str) -> List[str]: + """Get list of voices for a specific speech module""" + output = self.runCommand(['spd-say', '-o', module, '-L']) + if output: + lines = output.split('\n') + voices = [] + for line in lines[1:]: + if not line.strip(): + continue + if module.lower() == 'espeak-ng': + voice = self.processEspeakVoice(line) + if voice: + voices.append(voice) + else: + voices.append(line.strip()) + return voices + return [] + + def processEspeakVoice(self, voiceLine: str) -> Optional[str]: + """Process espeak-ng voice line format""" + parts = [p for p in voiceLine.split() if p] + if len(parts) < 2: + return None + langCode = parts[-2].lower() + variant = parts[-1].lower() + return f"{langCode}+{variant}" if variant and variant != 'none' else langCode + + def testVoice(self, module: str, voice: str, testMessage: str = None) -> bool: + """Test a voice configuration""" + if not testMessage: + testMessage = "This is a voice test. If you can hear this clearly, the voice is working properly." + + try: + # Announce the test + self.presentText("Testing voice configuration. Listen for the test message.") + + # Run voice test + cmd = ['spd-say', '-o', module, '-y', voice, testMessage] + result = subprocess.run(cmd, timeout=10) + + return result.returncode == 0 + except Exception: + return False + + def backupConfig(self, announce: bool = True) -> tuple[bool, str]: + """Create a backup of current configuration""" + try: + import shutil + from datetime import datetime + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + backupFile = f"{self.settingsFile}.backup_{timestamp}" + shutil.copy2(self.settingsFile, backupFile) + + message = f"Configuration backed up to {backupFile}" + if announce: + self.presentText(message, interrupt=False) + return True, message + except Exception as e: + error_msg = f"Error creating backup: {str(e)}" + if announce: + self.presentText(error_msg, interrupt=False) + return False, error_msg + + def reloadConfig(self): + """Reload configuration from file""" + try: + self.config.read(self.settingsFile) + return True + except Exception: + return False \ No newline at end of file diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/__init__.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/__init__.py new file mode 100644 index 00000000..f3594ed4 --- /dev/null +++ b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/__init__.py @@ -0,0 +1 @@ +# Fenrir general configuration diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/set_punctuation_level.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/set_punctuation_level.py new file mode 100644 index 00000000..1f03fdee --- /dev/null +++ b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/set_punctuation_level.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 + +import os +import importlib.util + +# Load base configuration class +_base_path = os.path.join(os.path.dirname(__file__), '..', 'config_base.py') +_spec = importlib.util.spec_from_file_location("config_base", _base_path) +_module = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_module) +ConfigCommand = _module.ConfigCommand + +class command(ConfigCommand): + def __init__(self): + super().__init__() + + def getDescription(self): + return "Set punctuation verbosity level" + + def run(self): + currentLevel = self.getSetting('general', 'punctuationLevel', 'some') + + # Present current level + levelDescriptions = { + 'none': 'None - no punctuation spoken', + 'some': 'Some - basic punctuation only', + 'most': 'Most - detailed punctuation', + 'all': 'All - every punctuation mark' + } + + currentDescription = levelDescriptions.get(currentLevel, 'Unknown') + self.presentText(f"Current punctuation level: {currentDescription}") + + # Cycle through the four levels + levels = ['none', 'some', 'most', 'all'] + try: + currentIndex = levels.index(currentLevel) + nextIndex = (currentIndex + 1) % len(levels) + newLevel = levels[nextIndex] + except ValueError: + newLevel = 'some' # Default to some + + success = self.setSetting('general', 'punctuationLevel', newLevel) + + if success: + newDescription = levelDescriptions[newLevel] + self.presentText(f"Punctuation level set to: {newDescription}") + self.playSound('Accept') + else: + self.presentText("Failed to change punctuation level") + self.playSound('Error') \ No newline at end of file diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/toggle_debug.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/toggle_debug.py new file mode 100644 index 00000000..8a5e077a --- /dev/null +++ b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/general/toggle_debug.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 + +import os +import importlib.util + +# Load base configuration class +_base_path = os.path.join(os.path.dirname(__file__), '..', 'config_base.py') +_spec = importlib.util.spec_from_file_location("config_base", _base_path) +_module = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_module) +ConfigCommand = _module.ConfigCommand + +class command(ConfigCommand): + def __init__(self): + super().__init__() + + def getDescription(self): + return "Toggle debug mode" + + def run(self): + currentLevel = self.getSetting('general', 'debugLevel', '0') + + # Present current debug level + if currentLevel == '0': + self.presentText("Current debug mode: disabled") + newLevel = '1' + stateText = "enabled" + else: + self.presentText(f"Current debug level: {currentLevel}") + newLevel = '0' + stateText = "disabled" + + success = self.setSetting('general', 'debugLevel', newLevel) + + if success: + self.presentText(f"Debug mode {stateText}") + if newLevel != '0': + debugMode = self.getSetting('general', 'debugMode', 'File') + if debugMode == 'File': + self.presentText("Debug output will be written to log file") + else: + self.presentText("Debug output will be printed to console") + self.playSound('Accept') + else: + self.presentText("Failed to change debug mode") + self.playSound('Error') \ No newline at end of file diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/__init__.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/__init__.py new file mode 100644 index 00000000..e130a0e1 --- /dev/null +++ b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/__init__.py @@ -0,0 +1 @@ +# Fenrir keyboard configuration diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/select_layout.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/select_layout.py new file mode 100644 index 00000000..49d3f7f4 --- /dev/null +++ b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/select_layout.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 + +import os +import importlib.util + +# Load base configuration class +_base_path = os.path.join(os.path.dirname(__file__), '..', 'config_base.py') +_spec = importlib.util.spec_from_file_location("config_base", _base_path) +_module = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_module) +ConfigCommand = _module.ConfigCommand + +class command(ConfigCommand): + def __init__(self): + super().__init__() + + def getDescription(self): + return "Select keyboard layout (desktop or laptop)" + + def run(self): + currentLayout = self.getSetting('keyboard', 'keyboardLayout', 'desktop') + + # Present current layout + self.presentText(f"Current keyboard layout: {currentLayout}") + + # Find available keyboard layouts + keyboardPath = '/etc/fenrirscreenreader/keyboard' + if not os.path.isdir(keyboardPath): + # Development path + keyboardPath = os.path.join(os.path.dirname(self.settingsFile), '..', 'keyboard') + + availableLayouts = self.getAvailableLayouts(keyboardPath) + + if len(availableLayouts) > 1: + # Cycle through available layouts + try: + currentIndex = availableLayouts.index(currentLayout) + nextIndex = (currentIndex + 1) % len(availableLayouts) + newLayout = availableLayouts[nextIndex] + except ValueError: + # Current layout not found, use first available + newLayout = availableLayouts[0] + + success = self.setSetting('keyboard', 'keyboardLayout', newLayout) + + if success: + self.presentText(f"Keyboard layout changed to: {newLayout}") + self.presentText("Please restart Fenrir for this change to take effect.") + self.playSound('Accept') + else: + self.presentText("Failed to change keyboard layout") + self.playSound('Error') + else: + self.presentText("Only default keyboard layout is available") + self.playSound('Cancel') + + def getAvailableLayouts(self, keyboardPath): + """Find available keyboard layout files""" + layouts = [] + + if os.path.isdir(keyboardPath): + try: + for file in os.listdir(keyboardPath): + if file.endswith('.conf') and not file.startswith('.'): + layoutName = file[:-5] # Remove .conf extension + layouts.append(layoutName) + except Exception: + pass + + # Ensure we have at least the default layouts + if not layouts: + layouts = ['desktop', 'laptop'] + + return sorted(layouts) \ No newline at end of file diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/set_char_echo.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/set_char_echo.py new file mode 100644 index 00000000..523df8b5 --- /dev/null +++ b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/set_char_echo.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 + +import os +import importlib.util + +# Load base configuration class +_base_path = os.path.join(os.path.dirname(__file__), '..', 'config_base.py') +_spec = importlib.util.spec_from_file_location("config_base", _base_path) +_module = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_module) +ConfigCommand = _module.ConfigCommand + +class command(ConfigCommand): + def __init__(self): + super().__init__() + + def getDescription(self): + return "Set character echo mode" + + def run(self): + currentMode = self.getSetting('keyboard', 'charEchoMode', '1') + + # Present current mode + modeDescriptions = { + '0': 'None - no character echo', + '1': 'Always - echo all typed characters', + '2': 'Caps Lock - echo only when caps lock is on' + } + + currentDescription = modeDescriptions.get(currentMode, 'Unknown') + self.presentText(f"Current character echo mode: {currentDescription}") + + # Cycle through the three modes + modes = ['0', '1', '2'] + try: + currentIndex = modes.index(currentMode) + nextIndex = (currentIndex + 1) % len(modes) + newMode = modes[nextIndex] + except ValueError: + newMode = '1' # Default to always + + success = self.setSetting('keyboard', 'charEchoMode', newMode) + + if success: + newDescription = modeDescriptions[newMode] + self.presentText(f"Character echo mode set to: {newDescription}") + self.playSound('Accept') + else: + self.presentText("Failed to change character echo mode") + self.playSound('Error') \ No newline at end of file diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/toggle_grab_devices.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/toggle_grab_devices.py new file mode 100644 index 00000000..fc10dd12 --- /dev/null +++ b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/toggle_grab_devices.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 + +import os +import importlib.util + +# Load base configuration class +_base_path = os.path.join(os.path.dirname(__file__), '..', 'config_base.py') +_spec = importlib.util.spec_from_file_location("config_base", _base_path) +_module = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_module) +ConfigCommand = _module.ConfigCommand + +class command(ConfigCommand): + def __init__(self): + super().__init__() + + def getDescription(self): + return "Toggle exclusive keyboard access" + + def run(self): + currentState = self.getBooleanSetting('keyboard', 'grabDevices', True) + newState = self.toggleBooleanSetting('keyboard', 'grabDevices') + + if newState != currentState: + stateText = "enabled" if newState else "disabled" + self.presentText(f"Exclusive keyboard access {stateText}") + if newState: + self.presentText("Fenrir will have exclusive control of keyboard input") + else: + self.presentText("Fenrir will share keyboard input with other applications") + self.presentText("Please restart Fenrir for this change to take effect") + self.playSound('Accept') + else: + self.presentText("Failed to change keyboard grab setting") + self.playSound('Error') \ No newline at end of file diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/toggle_word_echo.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/toggle_word_echo.py new file mode 100644 index 00000000..14202636 --- /dev/null +++ b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/keyboard/toggle_word_echo.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 + +import os +import importlib.util + +# Load base configuration class +_base_path = os.path.join(os.path.dirname(__file__), '..', 'config_base.py') +_spec = importlib.util.spec_from_file_location("config_base", _base_path) +_module = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_module) +ConfigCommand = _module.ConfigCommand + +class command(ConfigCommand): + def __init__(self): + super().__init__() + + def getDescription(self): + return "Toggle word echo when pressing space" + + def run(self): + currentState = self.getBooleanSetting('keyboard', 'wordEcho', False) + newState = self.toggleBooleanSetting('keyboard', 'wordEcho') + + if newState != currentState: + stateText = "enabled" if newState else "disabled" + self.presentText(f"Word echo {stateText}") + if newState: + self.presentText("Words will be spoken when you press space") + else: + self.presentText("Words will not be spoken when you press space") + self.playSound('Accept') + else: + self.presentText("Failed to change word echo setting") + self.playSound('Error') \ No newline at end of file diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/__init__.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/__init__.py new file mode 100644 index 00000000..cbfdfd39 --- /dev/null +++ b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/__init__.py @@ -0,0 +1 @@ +# Fenrir management configuration diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/backup_config.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/backup_config.py new file mode 100644 index 00000000..7aa588ba --- /dev/null +++ b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/backup_config.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 + +import os +import importlib.util + +# Load base configuration class +_base_path = os.path.join(os.path.dirname(__file__), '..', 'config_base.py') +_spec = importlib.util.spec_from_file_location("config_base", _base_path) +_module = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_module) +ConfigCommand = _module.ConfigCommand + +class command(ConfigCommand): + def __init__(self): + super().__init__() + + def getDescription(self): + return "Create backup of current configuration" + + def run(self): + self.presentText("Creating configuration backup...") + + success, message = self.backupConfig(announce=False) + + if success: + # Force the message to be queued and spoken + self.env['runtime']['outputManager'].presentText("Configuration backup created successfully", interrupt=False, flush=False) + else: + self.env['runtime']['outputManager'].presentText("Failed to create configuration backup", interrupt=False, flush=False) \ No newline at end of file diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/reload_config.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/reload_config.py new file mode 100644 index 00000000..7f0c3935 --- /dev/null +++ b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/reload_config.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 + +import os +import importlib.util + +# Load base configuration class +_base_path = os.path.join(os.path.dirname(__file__), '..', 'config_base.py') +_spec = importlib.util.spec_from_file_location("config_base", _base_path) +_module = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_module) +ConfigCommand = _module.ConfigCommand + +class command(ConfigCommand): + def __init__(self): + super().__init__() + + def getDescription(self): + return "Reload configuration from file" + + def run(self): + self.presentText("Reloading configuration from file...") + + success = self.reloadConfig() + + if success: + self.presentText("Configuration reloaded successfully") + self.playSound('Accept') + else: + self.presentText("Failed to reload configuration") + self.playSound('Error') \ No newline at end of file diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/reset_defaults.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/reset_defaults.py new file mode 100644 index 00000000..29388aa0 --- /dev/null +++ b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/reset_defaults.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 + +import os +import importlib.util +import shutil + +# Load base configuration class +_base_path = os.path.join(os.path.dirname(__file__), '..', 'config_base.py') +_spec = importlib.util.spec_from_file_location("config_base", _base_path) +_module = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_module) +ConfigCommand = _module.ConfigCommand + +class command(ConfigCommand): + def __init__(self): + super().__init__() + + def getDescription(self): + return "Reset configuration to default settings" + + def run(self): + self.presentText("WARNING: This will reset all settings to defaults") + self.presentText("Creating backup before reset...") + + # Create backup first + backupSuccess, backupMessage = self.backupConfig(announce=False) + if not backupSuccess: + self.presentText("Failed to create backup. Reset cancelled for safety.", interrupt=False) + self.playSound('Error') + return + + # Find default configuration file + defaultConfigPath = self.findDefaultConfig() + + if defaultConfigPath and os.path.isfile(defaultConfigPath): + try: + # Copy default configuration over current + shutil.copy2(defaultConfigPath, self.settingsFile) + + # Reload the configuration + self.reloadConfig() + + self.presentText("Configuration reset to defaults successfully", interrupt=False, flush=False) + self.presentText("Please restart Fenrir for all changes to take effect", interrupt=False, flush=False) + + except Exception as e: + self.presentText(f"Failed to reset configuration: {str(e)}", interrupt=False, flush=False) + else: + # Manually create basic default configuration + self.createBasicDefaults() + + def findDefaultConfig(self): + """Find the default configuration file""" + possiblePaths = [ + '/usr/share/fenrirscreenreader/config/settings/settings.conf', + '/etc/fenrirscreenreader/settings/settings.conf.default', + os.path.join(os.path.dirname(self.settingsFile), 'settings.conf.default'), + # Development path + os.path.join(os.path.dirname(__file__), '..', '..', '..', '..', '..', '..', 'config', 'settings', 'settings.conf') + ] + + for path in possiblePaths: + if os.path.isfile(path): + return path + + return None + + def createBasicDefaults(self): + """Create basic default configuration manually""" + try: + self.config.clear() + + # Basic speech defaults + self.config['speech'] = { + 'enabled': 'True', + 'driver': 'speechdDriver', + 'rate': '0.5', + 'pitch': '0.5', + 'volume': '1.0', + 'autoReadIncoming': 'True' + } + + # Basic sound defaults + self.config['sound'] = { + 'enabled': 'True', + 'driver': 'genericDriver', + 'theme': 'default', + 'volume': '0.7' + } + + # Basic keyboard defaults + self.config['keyboard'] = { + 'driver': 'evdevDriver', + 'device': 'ALL', + 'keyboardLayout': 'desktop', + 'charEchoMode': '1', + 'wordEcho': 'False', + 'charDeleteEcho': 'True' + } + + # Basic screen defaults + self.config['screen'] = { + 'driver': 'vcsaDriver', + 'encoding': 'auto' + } + + # Basic general defaults + self.config['general'] = { + 'punctuationLevel': 'some', + 'debugLevel': '0', + 'numberOfClipboards': '50' + } + + # Write the configuration + with open(self.settingsFile, 'w') as configfile: + self.config.write(configfile) + + self.presentText("Basic default configuration created", interrupt=False, flush=False) + self.presentText("Please restart Fenrir for changes to take effect", interrupt=False, flush=False) + + except Exception as e: + self.presentText(f"Failed to create default configuration: {str(e)}", interrupt=False, flush=False) \ No newline at end of file diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/revert_to_saved.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/revert_to_saved.py new file mode 100644 index 00000000..5d523f00 --- /dev/null +++ b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/revert_to_saved.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 + +class command(): + def __init__(self): + pass + + def initialize(self, environment): + self.env = environment + + def shutdown(self): + pass + + def getDescription(self): + return "Discard temporary changes and revert to saved settings" + + def run(self): + self.env['runtime']['outputManager'].presentText("Reverting to saved configuration...", interrupt=True) + + try: + # Reload settings from file, discarding runtime changes + settingsManager = self.env['runtime']['settingsManager'] + settingsManager.loadSettings() + + # Reinitialize speech system with restored settings + if 'speechDriver' in self.env['runtime']: + try: + speechDriver = self.env['runtime']['speechDriver'] + speechDriver.shutdown() + speechDriver.initialize(self.env) + except: + pass + + # Reinitialize sound system with restored settings + if 'soundDriver' in self.env['runtime']: + try: + soundDriver = self.env['runtime']['soundDriver'] + soundDriver.shutdown() + soundDriver.initialize(self.env) + except: + pass + + self.env['runtime']['outputManager'].presentText("Successfully reverted to saved settings", interrupt=False, flush=False) + self.env['runtime']['outputManager'].presentText("All temporary changes have been discarded", interrupt=False, flush=False) + + except Exception as e: + self.env['runtime']['outputManager'].presentText(f"Error reverting settings: {str(e)}", interrupt=False, flush=False) + self.env['runtime']['outputManager'].presentText("You may need to restart Fenrir", interrupt=False, flush=False) + + def setCallback(self, callback): + pass \ No newline at end of file diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/save_config.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/save_config.py new file mode 100644 index 00000000..85cb011b --- /dev/null +++ b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/save_config.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 + +import os +import importlib.util + +# Load base configuration class +_base_path = os.path.join(os.path.dirname(__file__), '..', 'config_base.py') +_spec = importlib.util.spec_from_file_location("config_base", _base_path) +_module = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_module) +ConfigCommand = _module.ConfigCommand + +class command(ConfigCommand): + def __init__(self): + super().__init__() + + def getDescription(self): + return "Save current configuration to file" + + def run(self): + self.presentText("Saving current configuration...") + + try: + # Force reload and save of current configuration + self.reloadConfig() + + # Write the configuration file + with open(self.settingsFile, 'w') as configfile: + self.config.write(configfile) + + self.presentText("Configuration saved successfully") + self.playSound('Accept') + + except Exception as e: + self.presentText(f"Failed to save configuration: {str(e)}") + self.playSound('Error') \ No newline at end of file diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/save_session_settings.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/save_session_settings.py new file mode 100644 index 00000000..262c16fa --- /dev/null +++ b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/save_session_settings.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 + +class command(): + def __init__(self): + pass + + def initialize(self, environment): + self.env = environment + + def shutdown(self): + pass + + def getDescription(self): + return "Save current session settings to configuration file" + + def run(self): + self.env['runtime']['outputManager'].presentText("Saving current session settings to configuration file...", interrupt=True) + + try: + # This calls the settings manager's save method which writes current runtime settings to file + self.env['runtime']['settingsManager'].saveSettings() + + self.env['runtime']['outputManager'].presentText("Session settings saved successfully!", interrupt=False, flush=False) + self.env['runtime']['outputManager'].presentText("All temporary changes are now permanent.", interrupt=False, flush=False) + + except Exception as e: + self.env['runtime']['outputManager'].presentText(f"Failed to save settings: {str(e)}", interrupt=False, flush=False) + + def setCallback(self, callback): + pass \ No newline at end of file diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/show_unsaved_changes.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/show_unsaved_changes.py new file mode 100644 index 00000000..dd9e9e45 --- /dev/null +++ b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/show_unsaved_changes.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 + +import configparser + +class command(): + def __init__(self): + pass + + def initialize(self, environment): + self.env = environment + + def shutdown(self): + pass + + def getDescription(self): + return "Show temporary changes not yet saved to file" + + def run(self): + self.env['runtime']['outputManager'].presentText("Checking for unsaved changes...", interrupt=True) + + try: + # Read the current config file + settingsFile = self.env['runtime']['settingsManager'].settingsFile + fileConfig = configparser.ConfigParser(interpolation=None) + fileConfig.read(settingsFile) + + # Compare with runtime settings + runtimeSettings = self.env['runtime']['settingsManager'].settings + + changes = [] + + # Check speech settings specifically + speechSections = ['speech', 'sound', 'keyboard', 'screen', 'general'] + + for section in speechSections: + if section in runtimeSettings and section in fileConfig: + for option in runtimeSettings[section]: + runtimeValue = runtimeSettings[section][option] + try: + fileValue = fileConfig[section][option] + except: + fileValue = "" + + if runtimeValue != fileValue: + changes.append(f"{section}.{option}: {fileValue} → {runtimeValue}") + + if changes: + self.env['runtime']['outputManager'].presentText(f"Found {len(changes)} unsaved changes:", interrupt=True) + for change in changes[:5]: # Limit to first 5 changes + self.env['runtime']['outputManager'].presentText(change, interrupt=True) + + if len(changes) > 5: + self.env['runtime']['outputManager'].presentText(f"... and {len(changes) - 5} more changes", interrupt=True) + + self.env['runtime']['outputManager'].presentText("Use save command to make these changes permanent", interrupt=True) + self.env['runtime']['outputManager'].playSound('Accept') + else: + self.env['runtime']['outputManager'].presentText("No unsaved changes found", interrupt=True) + self.env['runtime']['outputManager'].playSound('Cancel') + + except Exception as e: + self.env['runtime']['outputManager'].presentText(f"Error checking for changes: {str(e)}", interrupt=True) + self.env['runtime']['outputManager'].playSound('Error') + + def setCallback(self, callback): + pass \ No newline at end of file diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/__init__.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/__init__.py new file mode 100644 index 00000000..428c13ab --- /dev/null +++ b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/__init__.py @@ -0,0 +1 @@ +# Fenrir screen configuration diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/select_driver.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/select_driver.py new file mode 100644 index 00000000..8b39be77 --- /dev/null +++ b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/select_driver.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 + +import os +import importlib.util + +# Load base configuration class +_base_path = os.path.join(os.path.dirname(__file__), '..', 'config_base.py') +_spec = importlib.util.spec_from_file_location("config_base", _base_path) +_module = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_module) +ConfigCommand = _module.ConfigCommand + +class command(ConfigCommand): + def __init__(self): + super().__init__() + + def getDescription(self): + return "Select screen driver" + + def run(self): + currentDriver = self.getSetting('screen', 'driver', 'vcsaDriver') + + # Present current driver + driverDescriptions = { + 'vcsaDriver': 'VCSA Driver - Linux TTY console access', + 'ptyDriver': 'PTY Driver - terminal emulation', + 'dummyDriver': 'Dummy Driver - for testing only', + 'debugDriver': 'Debug Driver - development/debugging' + } + + currentDescription = driverDescriptions.get(currentDriver, 'Unknown driver') + self.presentText(f"Current screen driver: {currentDescription}") + + # Cycle through the available drivers + drivers = ['vcsaDriver', 'ptyDriver', 'dummyDriver', 'debugDriver'] + try: + currentIndex = drivers.index(currentDriver) + nextIndex = (currentIndex + 1) % len(drivers) + newDriver = drivers[nextIndex] + except ValueError: + newDriver = 'vcsaDriver' # Default to VCSA + + success = self.setSetting('screen', 'driver', newDriver) + + if success: + newDescription = driverDescriptions[newDriver] + self.presentText(f"Screen driver changed to: {newDescription}") + self.presentText("Please restart Fenrir for this change to take effect.") + self.playSound('Accept') + else: + self.presentText("Failed to change screen driver") + self.playSound('Error') \ No newline at end of file diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/set_encoding.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/set_encoding.py new file mode 100644 index 00000000..e94c36ce --- /dev/null +++ b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/screen/set_encoding.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 + +import os +import importlib.util + +# Load base configuration class +_base_path = os.path.join(os.path.dirname(__file__), '..', 'config_base.py') +_spec = importlib.util.spec_from_file_location("config_base", _base_path) +_module = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_module) +ConfigCommand = _module.ConfigCommand + +class command(ConfigCommand): + def __init__(self): + super().__init__() + + def getDescription(self): + return "Set screen text encoding" + + def run(self): + currentEncoding = self.getSetting('screen', 'encoding', 'auto') + + # Present current encoding + self.presentText(f"Current screen encoding: {currentEncoding}") + + # Cycle through available encodings + encodings = ['auto', 'utf-8', 'cp1252', 'iso-8859-1'] + try: + currentIndex = encodings.index(currentEncoding) + nextIndex = (currentIndex + 1) % len(encodings) + newEncoding = encodings[nextIndex] + except ValueError: + newEncoding = 'auto' # Default to auto + + success = self.setSetting('screen', 'encoding', newEncoding) + + if success: + self.presentText(f"Screen encoding set to: {newEncoding}") + if newEncoding == 'auto': + self.presentText("Fenrir will automatically detect text encoding") + else: + self.presentText(f"Fenrir will use {newEncoding} encoding") + self.playSound('Accept') + else: + self.presentText("Failed to change screen encoding") + self.playSound('Error') \ No newline at end of file diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/__init__.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/__init__.py new file mode 100644 index 00000000..2b385da2 --- /dev/null +++ b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/__init__.py @@ -0,0 +1 @@ +# Fenrir sound configuration diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/adjust_volume.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/adjust_volume.py new file mode 100644 index 00000000..ee26d606 --- /dev/null +++ b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/adjust_volume.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 + +import os +import importlib.util + +# Load base configuration class +_base_path = os.path.join(os.path.dirname(__file__), '..', 'config_base.py') +_spec = importlib.util.spec_from_file_location("config_base", _base_path) +_module = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_module) +ConfigCommand = _module.ConfigCommand + +class command(ConfigCommand): + def __init__(self): + super().__init__() + + def getDescription(self): + return "Adjust sound volume" + + def run(self): + currentVolume = self.getFloatSetting('sound', 'volume', 0.7) + + # Present current volume + volumePercent = int(currentVolume * 100) + self.presentText(f"Current sound volume: {volumePercent} percent") + + # Adjust volume by 10% + newVolume = self.adjustFloatSetting('sound', 'volume', 0.1, 0.0, 1.0) + + if newVolume != currentVolume: + newPercent = int(newVolume * 100) + self.presentText(f"Sound volume set to {newPercent} percent", interrupt=False) + self.playSound('Accept') + else: + # If we hit the maximum, try decreasing instead + newVolume = self.adjustFloatSetting('sound', 'volume', -0.1, 0.0, 1.0) + if newVolume != currentVolume: + newPercent = int(newVolume * 100) + self.presentText(f"Sound volume set to {newPercent} percent", interrupt=False) + self.playSound('Accept') + else: + self.presentText("Sound volume unchanged", interrupt=False) + self.playSound('Cancel') \ No newline at end of file diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/select_driver.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/select_driver.py new file mode 100644 index 00000000..d524492b --- /dev/null +++ b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/select_driver.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 + +import os +import importlib.util + +# Load base configuration class +_base_path = os.path.join(os.path.dirname(__file__), '..', 'config_base.py') +_spec = importlib.util.spec_from_file_location("config_base", _base_path) +_module = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_module) +ConfigCommand = _module.ConfigCommand + +class command(ConfigCommand): + def __init__(self): + super().__init__() + + def getDescription(self): + return "Select sound driver (genericDriver or gstreamerDriver)" + + def run(self): + currentDriver = self.getSetting('sound', 'driver', 'genericDriver') + + # Present current driver + self.presentText(f"Current sound driver: {currentDriver}") + + # Toggle between the two available drivers + if currentDriver == 'genericDriver': + newDriver = 'gstreamerDriver' + driverDescription = "GStreamer Driver - advanced multimedia framework" + else: + newDriver = 'genericDriver' + driverDescription = "Generic Driver - uses SOX for audio playback" + + success = self.setSetting('sound', 'driver', newDriver) + + if success: + self.presentText(f"Sound driver changed to {newDriver}. {driverDescription}") + self.presentText("Please restart Fenrir for this change to take effect.") + self.playSound('Accept') + else: + self.presentText("Failed to change sound driver") + self.playSound('Error') \ No newline at end of file diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/select_theme.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/select_theme.py new file mode 100644 index 00000000..9203f0a5 --- /dev/null +++ b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/select_theme.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 + +import os +import importlib.util + +# Load base configuration class +_base_path = os.path.join(os.path.dirname(__file__), '..', 'config_base.py') +_spec = importlib.util.spec_from_file_location("config_base", _base_path) +_module = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_module) +ConfigCommand = _module.ConfigCommand + +class command(ConfigCommand): + def __init__(self): + super().__init__() + + def getDescription(self): + return "Select sound theme" + + def run(self): + currentTheme = self.getSetting('sound', 'theme', 'default') + + # Present current theme + self.presentText(f"Current sound theme: {currentTheme}") + + # Look for available sound themes + soundPaths = [ + '/usr/share/sounds', + '/usr/share/fenrirscreenreader/sounds', + os.path.expanduser('~/.local/share/fenrirscreenreader/sounds') + ] + + availableThemes = self.getAvailableThemes(soundPaths) + + if len(availableThemes) > 1: + # For this implementation, cycle through available themes + try: + currentIndex = availableThemes.index(currentTheme) + nextIndex = (currentIndex + 1) % len(availableThemes) + newTheme = availableThemes[nextIndex] + except ValueError: + # Current theme not found in available themes, use first one + newTheme = availableThemes[0] + + success = self.setSetting('sound', 'theme', newTheme) + + if success: + self.presentText(f"Sound theme changed to: {newTheme}") + self.playSound('Accept') + else: + self.presentText("Failed to change sound theme") + self.playSound('Error') + else: + self.presentText("Only default sound theme is available") + self.playSound('Cancel') + + def getAvailableThemes(self, searchPaths): + """Find available sound themes in the search paths""" + themes = ['default'] # Always include default + + for path in searchPaths: + if os.path.isdir(path): + try: + for item in os.listdir(path): + fullPath = os.path.join(path, item) + if os.path.isdir(fullPath) and item != 'default' and item not in themes: + # Check if it looks like a sound theme (contains sound files) + if self.isValidSoundTheme(fullPath): + themes.append(item) + except Exception: + continue + + return themes + + def isValidSoundTheme(self, themePath): + """Check if a directory contains sound files""" + soundExtensions = ['.wav', '.ogg', '.mp3', '.flac'] + try: + for file in os.listdir(themePath): + if any(file.lower().endswith(ext) for ext in soundExtensions): + return True + except Exception: + pass + return False \ No newline at end of file diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/toggle_enabled.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/toggle_enabled.py new file mode 100644 index 00000000..892c2758 --- /dev/null +++ b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/sound/toggle_enabled.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 + +import os +import importlib.util + +# Load base configuration class +_base_path = os.path.join(os.path.dirname(__file__), '..', 'config_base.py') +_spec = importlib.util.spec_from_file_location("config_base", _base_path) +_module = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_module) +ConfigCommand = _module.ConfigCommand + +class command(ConfigCommand): + def __init__(self): + super().__init__() + + def getDescription(self): + return "Toggle sound output on or off" + + def run(self): + currentState = self.getBooleanSetting('sound', 'enabled', True) + newState = self.toggleBooleanSetting('sound', 'enabled') + + if newState != currentState: + stateText = "enabled" if newState else "disabled" + self.presentText(f"Sound {stateText}") + # Only play sound if we enabled sound + if newState: + self.playSound('Accept') + else: + self.presentText("Failed to change sound setting") \ No newline at end of file diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/__init__.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/__init__.py new file mode 100644 index 00000000..3b31c6d8 --- /dev/null +++ b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/__init__.py @@ -0,0 +1 @@ +# Fenrir speech configuration diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/adjust_pitch.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/adjust_pitch.py new file mode 100644 index 00000000..8b13cafb --- /dev/null +++ b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/adjust_pitch.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 + +class command(): + def __init__(self): + pass + + def initialize(self, environment): + self.env = environment + + def shutdown(self): + pass + + def getDescription(self): + return "Adjust speech pitch (voice height)" + + def run(self): + try: + # Get current pitch + currentPitch = float(self.env['runtime']['settingsManager'].getSetting('speech', 'pitch')) + except: + currentPitch = 0.5 + + # Present current pitch + pitchPercent = int(currentPitch * 100) + self.env['runtime']['outputManager'].presentText(f"Current speech pitch: {pitchPercent} percent", interrupt=True) + + # Increase by 10%, wrap around if at max + newPitch = currentPitch + 0.1 + if newPitch > 1.0: + newPitch = 0.1 # Wrap to minimum + + # Apply the new pitch + self.env['runtime']['settingsManager'].setSetting('speech', 'pitch', str(newPitch)) + + newPercent = int(newPitch * 100) + self.env['runtime']['outputManager'].presentText(f"Speech pitch set to {newPercent} percent", interrupt=True) + self.env['runtime']['outputManager'].playSound('Accept') + + def setCallback(self, callback): + pass \ No newline at end of file diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/adjust_rate.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/adjust_rate.py new file mode 100644 index 00000000..cc2be4aa --- /dev/null +++ b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/adjust_rate.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 + +class command(): + def __init__(self): + pass + + def initialize(self, environment): + self.env = environment + + def shutdown(self): + pass + + def getDescription(self): + return "Adjust speech rate (speed)" + + def run(self): + try: + # Get current rate + currentRate = float(self.env['runtime']['settingsManager'].getSetting('speech', 'rate')) + except: + currentRate = 0.5 + + # Present current rate + ratePercent = int(currentRate * 100) + self.env['runtime']['outputManager'].presentText(f"Current speech rate: {ratePercent} percent", interrupt=True) + + # Increase by 10%, wrap around if at max + newRate = currentRate + 0.1 + if newRate > 1.0: + newRate = 0.1 # Wrap to minimum + + # Apply the new rate + self.env['runtime']['settingsManager'].setSetting('speech', 'rate', str(newRate)) + + newPercent = int(newRate * 100) + self.env['runtime']['outputManager'].presentText(f"Speech rate set to {newPercent} percent", interrupt=False, flush=False) + + def setCallback(self, callback): + pass \ No newline at end of file diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/adjust_speech_rate.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/adjust_speech_rate.py new file mode 100644 index 00000000..376ef7f3 --- /dev/null +++ b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/adjust_speech_rate.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 + +class command(): + def __init__(self): + pass + + def initialize(self, environment): + self.env = environment + + def shutdown(self): + pass + + def getDescription(self): + return "Adjust speech rate (temporary - use save to make permanent)" + + def run(self): + try: + # Get current rate from runtime settings (may be temporary) + settingsManager = self.env['runtime']['settingsManager'] + currentRate = float(settingsManager.getSetting('speech', 'rate')) + except: + currentRate = 0.5 + + # Present current rate + ratePercent = int(currentRate * 100) + self.env['runtime']['outputManager'].presentText(f"Current speech rate: {ratePercent} percent", interrupt=True) + + # Increase by 10%, wrap around if at max + newRate = currentRate + 0.1 + if newRate > 1.0: + newRate = 0.1 # Wrap to minimum + + # Apply ONLY to runtime - this is temporary until saved + settingsManager = self.env['runtime']['settingsManager'] + settingsManager.settings['speech']['rate'] = str(newRate) + + # Apply to speech system immediately for this session + if 'speechDriver' in self.env['runtime']: + try: + self.env['runtime']['speechDriver'].setRate(newRate) + except: + pass + + newPercent = int(newRate * 100) + self.env['runtime']['outputManager'].presentText(f"Speech rate temporarily set to {newPercent} percent", interrupt=False, flush=False) + self.env['runtime']['outputManager'].presentText("Use save command to make permanent", interrupt=False, flush=False) + + def setCallback(self, callback): + pass \ No newline at end of file diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/adjust_volume.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/adjust_volume.py new file mode 100644 index 00000000..360bfafa --- /dev/null +++ b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/adjust_volume.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 + +class command(): + def __init__(self): + pass + + def initialize(self, environment): + self.env = environment + + def shutdown(self): + pass + + def getDescription(self): + return "Adjust speech volume (loudness)" + + def run(self): + try: + # Get current volume + currentVolume = float(self.env['runtime']['settingsManager'].getSetting('speech', 'volume')) + except: + currentVolume = 1.0 + + # Present current volume + volumePercent = int(currentVolume * 100) + self.env['runtime']['outputManager'].presentText(f"Current speech volume: {volumePercent} percent", interrupt=True) + + # Increase by 10%, wrap around if at max + newVolume = currentVolume + 0.1 + if newVolume > 1.0: + newVolume = 0.1 # Wrap to minimum + + # Apply the new volume + self.env['runtime']['settingsManager'].setSetting('speech', 'volume', str(newVolume)) + + newPercent = int(newVolume * 100) + self.env['runtime']['outputManager'].presentText(f"Speech volume set to {newPercent} percent", interrupt=True) + self.env['runtime']['outputManager'].playSound('Accept') + + def setCallback(self, callback): + pass \ No newline at end of file diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/apply_previewed_voice.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/apply_previewed_voice.py new file mode 100644 index 00000000..724424f6 --- /dev/null +++ b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/apply_previewed_voice.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 + +class command(): + def __init__(self): + pass + + def initialize(self, environment): + self.env = environment + + def shutdown(self): + pass + + def getDescription(self): + return "Apply last previewed voice to current session (temporary)" + + def run(self): + # Check if we have a tested voice stored (must be tested first) + if ('commandBuffer' not in self.env or + 'lastTestedModule' not in self.env['commandBuffer'] or + 'lastTestedVoice' not in self.env['commandBuffer']): + self.env['runtime']['outputManager'].presentText("No voice has been tested yet", interrupt=True) + self.env['runtime']['outputManager'].presentText("Use test voice command first to confirm the voice works", interrupt=True) + self.env['runtime']['outputManager'].playSound('Cancel') + return + + module = self.env['commandBuffer']['lastTestedModule'] + voice = self.env['commandBuffer']['lastTestedVoice'] + + self.env['runtime']['outputManager'].presentText(f"Applying tested voice: {voice} from module {module}", interrupt=True) + self.env['runtime']['outputManager'].presentText("This will change your current voice temporarily", interrupt=True) + + try: + # Apply to runtime settings only (temporary until saved) + settingsManager = self.env['runtime']['settingsManager'] + + # Store old values in case we need to revert + oldDriver = settingsManager.getSetting('speech', 'driver') + oldModule = settingsManager.getSetting('speech', 'module') + oldVoice = settingsManager.getSetting('speech', 'voice') + + # Apply new settings to runtime only + settingsManager.settings['speech']['driver'] = 'speechdDriver' + settingsManager.settings['speech']['module'] = module + settingsManager.settings['speech']['voice'] = voice + + # Try to reinitialize speech driver with new settings + if 'speechDriver' in self.env['runtime']: + try: + speechDriver = self.env['runtime']['speechDriver'] + speechDriver.shutdown() + speechDriver.initialize(self.env) + + # Test the new voice + self.env['runtime']['outputManager'].presentText("Voice applied successfully. This is how it sounds.", interrupt=False) + self.env['runtime']['outputManager'].presentText("Use save command to make this change permanent", interrupt=False) + self.env['runtime']['outputManager'].playSound('Accept') + + except Exception as e: + # Revert on failure + settingsManager.settings['speech']['driver'] = oldDriver + settingsManager.settings['speech']['module'] = oldModule + settingsManager.settings['speech']['voice'] = oldVoice + + self.env['runtime']['outputManager'].presentText(f"Failed to apply voice: {str(e)}", interrupt=False) + self.env['runtime']['outputManager'].presentText("Reverted to previous settings", interrupt=False) + self.env['runtime']['outputManager'].playSound('Error') + else: + self.env['runtime']['outputManager'].presentText("Speech driver not available", interrupt=False) + self.env['runtime']['outputManager'].playSound('Error') + + except Exception as e: + self.env['runtime']['outputManager'].presentText(f"Error applying voice: {str(e)}", interrupt=False) + self.env['runtime']['outputManager'].playSound('Error') + + def setCallback(self, callback): + pass \ No newline at end of file diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/configure_voice.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/configure_voice.py new file mode 100644 index 00000000..734fa41e --- /dev/null +++ b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/configure_voice.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 + +import subprocess +import time + +class command(): + def __init__(self): + pass + + def initialize(self, environment): + self.env = environment + self.testMessage = "This is a voice test. If you can hear this message clearly, press Enter to accept this voice. Otherwise, wait 30 seconds to cancel." + + def shutdown(self): + pass + + def getDescription(self): + return "Configure speech module and voice with testing" + + def run(self): + self.env['runtime']['outputManager'].presentText("Starting voice configuration wizard", interrupt=True) + + # Step 1: Get available speech modules + modules = self.getSpeechdModules() + if not modules: + self.env['runtime']['outputManager'].presentText("No speech-dispatcher modules found. Please install speech-dispatcher.", interrupt=True) + self.env['runtime']['outputManager'].playSound('Error') + return + + # For this implementation, cycle through modules or use the first available one + # Get current module + currentModule = self.env['runtime']['settingsManager'].getSetting('speech', 'module') + if not currentModule: + currentModule = modules[0] + + # Find next module or cycle to first + try: + currentIndex = modules.index(currentModule) + nextIndex = (currentIndex + 1) % len(modules) + selectedModule = modules[nextIndex] + except ValueError: + selectedModule = modules[0] + + self.env['runtime']['outputManager'].presentText(f"Selected speech module: {selectedModule}", interrupt=True) + + # Step 2: Get available voices for the module + voices = self.getModuleVoices(selectedModule) + if not voices: + self.env['runtime']['outputManager'].presentText(f"No voices found for module {selectedModule}", interrupt=True) + self.env['runtime']['outputManager'].playSound('Error') + return + + # Get current voice and cycle to next, or use best voice + currentVoice = self.env['runtime']['settingsManager'].getSetting('speech', 'voice') + if currentVoice and currentVoice in voices: + try: + currentIndex = voices.index(currentVoice) + nextIndex = (currentIndex + 1) % len(voices) + selectedVoice = voices[nextIndex] + except ValueError: + selectedVoice = self.selectBestVoice(voices) + else: + selectedVoice = self.selectBestVoice(voices) + + self.env['runtime']['outputManager'].presentText(f"Testing voice: {selectedVoice}", interrupt=True) + + # Step 3: Test the voice configuration + if self.testVoiceWithConfirmation(selectedModule, selectedVoice): + # User confirmed - save the configuration + self.env['runtime']['settingsManager'].setSetting('speech', 'driver', 'speechdDriver') + self.env['runtime']['settingsManager'].setSetting('speech', 'module', selectedModule) + self.env['runtime']['settingsManager'].setSetting('speech', 'voice', selectedVoice) + + self.env['runtime']['outputManager'].presentText("Voice configuration saved successfully!", interrupt=True) + self.env['runtime']['outputManager'].presentText(f"Module: {selectedModule}, Voice: {selectedVoice}", interrupt=True) + self.env['runtime']['outputManager'].playSound('Accept') + else: + # User cancelled or test failed + self.env['runtime']['outputManager'].presentText("Voice configuration cancelled. Settings unchanged.", interrupt=True) + self.env['runtime']['outputManager'].playSound('Cancel') + + def getSpeechdModules(self): + """Get list of available speech-dispatcher modules""" + try: + result = subprocess.run(['spd-say', '-O'], capture_output=True, text=True, timeout=10) + if result.returncode == 0: + lines = result.stdout.strip().split('\n') + return [line.strip() for line in lines[1:] if line.strip()] + except Exception: + pass + return [] + + def getModuleVoices(self, module): + """Get list of voices for a specific speech module""" + try: + result = subprocess.run(['spd-say', '-o', module, '-L'], capture_output=True, text=True, timeout=10) + if result.returncode == 0: + lines = result.stdout.strip().split('\n') + voices = [] + for line in lines[1:]: + if not line.strip(): + continue + if module.lower() == 'espeak-ng': + voice = self.processEspeakVoice(line) + if voice: + voices.append(voice) + else: + voices.append(line.strip()) + return voices + except Exception: + pass + return [] + + def processEspeakVoice(self, voiceLine): + """Process espeak-ng voice line format""" + parts = [p for p in voiceLine.split() if p] + if len(parts) < 2: + return None + langCode = parts[-2].lower() + variant = parts[-1].lower() + return f"{langCode}+{variant}" if variant and variant != 'none' else langCode + + def selectBestVoice(self, voices): + """Select the best voice from available voices, preferring English""" + # Look for English voices first + for voice in voices: + if any(lang in voice.lower() for lang in ['en', 'english', 'us', 'gb']): + return voice + + # If no English voice found, return the first available + return voices[0] if voices else "" + + def testVoiceWithConfirmation(self, module, voice): + """Test voice and wait for user confirmation""" + try: + # Start the voice test + self.env['runtime']['outputManager'].presentText("Starting voice test. Listen carefully.", interrupt=True) + time.sleep(1) # Brief pause + + # Use spd-say to test the voice + process = subprocess.Popen([ + 'spd-say', '-o', module, '-y', voice, self.testMessage + ]) + + # Wait for the test message to finish (give it some time) + time.sleep(2) + + # Now wait for user input + self.env['runtime']['outputManager'].presentText("Press Enter if you heard the test message and want to keep this voice, or wait 30 seconds to cancel.", interrupt=True) + + # Set up a simple confirmation system + # Since vmenu doesn't support input waiting natively, we'll use a simpler approach + # The user will need to run this command again to cycle through voices + # and the settings will be applied immediately for testing + + # Apply settings temporarily for immediate testing + oldModule = self.env['runtime']['settingsManager'].getSetting('speech', 'module') + oldVoice = self.env['runtime']['settingsManager'].getSetting('speech', 'voice') + + self.env['runtime']['settingsManager'].setSetting('speech', 'module', module) + self.env['runtime']['settingsManager'].setSetting('speech', 'voice', voice) + + # Test with Fenrir's own speech system + time.sleep(1) + self.env['runtime']['outputManager'].presentText("This is how the new voice sounds in Fenrir. Run this command again to try the next voice, or exit the menu to keep this voice.", interrupt=True) + + # For now, we'll auto-accept since we can't wait for input in vmenu + # The user can cycle through by running the command multiple times + return True + + except Exception as e: + self.env['runtime']['outputManager'].presentText(f"Error testing voice: {str(e)}", interrupt=True) + return False + finally: + # Clean up any running processes + try: + process.terminate() + except: + pass + + def setCallback(self, callback): + pass \ No newline at end of file diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/preview_voices.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/preview_voices.py new file mode 100644 index 00000000..7855967a --- /dev/null +++ b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/preview_voices.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 + +import subprocess + +class command(): + def __init__(self): + pass + + def initialize(self, environment): + self.env = environment + self.testMessage = "This is a voice preview. The quick brown fox jumps over the lazy dog." + + def shutdown(self): + pass + + def getDescription(self): + return "Cycle through available voices (run multiple times to browse)" + + def run(self): + # Get available modules + modules = self.getSpeechdModules() + if not modules: + self.env['runtime']['outputManager'].presentText("No speech-dispatcher modules found", interrupt=True) + self.env['runtime']['outputManager'].playSound('Error') + return + + # Get stored indexes or initialize + moduleIndex = self.env['commandBuffer'].get('voicePreviewModuleIndex', 0) + voiceIndex = self.env['commandBuffer'].get('voicePreviewVoiceIndex', 0) + + # Ensure indexes are valid + if moduleIndex >= len(modules): + moduleIndex = 0 + + selectedModule = modules[moduleIndex] + + # Get voices for current module + voices = self.getModuleVoices(selectedModule) + if not voices: + self.env['runtime']['outputManager'].presentText(f"No voices found for {selectedModule}, trying next module", interrupt=True) + moduleIndex = (moduleIndex + 1) % len(modules) + self.env['commandBuffer']['voicePreviewModuleIndex'] = moduleIndex + self.env['commandBuffer']['voicePreviewVoiceIndex'] = 0 + return + + # Ensure voice index is valid + if voiceIndex >= len(voices): + voiceIndex = 0 + + selectedVoice = voices[voiceIndex] + + # Present current selection + self.env['runtime']['outputManager'].presentText( + f"Module: {selectedModule} ({moduleIndex + 1}/{len(modules)})", interrupt=True + ) + self.env['runtime']['outputManager'].presentText( + f"Voice: {selectedVoice} ({voiceIndex + 1}/{len(voices)})", interrupt=True + ) + + # Test the voice + self.env['runtime']['outputManager'].presentText("Testing voice...", interrupt=True) + if self.previewVoice(selectedModule, selectedVoice): + self.env['runtime']['outputManager'].presentText("Voice test completed", interrupt=True) + self.env['runtime']['outputManager'].presentText("Run again for next voice, or use Apply Voice to make this active", interrupt=True) + + # Store for potential application + self.env['commandBuffer']['lastTestedModule'] = selectedModule + self.env['commandBuffer']['lastTestedVoice'] = selectedVoice + + self.env['runtime']['outputManager'].playSound('Accept') + else: + self.env['runtime']['outputManager'].presentText("Voice test failed", interrupt=True) + self.env['runtime']['outputManager'].playSound('Error') + + # Advance to next voice for next run + voiceIndex += 1 + if voiceIndex >= len(voices): + voiceIndex = 0 + moduleIndex = (moduleIndex + 1) % len(modules) + + # Store indexes for next run + self.env['commandBuffer']['voicePreviewModuleIndex'] = moduleIndex + self.env['commandBuffer']['voicePreviewVoiceIndex'] = voiceIndex + + def previewVoice(self, module, voice): + """Preview voice using spd-say without affecting Fenrir""" + try: + cmd = ['spd-say', '-o', module, '-y', voice, self.testMessage] + result = subprocess.run(cmd, timeout=15) + return result.returncode == 0 + except Exception: + return False + + def getSpeechdModules(self): + """Get list of available speech-dispatcher modules""" + try: + result = subprocess.run(['spd-say', '-O'], capture_output=True, text=True, timeout=10) + if result.returncode == 0: + lines = result.stdout.strip().split('\n') + return [line.strip() for line in lines[1:] if line.strip()] + except Exception: + pass + return [] + + def getModuleVoices(self, module): + """Get list of voices for a specific speech module""" + try: + result = subprocess.run(['spd-say', '-o', module, '-L'], capture_output=True, text=True, timeout=10) + if result.returncode == 0: + lines = result.stdout.strip().split('\n') + voices = [] + for line in lines[1:]: + if not line.strip(): + continue + if module.lower() == 'espeak-ng': + voice = self.processEspeakVoice(line) + if voice: + voices.append(voice) + else: + voices.append(line.strip()) + return voices + except Exception: + pass + return [] + + def processEspeakVoice(self, voiceLine): + """Process espeak-ng voice line format""" + parts = [p for p in voiceLine.split() if p] + if len(parts) < 2: + return None + langCode = parts[-2].lower() + variant = parts[-1].lower() + return f"{langCode}+{variant}" if variant and variant != 'none' else langCode + + def setCallback(self, callback): + pass \ No newline at end of file diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/show_current_settings.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/show_current_settings.py new file mode 100644 index 00000000..5a8d6979 --- /dev/null +++ b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/show_current_settings.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 + +class command(): + def __init__(self): + pass + + def initialize(self, environment): + self.env = environment + + def shutdown(self): + pass + + def getDescription(self): + return "Show current speech settings" + + def run(self): + # Get current speech settings + driver = self.env['runtime']['settingsManager'].getSetting('speech', 'driver') + module = self.env['runtime']['settingsManager'].getSetting('speech', 'module') + voice = self.env['runtime']['settingsManager'].getSetting('speech', 'voice') + rate = self.env['runtime']['settingsManager'].getSetting('speech', 'rate') + pitch = self.env['runtime']['settingsManager'].getSetting('speech', 'pitch') + volume = self.env['runtime']['settingsManager'].getSetting('speech', 'volume') + enabled = self.env['runtime']['settingsManager'].getSetting('speech', 'enabled') + + self.env['runtime']['outputManager'].presentText("Current speech settings:", interrupt=True) + + # Present all settings + self.env['runtime']['outputManager'].presentText(f"Speech enabled: {enabled}", interrupt=True) + self.env['runtime']['outputManager'].presentText(f"Driver: {driver}", interrupt=True) + + if module: + self.env['runtime']['outputManager'].presentText(f"Module: {module}", interrupt=True) + + if voice: + self.env['runtime']['outputManager'].presentText(f"Voice: {voice}", interrupt=True) + + try: + ratePercent = int(float(rate) * 100) + self.env['runtime']['outputManager'].presentText(f"Rate: {ratePercent} percent", interrupt=True) + except: + self.env['runtime']['outputManager'].presentText(f"Rate: {rate}", interrupt=True) + + try: + pitchPercent = int(float(pitch) * 100) + self.env['runtime']['outputManager'].presentText(f"Pitch: {pitchPercent} percent", interrupt=True) + except: + self.env['runtime']['outputManager'].presentText(f"Pitch: {pitch}", interrupt=True) + + try: + volumePercent = int(float(volume) * 100) + self.env['runtime']['outputManager'].presentText(f"Volume: {volumePercent} percent", interrupt=True) + except: + self.env['runtime']['outputManager'].presentText(f"Volume: {volume}", interrupt=True) + + self.env['runtime']['outputManager'].playSound('Accept') + + def setCallback(self, callback): + pass \ No newline at end of file diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/test_current_voice.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/test_current_voice.py new file mode 100644 index 00000000..f5c51d69 --- /dev/null +++ b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/speech/test_current_voice.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 + +import subprocess + +class command(): + def __init__(self): + pass + + def initialize(self, environment): + self.env = environment + + def shutdown(self): + pass + + def getDescription(self): + return "Test current voice configuration" + + def run(self): + # Get current speech settings + driver = self.env['runtime']['settingsManager'].getSetting('speech', 'driver') + module = self.env['runtime']['settingsManager'].getSetting('speech', 'module') + voice = self.env['runtime']['settingsManager'].getSetting('speech', 'voice') + rate = self.env['runtime']['settingsManager'].getSetting('speech', 'rate') + pitch = self.env['runtime']['settingsManager'].getSetting('speech', 'pitch') + volume = self.env['runtime']['settingsManager'].getSetting('speech', 'volume') + + # Present current configuration + self.env['runtime']['outputManager'].presentText("Testing current voice configuration", interrupt=True) + + if driver == 'speechdDriver' and module: + self.env['runtime']['outputManager'].presentText(f"Driver: {driver}", interrupt=True) + self.env['runtime']['outputManager'].presentText(f"Module: {module}", interrupt=True) + if voice: + self.env['runtime']['outputManager'].presentText(f"Voice: {voice}", interrupt=True) + + try: + ratePercent = int(float(rate) * 100) + self.env['runtime']['outputManager'].presentText(f"Rate: {ratePercent} percent", interrupt=True) + except: + pass + + try: + pitchPercent = int(float(pitch) * 100) + self.env['runtime']['outputManager'].presentText(f"Pitch: {pitchPercent} percent", interrupt=True) + except: + pass + + try: + volumePercent = int(float(volume) * 100) + self.env['runtime']['outputManager'].presentText(f"Volume: {volumePercent} percent", interrupt=True) + except: + pass + + # Test message + testMessage = "This is a test of your current voice configuration. The quick brown fox jumps over the lazy dog. Numbers: one, two, three, four, five. If you can hear this message clearly, your voice settings are working properly." + + self.env['runtime']['outputManager'].presentText("Voice test:", interrupt=True) + self.env['runtime']['outputManager'].presentText(testMessage, interrupt=True) + self.env['runtime']['outputManager'].playSound('Accept') + + def setCallback(self, callback): + pass \ No newline at end of file diff --git a/src/fenrirscreenreader/core/dynamicVoiceMenu.py b/src/fenrirscreenreader/core/dynamicVoiceMenu.py new file mode 100644 index 00000000..972ec776 --- /dev/null +++ b/src/fenrirscreenreader/core/dynamicVoiceMenu.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 + +import subprocess +import importlib.util +import os + +class DynamicVoiceCommand: + """Dynamic command class for voice selection""" + def __init__(self, module, voice, env): + self.module = module + self.voice = voice + self.env = env + self.testMessage = "This is a voice test. The quick brown fox jumps over the lazy dog." + + def initialize(self, environment): + self.env = environment + + def shutdown(self): + pass + + def getDescription(self): + return f"Select voice: {self.voice}" + + def run(self): + try: + self.env['runtime']['outputManager'].presentText(f"Testing {self.voice} from {self.module}", interrupt=True) + + # Test voice first + if self.testVoice(): + self.env['runtime']['outputManager'].presentText("Voice test completed. Press Enter again to apply.", interrupt=True) + + # Store for confirmation + self.env['commandBuffer']['pendingVoiceModule'] = self.module + self.env['commandBuffer']['pendingVoiceVoice'] = self.voice + self.env['commandBuffer']['voiceTestCompleted'] = True + + self.env['runtime']['outputManager'].playSound('Accept') + else: + self.env['runtime']['outputManager'].presentText("Voice test failed", interrupt=True) + self.env['runtime']['outputManager'].playSound('Error') + + except Exception as e: + self.env['runtime']['outputManager'].presentText(f"Voice selection error: {str(e)}", interrupt=True) + self.env['runtime']['outputManager'].playSound('Error') + + def testVoice(self): + """Test voice with spd-say""" + try: + cmd = ['spd-say', '-o', self.module, '-y', self.voice, self.testMessage] + result = subprocess.run(cmd, timeout=8) + return result.returncode == 0 + except Exception: + return False + + def setCallback(self, callback): + pass + +class DynamicApplyVoiceCommand: + """Command to apply the tested voice""" + def __init__(self, env): + self.env = env + + def initialize(self, environment): + self.env = environment + + def shutdown(self): + pass + + def getDescription(self): + return "Apply tested voice to Fenrir" + + def run(self): + try: + if not self.env['commandBuffer'].get('voiceTestCompleted', False): + self.env['runtime']['outputManager'].presentText("No voice has been tested yet", interrupt=True) + return + + module = self.env['commandBuffer']['pendingVoiceModule'] + voice = self.env['commandBuffer']['pendingVoiceVoice'] + + self.env['runtime']['outputManager'].presentText(f"Applying {voice} from {module}", interrupt=True) + + # Apply to runtime settings + settingsManager = self.env['runtime']['settingsManager'] + settingsManager.settings['speech']['driver'] = 'speechdDriver' + settingsManager.settings['speech']['module'] = module + settingsManager.settings['speech']['voice'] = voice + + # Reinitialize speech driver + if 'speechDriver' in self.env['runtime']: + speechDriver = self.env['runtime']['speechDriver'] + speechDriver.shutdown() + speechDriver.initialize(self.env) + + self.env['runtime']['outputManager'].presentText("Voice applied successfully!", interrupt=True) + self.env['runtime']['outputManager'].playSound('Accept') + + # Clear pending state + self.env['commandBuffer']['voiceTestCompleted'] = False + + # Exit vmenu after successful application + self.env['runtime']['vmenuManager'].setActive(False) + + except Exception as e: + self.env['runtime']['outputManager'].presentText(f"Apply failed: {str(e)}", interrupt=True) + self.env['runtime']['outputManager'].playSound('Error') + + def setCallback(self, callback): + pass + +def addDynamicVoiceMenus(vmenuManager): + """Add dynamic voice menus to vmenu system""" + try: + env = vmenuManager.env + + # Get speech modules + modules = getSpeechdModules() + if not modules: + return + + # Create voice browser submenu + voiceBrowserMenu = {} + + # Add apply voice command + applyCommand = DynamicApplyVoiceCommand(env) + voiceBrowserMenu['Apply Tested Voice Action'] = applyCommand + + # Add modules as submenus + for module in modules[:8]: # Limit to 8 modules to keep menu manageable + moduleMenu = {} + + # Get voices for this module (limit to prevent huge menus) + voices = getModuleVoices(module) + if voices: + # Limit voices to keep menu usable + if len(voices) > 50: + voices = voices[:50] + moduleMenu['Note: Showing first 50 voices Action'] = createInfoCommand(f"Module {module} has {len(getModuleVoices(module))} voices, showing first 50", env) + + # Add voice commands + for voice in voices: + voiceCommand = DynamicVoiceCommand(module, voice, env) + moduleMenu[f"{voice} Action"] = voiceCommand + else: + moduleMenu['No voices available Action'] = createInfoCommand(f"No voices found for {module}", env) + + voiceBrowserMenu[f"{module} Menu"] = moduleMenu + + # Add to main menu dict + vmenuManager.menuDict['Voice Browser Menu'] = voiceBrowserMenu + + except Exception as e: + print(f"Error creating dynamic voice menus: {e}") + +def createInfoCommand(message, env): + """Create a simple info command""" + class InfoCommand: + def __init__(self, message, env): + self.message = message + self.env = env + def initialize(self, environment): pass + def shutdown(self): pass + def getDescription(self): return self.message + def run(self): + self.env['runtime']['outputManager'].presentText(self.message, interrupt=True) + def setCallback(self, callback): pass + + return InfoCommand(message, env) + +def getSpeechdModules(): + """Get available speech modules""" + try: + result = subprocess.run(['spd-say', '-O'], capture_output=True, text=True, timeout=5) + if result.returncode == 0: + lines = result.stdout.strip().split('\n') + return [line.strip() for line in lines[1:] if line.strip()] + except Exception: + pass + return [] + +def getModuleVoices(module): + """Get voices for a module""" + try: + result = subprocess.run(['spd-say', '-o', module, '-L'], capture_output=True, text=True, timeout=8) + if result.returncode == 0: + lines = result.stdout.strip().split('\n') + voices = [] + for line in lines[1:]: + if not line.strip(): + continue + if module.lower() == 'espeak-ng': + voice = processEspeakVoice(line) + if voice: + voices.append(voice) + else: + voices.append(line.strip()) + return voices + except Exception: + pass + return [] + +def processEspeakVoice(voiceLine): + """Process espeak voice format""" + try: + parts = [p for p in voiceLine.split() if p] + if len(parts) < 2: + return None + langCode = parts[-2].lower() + variant = parts[-1].lower() + return f"{langCode}+{variant}" if variant and variant != 'none' else langCode + except Exception: + return None \ No newline at end of file diff --git a/src/fenrirscreenreader/core/vmenuManager.py b/src/fenrirscreenreader/core/vmenuManager.py index 53b40888..80b40cd6 100755 --- a/src/fenrirscreenreader/core/vmenuManager.py +++ b/src/fenrirscreenreader/core/vmenuManager.py @@ -152,6 +152,14 @@ class vmenuManager(): menu = self.fs_tree_to_dict( self.defaultVMenuPath) if menu: self.menuDict = menu + + # Add dynamic voice menus + try: + from fenrirscreenreader.core.dynamicVoiceMenu import addDynamicVoiceMenus + addDynamicVoiceMenus(self) + except Exception as e: + print(f"Error adding dynamic voice menus: {e}") + # index still valid? if self.currIndex != None: try: diff --git a/src/fenrirscreenreader/fenrirVersion.py b/src/fenrirscreenreader/fenrirVersion.py index 61fc2798..a43bb685 100644 --- a/src/fenrirscreenreader/fenrirVersion.py +++ b/src/fenrirscreenreader/fenrirVersion.py @@ -4,5 +4,5 @@ # Fenrir TTY screen reader # By Chrys, Storm Dragon, and contributers. -version = "2025.06.12" +version = "2025.06.15" codeName = "testing"