latest testing code merged. Nothing major reported from testing branch, so if we get no reports, this will become the next stable release. I'm waiting a bit to tag because major new features introduced.

This commit is contained in:
Storm Dragon 2025-06-17 00:53:28 -04:00
commit efb308ac72
54 changed files with 2203 additions and 9 deletions

View File

@ -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

View File

@ -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

Binary file not shown.

Binary file not shown.

View File

@ -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'

View File

@ -0,0 +1,71 @@
#!/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 (use setSetting to update settingArgDict)
settingsManager.setSetting('speech', 'driver', 'speechdDriver')
settingsManager.setSetting('speech', 'module', module)
settingsManager.setSetting('speech', 'voice', voice)
# Apply to speech driver instance directly
if 'speechDriver' in self.env['runtime']:
speechDriver = self.env['runtime']['speechDriver']
# Set the module and voice on the driver instance
speechDriver.setModule(module)
speechDriver.setVoice(voice)
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.setSetting('speech', 'driver', oldDriver)
settingsManager.setSetting('speech', 'module', oldModule)
settingsManager.setSetting('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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,22 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributers.
class command():
def __init__(self):
pass
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def getDescription(self):
return _('jump down 10% in v menu')
def run(self):
self.env['runtime']['vmenuManager'].pageDown()
text = self.env['runtime']['vmenuManager'].getCurrentEntry()
self.env['runtime']['outputManager'].presentText(text, interrupt=True)
def setCallback(self, callback):
pass

View File

@ -0,0 +1,22 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributers.
class command():
def __init__(self):
pass
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def getDescription(self):
return _('jump up 10% in v menu')
def run(self):
self.env['runtime']['vmenuManager'].pageUp()
text = self.env['runtime']['vmenuManager'].getCurrentEntry()
self.env['runtime']['outputManager'].presentText(text, interrupt=True)
def setCallback(self, callback):
pass

View File

@ -0,0 +1 @@
# Fenrir Configuration VMenu Profile

View File

@ -0,0 +1 @@
# Fenrir general configuration

View File

@ -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')

View File

@ -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')

View File

@ -0,0 +1 @@
# Fenrir keyboard configuration

View File

@ -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)

View File

@ -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')

View File

@ -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')

View File

@ -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')

View File

@ -0,0 +1 @@
# Fenrir management configuration

View File

@ -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)

View File

@ -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')

View File

@ -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)

View File

@ -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

View File

@ -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')

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@
# Fenrir screen configuration

View File

@ -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')

View File

@ -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')

View File

@ -0,0 +1 @@
# Fenrir sound configuration

View File

@ -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')

View File

@ -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')

View File

@ -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

View File

@ -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")

View File

@ -0,0 +1 @@
# Fenrir speech configuration

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,270 @@
#!/usr/bin/env python3
import subprocess
import importlib.util
import os
import time
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 voice {self.voice} from {self.module}. Please wait.", interrupt=True)
# Brief pause before testing to avoid speech overlap
time.sleep(0.5)
# Test voice
testResult, errorMsg = self.testVoice()
if testResult:
self.env['runtime']['outputManager'].presentText("Voice test completed successfully. Navigate to Apply Tested Voice to use this voice.", interrupt=False, flush=False)
# Store for confirmation (use same variables as apply_tested_voice.py)
self.env['commandBuffer']['lastTestedModule'] = self.module
self.env['commandBuffer']['lastTestedVoice'] = self.voice
self.env['commandBuffer']['pendingVoiceModule'] = self.module
self.env['commandBuffer']['pendingVoiceVoice'] = self.voice
self.env['commandBuffer']['voiceTestCompleted'] = True
else:
self.env['runtime']['outputManager'].presentText(f"Voice test failed: {errorMsg}", interrupt=False, flush=False)
except Exception as e:
self.env['runtime']['outputManager'].presentText(f"Voice selection error: {str(e)}", interrupt=False, flush=False)
def testVoice(self):
"""Test voice with spd-say"""
try:
cmd = ['spd-say', '-C', '-w', '-o', self.module, '-y', self.voice, self.testMessage]
result = subprocess.run(cmd, timeout=8, capture_output=True, text=True)
if result.returncode == 0:
return True, "Voice test successful"
else:
error_msg = result.stderr.strip() if result.stderr else f"Command failed with return code {result.returncode}"
return False, error_msg
except subprocess.TimeoutExpired:
return False, "Voice test timed out"
except Exception as e:
return False, f"Error running voice test: {str(e)}"
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)
# Debug: Show current settings
settingsManager = self.env['runtime']['settingsManager']
currentModule = settingsManager.getSetting('speech', 'module')
currentVoice = settingsManager.getSetting('speech', 'voice')
self.env['runtime']['outputManager'].presentText(f"Current: {currentVoice} from {currentModule}", interrupt=False, flush=False)
# Apply to runtime settings with fallback
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 (use setSetting to update settingArgDict)
settingsManager.setSetting('speech', 'driver', 'speechdDriver')
settingsManager.setSetting('speech', 'module', module)
settingsManager.setSetting('speech', 'voice', voice)
# Apply settings to speech driver directly
if 'speechDriver' in self.env['runtime']:
speechDriver = self.env['runtime']['speechDriver']
# Get current module to see if we're changing modules
currentModule = settingsManager.getSetting('speech', 'module')
moduleChanging = (currentModule != module)
# Set module and voice on driver instance first
speechDriver.setModule(module)
speechDriver.setVoice(voice)
if moduleChanging:
# Module change requires reinitializing the speech driver
self.env['runtime']['outputManager'].presentText(f"Switching from {currentModule} to {module} module", interrupt=True)
speechDriver.shutdown()
speechDriver.initialize(self.env)
# Re-set after initialization
speechDriver.setModule(module)
speechDriver.setVoice(voice)
self.env['runtime']['outputManager'].presentText("Speech driver reinitialized", interrupt=True)
# Debug: verify what was actually set
self.env['runtime']['outputManager'].presentText(f"Speech driver now has module: {speechDriver.module}, voice: {speechDriver.voice}", interrupt=True)
# Force application by speaking a test message
self.env['runtime']['outputManager'].presentText("Voice applied successfully! You should hear this in the new voice.", interrupt=True)
# Brief pause then more speech to test
time.sleep(1)
self.env['runtime']['outputManager'].presentText("Use save settings to make permanent", interrupt=True)
# Clear pending state
self.env['commandBuffer']['voiceTestCompleted'] = False
# Exit vmenu after successful application
self.env['runtime']['vmenuManager'].setActive(False)
except Exception as e:
# Revert on failure
settingsManager.settings['speech']['driver'] = oldDriver
settingsManager.settings['speech']['module'] = oldModule
settingsManager.settings['speech']['voice'] = oldVoice
# Try to reinitialize with old settings
if 'speechDriver' in self.env['runtime']:
try:
speechDriver = self.env['runtime']['speechDriver']
speechDriver.shutdown()
speechDriver.initialize(self.env)
except:
pass # If this fails, at least we tried
self.env['runtime']['outputManager'].presentText(f"Failed to apply voice, reverted: {str(e)}", interrupt=False, flush=False)
except Exception as e:
self.env['runtime']['outputManager'].presentText(f"Apply voice error: {str(e)}", interrupt=False, flush=False)
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
voices = getModuleVoices(module)
if voices:
# 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

View File

@ -192,3 +192,12 @@ class outputManager():
self.presentText(' review cursor ', interrupt=interrupt_p)
else:
self.presentText(' text cursor ', interrupt=interrupt_p)
def resetSpeechDriver(self):
"""Reset speech driver to clean state - called by settingsManager"""
if 'speechDriver' in self.env['runtime'] and self.env['runtime']['speechDriver']:
try:
self.env['runtime']['speechDriver'].reset()
self.env['runtime']['debug'].writeDebugOut("Speech driver reset successfully", debug.debugLevel.INFO)
except Exception as e:
self.env['runtime']['debug'].writeDebugOut(f"resetSpeechDriver error: {e}", debug.debugLevel.ERROR)

View File

@ -138,6 +138,9 @@ class vmenuManager():
self.env['bindings'][str([1, ['KEY_X']])] = 'SEARCH_X'
self.env['bindings'][str([1, ['KEY_Y']])] = 'SEARCH_Y'
self.env['bindings'][str([1, ['KEY_Z']])] = 'SEARCH_Z'
# page navigation
self.env['bindings'][str([1, ['KEY_PAGEUP']])] = 'PAGE_UP_VMENU'
self.env['bindings'][str([1, ['KEY_PAGEDOWN']])] = 'PAGE_DOWN_VMENU'
except Exception as e:
print(e)
else:
@ -152,6 +155,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:
@ -218,6 +229,32 @@ class vmenuManager():
else:
self.currIndex[len(self.currIndex) - 1] -= 1
return True
def pageUp(self):
if self.currIndex == None:
return False
menuSize = len(self.getNestedByPath(self.menuDict, self.currIndex[:-1]))
if menuSize <= 1:
return False
jumpSize = max(1, int(menuSize * 0.1)) # 10% of menu size, minimum 1
newIndex = self.currIndex[len(self.currIndex) - 1] - jumpSize
if newIndex < 0:
newIndex = 0
self.currIndex[len(self.currIndex) - 1] = newIndex
return True
def pageDown(self):
if self.currIndex == None:
return False
menuSize = len(self.getNestedByPath(self.menuDict, self.currIndex[:-1]))
if menuSize <= 1:
return False
jumpSize = max(1, int(menuSize * 0.1)) # 10% of menu size, minimum 1
newIndex = self.currIndex[len(self.currIndex) - 1] + jumpSize
if newIndex >= menuSize:
newIndex = menuSize - 1
self.currIndex[len(self.currIndex) - 1] = newIndex
return True
def getCurrentEntry(self):
return self.getKeysByPath(self.menuDict, self.currIndex)[self.currIndex[-1]]

View File

@ -4,5 +4,5 @@
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributers.
version = "2025.06.07"
version = "2025.06.17"
codeName = "master"

View File

@ -16,9 +16,15 @@ class driver(speechDriver):
self._sd = None
self.env = environment
self._isInitialized = False
self.language = ''
self.voice = ''
self.module = ''
# Only set these if they haven't been set yet (preserve existing values)
if not hasattr(self, 'language') or self.language is None:
self.language = ''
if not hasattr(self, 'voice') or self.voice is None:
self.voice = ''
if not hasattr(self, 'module') or self.module is None:
self.module = ''
try:
import speechd
self._sd = speechd.SSIPClient('fenrir')