Compare commits
7 Commits
2766f70c5d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c66a9ba9c2 | ||
|
|
2092a3e257 | ||
|
|
d46d8de3ee | ||
|
|
75a8447759 | ||
|
|
1650eec768 | ||
|
|
5bb786ef4c | ||
|
|
7f7faa17d3 |
@@ -3,9 +3,9 @@
|
|||||||
enabled=True
|
enabled=True
|
||||||
|
|
||||||
# Select the driver used to play sounds, choices are genericDriver and gstreamerDriver.
|
# Select the driver used to play sounds, choices are genericDriver and gstreamerDriver.
|
||||||
# Sox is the default.
|
# Gstreamer is the default.
|
||||||
#driver=gstreamerDriver
|
driver=gstreamerDriver
|
||||||
driver=genericDriver
|
#driver=genericDriver
|
||||||
|
|
||||||
# Sound themes. These are the pack of sounds used for sound alerts.
|
# Sound themes. These are the pack of sounds used for sound alerts.
|
||||||
# Sound packs may be located at /usr/share/sounds
|
# Sound packs may be located at /usr/share/sounds
|
||||||
@@ -217,6 +217,13 @@ list=
|
|||||||
|
|
||||||
[menu]
|
[menu]
|
||||||
vmenuPath=
|
vmenuPath=
|
||||||
|
# quickMenu: Semicolon-separated list of settings for quick adjustment
|
||||||
|
# Format: section#setting;section#setting;...
|
||||||
|
# Supported settings:
|
||||||
|
# - speech#rate, speech#pitch, speech#volume (0.0-1.0)
|
||||||
|
# - speech#module, speech#voice (speechdDriver only, auto-added)
|
||||||
|
# Note: speech#module and speech#voice are automatically added when
|
||||||
|
# speechdDriver is active. Do not add them manually.
|
||||||
quickMenu=speech#rate;speech#pitch;speech#volume
|
quickMenu=speech#rate;speech#pitch;speech#volume
|
||||||
|
|
||||||
[prompt]
|
[prompt]
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
# Fenrir TTY screen reader
|
||||||
|
# By Chrys, Storm Dragon, and contributors.
|
||||||
|
|
||||||
|
from fenrirscreenreader.core.i18n import _
|
||||||
|
from fenrirscreenreader.core import debug
|
||||||
|
|
||||||
|
|
||||||
|
class command:
|
||||||
|
def __init__(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def initialize(self, environment):
|
||||||
|
self.env = environment
|
||||||
|
|
||||||
|
def shutdown(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_description(self):
|
||||||
|
return _(
|
||||||
|
"TUI focus mode handler - suppresses screen update spam "
|
||||||
|
"for interactive TUI applications"
|
||||||
|
)
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
# Check if TUI mode is enabled
|
||||||
|
if not self.env["runtime"]["SettingsManager"].get_setting_as_bool(
|
||||||
|
"focus", "tui"
|
||||||
|
):
|
||||||
|
return
|
||||||
|
|
||||||
|
# TUI mode is active - set suppression flag for incoming handler
|
||||||
|
# This prevents the 70000-incoming.py command from announcing
|
||||||
|
# screen updates
|
||||||
|
self.env["commandBuffer"]["tuiSuppressIncoming"] = True
|
||||||
|
|
||||||
|
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||||
|
"tui_focus_handler: TUI mode active, suppressing incoming text",
|
||||||
|
debug.DebugLevel.INFO
|
||||||
|
)
|
||||||
|
|
||||||
|
def set_callback(self, callback):
|
||||||
|
pass
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
|
|
||||||
import importlib.util
|
|
||||||
import os
|
|
||||||
|
|
||||||
from fenrirscreenreader.core.i18n import _
|
|
||||||
|
|
||||||
# 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)
|
|
||||||
config_command = _module.config_command
|
|
||||||
|
|
||||||
|
|
||||||
class command(config_command):
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
|
|
||||||
def get_description(self):
|
|
||||||
return "Set keyboard layout to Desktop"
|
|
||||||
|
|
||||||
def run(self):
|
|
||||||
current_layout = self.get_setting("keyboard", "keyboardLayout", "desktop")
|
|
||||||
|
|
||||||
if current_layout.lower() == "desktop":
|
|
||||||
self.present_text("Keyboard layout already set to Desktop")
|
|
||||||
return
|
|
||||||
|
|
||||||
success = self.set_setting("keyboard", "keyboardLayout", "desktop")
|
|
||||||
|
|
||||||
if success:
|
|
||||||
self.present_text("Keyboard layout set to Desktop")
|
|
||||||
self.present_text("Please restart Fenrir for this change to take effect")
|
|
||||||
self.play_sound("Accept")
|
|
||||||
else:
|
|
||||||
self.present_text("Failed to change keyboard layout")
|
|
||||||
self.play_sound("Error")
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
|
|
||||||
import importlib.util
|
|
||||||
import os
|
|
||||||
|
|
||||||
from fenrirscreenreader.core.i18n import _
|
|
||||||
|
|
||||||
# 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)
|
|
||||||
config_command = _module.config_command
|
|
||||||
|
|
||||||
|
|
||||||
class command(config_command):
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
|
|
||||||
def get_description(self):
|
|
||||||
return "Set keyboard layout to Laptop"
|
|
||||||
|
|
||||||
def run(self):
|
|
||||||
current_layout = self.get_setting("keyboard", "keyboardLayout", "desktop")
|
|
||||||
|
|
||||||
if current_layout.lower() == "laptop":
|
|
||||||
self.present_text("Keyboard layout already set to Laptop")
|
|
||||||
return
|
|
||||||
|
|
||||||
success = self.set_setting("keyboard", "keyboardLayout", "laptop")
|
|
||||||
|
|
||||||
if success:
|
|
||||||
self.present_text("Keyboard layout set to Laptop")
|
|
||||||
self.present_text("Please restart Fenrir for this change to take effect")
|
|
||||||
self.play_sound("Accept")
|
|
||||||
else:
|
|
||||||
self.present_text("Failed to change keyboard layout")
|
|
||||||
self.play_sound("Error")
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
|
|
||||||
import importlib.util
|
|
||||||
import os
|
|
||||||
|
|
||||||
from fenrirscreenreader.core.i18n import _
|
|
||||||
|
|
||||||
# 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)
|
|
||||||
config_command = _module.config_command
|
|
||||||
|
|
||||||
|
|
||||||
class command(config_command):
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
|
|
||||||
def get_description(self):
|
|
||||||
return "Set keyboard layout to PTY (terminal emulation)"
|
|
||||||
|
|
||||||
def run(self):
|
|
||||||
current_layout = self.get_setting("keyboard", "keyboardLayout", "desktop")
|
|
||||||
|
|
||||||
if current_layout.lower() == "pty":
|
|
||||||
self.present_text("Keyboard layout already set to PTY")
|
|
||||||
return
|
|
||||||
|
|
||||||
success = self.set_setting("keyboard", "keyboardLayout", "pty")
|
|
||||||
|
|
||||||
if success:
|
|
||||||
self.present_text("Keyboard layout set to PTY for terminal emulation")
|
|
||||||
self.present_text("Please restart Fenrir for this change to take effect")
|
|
||||||
self.play_sound("Accept")
|
|
||||||
else:
|
|
||||||
self.present_text("Failed to change keyboard layout")
|
|
||||||
self.play_sound("Error")
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
|
|
||||||
import importlib.util
|
|
||||||
import os
|
|
||||||
|
|
||||||
from fenrirscreenreader.core.i18n import _
|
|
||||||
|
|
||||||
# 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)
|
|
||||||
config_command = _module.config_command
|
|
||||||
|
|
||||||
|
|
||||||
class command(config_command):
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
|
|
||||||
def get_description(self):
|
|
||||||
return "Set keyboard layout to PTY2 (alternative terminal layout)"
|
|
||||||
|
|
||||||
def run(self):
|
|
||||||
current_layout = self.get_setting("keyboard", "keyboardLayout", "desktop")
|
|
||||||
|
|
||||||
if current_layout.lower() == "pty2":
|
|
||||||
self.present_text("Keyboard layout already set to PTY2")
|
|
||||||
return
|
|
||||||
|
|
||||||
success = self.set_setting("keyboard", "keyboardLayout", "pty2")
|
|
||||||
|
|
||||||
if success:
|
|
||||||
self.present_text("Keyboard layout set to PTY2 alternative terminal layout")
|
|
||||||
self.present_text("Please restart Fenrir for this change to take effect")
|
|
||||||
self.play_sound("Accept")
|
|
||||||
else:
|
|
||||||
self.present_text("Failed to change keyboard layout")
|
|
||||||
self.play_sound("Error")
|
|
||||||
203
src/fenrirscreenreader/core/dynamicKeyboardLayoutMenu.py
Normal file
203
src/fenrirscreenreader/core/dynamicKeyboardLayoutMenu.py
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
|
||||||
|
from fenrirscreenreader.core import debug
|
||||||
|
|
||||||
|
|
||||||
|
class DynamicKeyboardLayoutCommand:
|
||||||
|
"""Dynamic command class for keyboard layout selection"""
|
||||||
|
|
||||||
|
def __init__(self, layoutName, layoutPath, env):
|
||||||
|
self.layoutName = layoutName
|
||||||
|
self.layoutPath = layoutPath
|
||||||
|
# Extract just the base name without extension for comparison
|
||||||
|
self.layoutBaseName = layoutName
|
||||||
|
self.env = env
|
||||||
|
|
||||||
|
def initialize(self, environment):
|
||||||
|
self.env = environment
|
||||||
|
|
||||||
|
def shutdown(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_description(self):
|
||||||
|
return f"Set keyboard layout to {self.layoutName}"
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
try:
|
||||||
|
settingsManager = self.env["runtime"]["SettingsManager"]
|
||||||
|
currentLayout = settingsManager.get_setting(
|
||||||
|
"keyboard", "keyboardLayout"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if already set (compare both full path and base name)
|
||||||
|
currentBaseName = os.path.splitext(os.path.basename(currentLayout))[0] if currentLayout else ""
|
||||||
|
if currentBaseName.lower() == self.layoutBaseName.lower() or currentLayout.lower() == self.layoutPath.lower():
|
||||||
|
self.env["runtime"]["OutputManager"].present_text(
|
||||||
|
f"Keyboard layout already set to {self.layoutName}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Set the new layout in the config file using full path
|
||||||
|
try:
|
||||||
|
# Update the setting in memory
|
||||||
|
settingsManager.set_setting(
|
||||||
|
"keyboard", "keyboardLayout", self.layoutPath
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save to the actual config file
|
||||||
|
configFilePath = settingsManager.get_settings_file()
|
||||||
|
settingsManager.save_settings(configFilePath)
|
||||||
|
|
||||||
|
self.env["runtime"]["OutputManager"].present_text(
|
||||||
|
f"Keyboard layout set to {self.layoutName}. Please restart Fenrir for this change to take effect."
|
||||||
|
)
|
||||||
|
# Play accept sound
|
||||||
|
self.env["runtime"]["OutputManager"].present_text(
|
||||||
|
"", sound_icon="Accept", interrupt=False
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self.env["runtime"]["OutputManager"].present_text(
|
||||||
|
f"Failed to change keyboard layout to {self.layoutName}"
|
||||||
|
)
|
||||||
|
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||||
|
f"DynamicKeyboardLayout: Error setting layout {self.layoutName}: {e}",
|
||||||
|
debug.DebugLevel.ERROR,
|
||||||
|
)
|
||||||
|
# Play error sound
|
||||||
|
self.env["runtime"]["OutputManager"].present_text(
|
||||||
|
"", sound_icon="ErrorSound", interrupt=False
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.env["runtime"]["OutputManager"].present_text(
|
||||||
|
f"Keyboard layout change error: {str(e)}", interrupt=True
|
||||||
|
)
|
||||||
|
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||||
|
f"DynamicKeyboardLayout: Unexpected error for {self.layoutName}: {e}",
|
||||||
|
debug.DebugLevel.ERROR,
|
||||||
|
)
|
||||||
|
|
||||||
|
def set_callback(self, callback):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def add_dynamic_keyboard_layout_menus(VmenuManager):
|
||||||
|
"""Add dynamic keyboard layout menus to vmenu system"""
|
||||||
|
try:
|
||||||
|
env = VmenuManager.env
|
||||||
|
|
||||||
|
# Get keyboard layout files
|
||||||
|
layouts = get_keyboard_layouts(env)
|
||||||
|
if not layouts:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create keyboard layouts submenu
|
||||||
|
layoutMenu = {}
|
||||||
|
|
||||||
|
# Add layout commands
|
||||||
|
for layoutName, layoutPath in layouts:
|
||||||
|
layoutCommand = DynamicKeyboardLayoutCommand(
|
||||||
|
layoutName, layoutPath, env
|
||||||
|
)
|
||||||
|
layoutMenu[f"{layoutName} Action"] = layoutCommand
|
||||||
|
|
||||||
|
# Find keyboard menu in existing vmenu structure
|
||||||
|
# If fenrir menu exists, add layouts under it
|
||||||
|
if "fenrir Menu" in VmenuManager.menuDict:
|
||||||
|
fenrirMenu = VmenuManager.menuDict["fenrir Menu"]
|
||||||
|
if "keyboard Menu" in fenrirMenu:
|
||||||
|
# Add dynamic layouts to existing keyboard menu
|
||||||
|
keyboardMenu = fenrirMenu["keyboard Menu"]
|
||||||
|
keyboardMenu["Keyboard Layouts Menu"] = layoutMenu
|
||||||
|
else:
|
||||||
|
# Create keyboard menu with layouts
|
||||||
|
fenrirMenu["keyboard Menu"] = {
|
||||||
|
"Keyboard Layouts Menu": layoutMenu
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Create standalone keyboard layouts menu
|
||||||
|
VmenuManager.menuDict["Keyboard Layouts Menu"] = layoutMenu
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Use debug manager for error logging
|
||||||
|
if "DebugManager" in env.get("runtime", {}):
|
||||||
|
env["runtime"]["DebugManager"].write_debug_out(
|
||||||
|
f"Error creating dynamic keyboard layout menus: {e}",
|
||||||
|
debug.DebugLevel.ERROR,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(f"Error creating dynamic keyboard layout menus: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def get_keyboard_layouts(env):
|
||||||
|
"""Get available keyboard layouts from keyboard directory"""
|
||||||
|
layouts = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get keyboard directory paths
|
||||||
|
keyboardDirs = []
|
||||||
|
|
||||||
|
# Check system installation path
|
||||||
|
systemKeyboardPath = "/etc/fenrirscreenreader/keyboard/"
|
||||||
|
if os.path.exists(systemKeyboardPath):
|
||||||
|
keyboardDirs.append(systemKeyboardPath)
|
||||||
|
|
||||||
|
# Check source/development path
|
||||||
|
try:
|
||||||
|
import fenrirscreenreader
|
||||||
|
|
||||||
|
fenrirPath = os.path.dirname(fenrirscreenreader.__file__)
|
||||||
|
devKeyboardPath = os.path.join(
|
||||||
|
fenrirPath, "..", "..", "config", "keyboard"
|
||||||
|
)
|
||||||
|
devKeyboardPath = os.path.abspath(devKeyboardPath)
|
||||||
|
if os.path.exists(devKeyboardPath):
|
||||||
|
keyboardDirs.append(devKeyboardPath)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Get current layout setting path
|
||||||
|
try:
|
||||||
|
currentLayoutSetting = env["runtime"]["SettingsManager"].get_setting(
|
||||||
|
"keyboard", "keyboardLayout"
|
||||||
|
)
|
||||||
|
if currentLayoutSetting and os.path.exists(currentLayoutSetting):
|
||||||
|
currentLayoutDir = os.path.dirname(currentLayoutSetting)
|
||||||
|
if currentLayoutDir not in keyboardDirs:
|
||||||
|
keyboardDirs.append(currentLayoutDir)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Scan for .conf files
|
||||||
|
seenLayouts = set()
|
||||||
|
for keyboardDir in keyboardDirs:
|
||||||
|
try:
|
||||||
|
confFiles = glob.glob(os.path.join(keyboardDir, "*.conf"))
|
||||||
|
for confFile in confFiles:
|
||||||
|
layoutName = os.path.splitext(os.path.basename(confFile))[
|
||||||
|
0
|
||||||
|
]
|
||||||
|
if layoutName not in seenLayouts:
|
||||||
|
seenLayouts.add(layoutName)
|
||||||
|
layouts.append((layoutName, confFile))
|
||||||
|
except Exception as e:
|
||||||
|
if "DebugManager" in env.get("runtime", {}):
|
||||||
|
env["runtime"]["DebugManager"].write_debug_out(
|
||||||
|
f"Error scanning keyboard directory {keyboardDir}: {e}",
|
||||||
|
debug.DebugLevel.WARNING,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sort layouts alphabetically
|
||||||
|
layouts.sort(key=lambda x: x[0].lower())
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if "DebugManager" in env.get("runtime", {}):
|
||||||
|
env["runtime"]["DebugManager"].write_debug_out(
|
||||||
|
f"Error getting keyboard layouts: {e}",
|
||||||
|
debug.DebugLevel.ERROR,
|
||||||
|
)
|
||||||
|
|
||||||
|
return layouts
|
||||||
@@ -7,25 +7,285 @@
|
|||||||
from fenrirscreenreader.core import debug
|
from fenrirscreenreader.core import debug
|
||||||
from fenrirscreenreader.core.i18n import _
|
from fenrirscreenreader.core.i18n import _
|
||||||
from fenrirscreenreader.core.settingsData import settings_data
|
from fenrirscreenreader.core.settingsData import settings_data
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
class QuickMenuManager:
|
class SpeechHelperMixin:
|
||||||
|
"""Helper methods for querying speech-dispatcher modules and voices.
|
||||||
|
|
||||||
|
Provides caching and query functionality for speech-dispatcher module
|
||||||
|
and voice enumeration, reusing proven logic from voice_browser.py.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
self._modules_cache = None
|
||||||
|
self._voices_cache = {} # {module_name: [voice_list]}
|
||||||
|
self._cache_timestamp = 0
|
||||||
|
self._cache_timeout = 300 # 5 minutes
|
||||||
|
|
||||||
|
def get_speechd_modules(self):
|
||||||
|
"""Get available speech-dispatcher modules (cached).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: Available module names (e.g., ['espeak-ng', 'festival'])
|
||||||
|
"""
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
# Return cached if valid
|
||||||
|
if (self._modules_cache and
|
||||||
|
(now - self._cache_timestamp) < self._cache_timeout):
|
||||||
|
return self._modules_cache
|
||||||
|
|
||||||
|
# Query spd-say
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["spd-say", "-O"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=8
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
lines = result.stdout.strip().split("\n")
|
||||||
|
self._modules_cache = [
|
||||||
|
line.strip() for line in lines[1:]
|
||||||
|
if line.strip() and line.strip().lower() != "dummy"
|
||||||
|
]
|
||||||
|
self._cache_timestamp = now
|
||||||
|
return self._modules_cache
|
||||||
|
except Exception as e:
|
||||||
|
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||||
|
(f"QuickMenuManager get_speechd_modules: "
|
||||||
|
f"Error querying modules: {e}"),
|
||||||
|
debug.DebugLevel.ERROR
|
||||||
|
)
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_module_voices(self, module):
|
||||||
|
"""Get voices for a specific module (cached per-module).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
module (str): Module name (e.g., 'espeak-ng')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list: Available voice names for this module
|
||||||
|
"""
|
||||||
|
# Return cached if available
|
||||||
|
if module in self._voices_cache:
|
||||||
|
return self._voices_cache[module]
|
||||||
|
|
||||||
|
# Query spd-say
|
||||||
|
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 = self._process_espeak_voice(line)
|
||||||
|
if voice:
|
||||||
|
voices.append(voice)
|
||||||
|
elif module.lower() == "voxin":
|
||||||
|
# For Voxin, store voice name with language
|
||||||
|
voice_data = self._process_voxin_voice(line)
|
||||||
|
if voice_data:
|
||||||
|
voices.append(voice_data)
|
||||||
|
else:
|
||||||
|
# For other modules, extract first field (voice name)
|
||||||
|
parts = line.strip().split()
|
||||||
|
if parts:
|
||||||
|
voices.append(parts[0])
|
||||||
|
|
||||||
|
self._voices_cache[module] = voices
|
||||||
|
return voices
|
||||||
|
except Exception as e:
|
||||||
|
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||||
|
(f"QuickMenuManager get_module_voices: "
|
||||||
|
f"Error querying voices for {module}: {e}"),
|
||||||
|
debug.DebugLevel.ERROR
|
||||||
|
)
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _process_espeak_voice(self, voice_line):
|
||||||
|
"""Process espeak-ng voice format into usable voice name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
voice_line (str): Raw line from spd-say -L output
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Processed voice name (e.g., 'en-us' or 'en-us+f3')
|
||||||
|
"""
|
||||||
|
parts = [p for p in voice_line.split() if p]
|
||||||
|
if len(parts) < 2:
|
||||||
|
return None
|
||||||
|
lang_code = parts[-2].lower()
|
||||||
|
variant = parts[-1].lower()
|
||||||
|
return (f"{lang_code}+{variant}"
|
||||||
|
if variant and variant != "none" else lang_code)
|
||||||
|
|
||||||
|
def _process_voxin_voice(self, voice_line):
|
||||||
|
"""Process Voxin voice format with language information.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
voice_line (str): Raw line from spd-say -o voxin -L output
|
||||||
|
Format: NAME LANGUAGE VARIANT
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Voice name with language encoded (e.g., 'daniel-embedded-high|en-GB')
|
||||||
|
"""
|
||||||
|
parts = [p for p in voice_line.split() if p]
|
||||||
|
if len(parts) < 2:
|
||||||
|
return None
|
||||||
|
voice_name = parts[0]
|
||||||
|
language = parts[1]
|
||||||
|
# Encode language with voice for later extraction
|
||||||
|
return f"{voice_name}|{language}"
|
||||||
|
|
||||||
|
def _select_default_voice(self, voices):
|
||||||
|
"""Select a sensible default voice from list, preferring user's
|
||||||
|
language.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
voices (list): List of available voice names
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Selected default voice (matches user language if possible)
|
||||||
|
"""
|
||||||
|
if not voices:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Get current voice to preserve language preference
|
||||||
|
current_voice = self.env["runtime"]["SettingsManager"].get_setting(
|
||||||
|
"speech", "voice"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get configured language from settings
|
||||||
|
configured_lang = self.env["runtime"]["SettingsManager"].get_setting(
|
||||||
|
"speech", "language"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract language code from current voice if available
|
||||||
|
current_lang = None
|
||||||
|
if current_voice:
|
||||||
|
# Extract language code (e.g., 'en-gb' from 'en-gb+male')
|
||||||
|
current_lang = current_voice.split('+')[0].lower()
|
||||||
|
|
||||||
|
# Build preference list: current language, configured language, English
|
||||||
|
preferences = []
|
||||||
|
if current_lang:
|
||||||
|
preferences.append(current_lang)
|
||||||
|
if configured_lang:
|
||||||
|
preferences.append(configured_lang.lower())
|
||||||
|
preferences.extend(['en-gb', 'en-us', 'en'])
|
||||||
|
|
||||||
|
# Remove duplicates while preserving order
|
||||||
|
seen = set()
|
||||||
|
preferences = [x for x in preferences
|
||||||
|
if not (x in seen or seen.add(x))]
|
||||||
|
|
||||||
|
# Try exact matches for preferred languages
|
||||||
|
for pref in preferences:
|
||||||
|
for voice in voices:
|
||||||
|
# Extract language if voice is in "name|lang" format
|
||||||
|
voice_to_check = voice
|
||||||
|
if "|" in voice:
|
||||||
|
_, voice_lang = voice.split("|", 1)
|
||||||
|
voice_to_check = voice_lang
|
||||||
|
if voice_to_check.lower() == pref:
|
||||||
|
return voice
|
||||||
|
|
||||||
|
# Try voices starting with preferred language codes
|
||||||
|
for pref in preferences:
|
||||||
|
for voice in voices:
|
||||||
|
# Extract language if voice is in "name|lang" format
|
||||||
|
voice_to_check = voice
|
||||||
|
if "|" in voice:
|
||||||
|
_, voice_lang = voice.split("|", 1)
|
||||||
|
voice_to_check = voice_lang
|
||||||
|
if voice_to_check.lower().startswith(pref):
|
||||||
|
return voice
|
||||||
|
|
||||||
|
# Fall back to first available voice
|
||||||
|
return voices[0]
|
||||||
|
|
||||||
|
def invalidate_speech_cache(self):
|
||||||
|
"""Clear cached module and voice data."""
|
||||||
|
self._modules_cache = None
|
||||||
|
self._voices_cache = {}
|
||||||
|
self._cache_timestamp = 0
|
||||||
|
|
||||||
|
|
||||||
|
class QuickMenuManager(SpeechHelperMixin):
|
||||||
|
def __init__(self):
|
||||||
|
SpeechHelperMixin.__init__(self)
|
||||||
self.position = 0
|
self.position = 0
|
||||||
self.quickMenu = []
|
self.quickMenu = []
|
||||||
self.settings = settings_data
|
self.settings = settings_data
|
||||||
|
|
||||||
def initialize(self, environment):
|
def initialize(self, environment):
|
||||||
self.env = environment
|
self.env = environment
|
||||||
self.load_menu(
|
|
||||||
self.env["runtime"]["SettingsManager"].get_setting(
|
# Load base menu from config
|
||||||
"menu", "quickMenu"
|
menu_string = self.env["runtime"]["SettingsManager"].get_setting(
|
||||||
)
|
"menu", "quickMenu"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Dynamically add speech-dispatcher specific items
|
||||||
|
if self._is_speechd_driver_active():
|
||||||
|
menu_string = self._add_speechd_menu_items(menu_string)
|
||||||
|
|
||||||
|
self.load_menu(menu_string)
|
||||||
|
|
||||||
def shutdown(self):
|
def shutdown(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def _is_speechd_driver_active(self):
|
||||||
|
"""Check if speechdDriver is currently active.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if speech driver is speechdDriver
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
driver = self.env["runtime"]["SettingsManager"].get_setting(
|
||||||
|
"speech", "driver"
|
||||||
|
)
|
||||||
|
return driver == "speechdDriver"
|
||||||
|
except Exception as e:
|
||||||
|
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||||
|
(f"QuickMenuManager _is_speechd_driver_active: "
|
||||||
|
f"Error checking driver: {e}"),
|
||||||
|
debug.DebugLevel.ERROR
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _add_speechd_menu_items(self, menu_string):
|
||||||
|
"""Add speech-dispatcher module and voice to quick menu.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
menu_string (str): Existing menu string from config
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Updated menu string with module and voice added
|
||||||
|
"""
|
||||||
|
if not menu_string:
|
||||||
|
return "speech#module;speech#voice"
|
||||||
|
|
||||||
|
# Check if already present (user manually added)
|
||||||
|
if "speech#module" in menu_string or "speech#voice" in menu_string:
|
||||||
|
return menu_string
|
||||||
|
|
||||||
|
# Add module and voice after existing items
|
||||||
|
return f"{menu_string};speech#module;speech#voice"
|
||||||
|
|
||||||
def load_menu(self, menuString):
|
def load_menu(self, menuString):
|
||||||
self.position = 0
|
self.position = 0
|
||||||
self.quickMenu = []
|
self.quickMenu = []
|
||||||
@@ -86,8 +346,15 @@ class QuickMenuManager:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
if isinstance(self.settings[section][setting], str):
|
if isinstance(self.settings[section][setting], str):
|
||||||
value = str(value_string)
|
# Check for special string cycling cases
|
||||||
return False
|
if section == "speech" and setting == "module":
|
||||||
|
return self.cycle_speech_module("next")
|
||||||
|
elif section == "speech" and setting == "voice":
|
||||||
|
return self.cycle_speech_voice("next")
|
||||||
|
else:
|
||||||
|
# Generic strings not supported for cycling
|
||||||
|
value = str(value_string)
|
||||||
|
return False
|
||||||
elif isinstance(self.settings[section][setting], bool):
|
elif isinstance(self.settings[section][setting], bool):
|
||||||
if value_string not in ["True", "False"]:
|
if value_string not in ["True", "False"]:
|
||||||
return False
|
return False
|
||||||
@@ -132,8 +399,15 @@ class QuickMenuManager:
|
|||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
if isinstance(self.settings[section][setting], str):
|
if isinstance(self.settings[section][setting], str):
|
||||||
value = str(value_string)
|
# Check for special string cycling cases
|
||||||
return False
|
if section == "speech" and setting == "module":
|
||||||
|
return self.cycle_speech_module("prev")
|
||||||
|
elif section == "speech" and setting == "voice":
|
||||||
|
return self.cycle_speech_voice("prev")
|
||||||
|
else:
|
||||||
|
# Generic strings not supported for cycling
|
||||||
|
value = str(value_string)
|
||||||
|
return False
|
||||||
elif isinstance(self.settings[section][setting], bool):
|
elif isinstance(self.settings[section][setting], bool):
|
||||||
if value_string not in ["True", "False"]:
|
if value_string not in ["True", "False"]:
|
||||||
return False
|
return False
|
||||||
@@ -161,6 +435,199 @@ class QuickMenuManager:
|
|||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def cycle_speech_module(self, direction):
|
||||||
|
"""Cycle to next/previous speech-dispatcher module.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
direction (str): 'next' or 'prev'
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if successful, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get available modules
|
||||||
|
modules = self.get_speechd_modules()
|
||||||
|
if not modules:
|
||||||
|
self.env["runtime"]["OutputManager"].present_text(
|
||||||
|
"No modules available", interrupt=True
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Get current module
|
||||||
|
current_module = self.env["runtime"]["SettingsManager"].get_setting(
|
||||||
|
"speech", "module"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Find current index
|
||||||
|
try:
|
||||||
|
current_index = (modules.index(current_module)
|
||||||
|
if current_module else 0)
|
||||||
|
except ValueError:
|
||||||
|
current_index = 0
|
||||||
|
|
||||||
|
# Cycle to next/previous
|
||||||
|
if direction == "next":
|
||||||
|
new_index = (current_index + 1) % len(modules)
|
||||||
|
else: # prev
|
||||||
|
new_index = (current_index - 1) % len(modules)
|
||||||
|
|
||||||
|
new_module = modules[new_index]
|
||||||
|
|
||||||
|
# Update setting (runtime only)
|
||||||
|
self.env["runtime"]["SettingsManager"].set_setting(
|
||||||
|
"speech", "module", new_module
|
||||||
|
)
|
||||||
|
|
||||||
|
# Select sensible default voice for new module
|
||||||
|
voices = self.get_module_voices(new_module)
|
||||||
|
if voices:
|
||||||
|
default_voice = self._select_default_voice(voices)
|
||||||
|
|
||||||
|
# Parse voice name and language for modules like Voxin
|
||||||
|
voice_name = default_voice
|
||||||
|
voice_lang = None
|
||||||
|
if "|" in default_voice:
|
||||||
|
voice_name, voice_lang = default_voice.split("|", 1)
|
||||||
|
|
||||||
|
self.env["runtime"]["SettingsManager"].set_setting(
|
||||||
|
"speech", "voice", voice_name
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply voice to speech driver immediately
|
||||||
|
if "SpeechDriver" in self.env["runtime"]:
|
||||||
|
try:
|
||||||
|
self.env["runtime"]["SpeechDriver"].set_module(
|
||||||
|
new_module
|
||||||
|
)
|
||||||
|
# Set language first if available
|
||||||
|
if voice_lang:
|
||||||
|
self.env["runtime"]["SpeechDriver"].set_language(
|
||||||
|
voice_lang
|
||||||
|
)
|
||||||
|
# Then set voice
|
||||||
|
self.env["runtime"]["SpeechDriver"].set_voice(
|
||||||
|
voice_name
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||||
|
(f"QuickMenuManager cycle_speech_module: "
|
||||||
|
f"Error applying voice: {e}"),
|
||||||
|
debug.DebugLevel.ERROR
|
||||||
|
)
|
||||||
|
|
||||||
|
# Announce new module
|
||||||
|
self.env["runtime"]["OutputManager"].present_text(
|
||||||
|
new_module, interrupt=True
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||||
|
f"QuickMenuManager cycle_speech_module: Error: {e}",
|
||||||
|
debug.DebugLevel.ERROR
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def cycle_speech_voice(self, direction):
|
||||||
|
"""Cycle to next/previous voice for current module.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
direction (str): 'next' or 'prev'
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if successful, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get current module
|
||||||
|
current_module = self.env["runtime"]["SettingsManager"].get_setting(
|
||||||
|
"speech", "module"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not current_module:
|
||||||
|
self.env["runtime"]["OutputManager"].present_text(
|
||||||
|
"No module selected", interrupt=True
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Get available voices for this module
|
||||||
|
voices = self.get_module_voices(current_module)
|
||||||
|
if not voices:
|
||||||
|
self.env["runtime"]["OutputManager"].present_text(
|
||||||
|
f"No voices for module {current_module}", interrupt=True
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Get current voice
|
||||||
|
current_voice = self.env["runtime"]["SettingsManager"].get_setting(
|
||||||
|
"speech", "voice"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Find current index (handle Voxin voice|language format)
|
||||||
|
current_index = 0
|
||||||
|
if current_voice:
|
||||||
|
try:
|
||||||
|
# Try exact match first
|
||||||
|
current_index = voices.index(current_voice)
|
||||||
|
except ValueError:
|
||||||
|
# For Voxin, compare just the voice name part
|
||||||
|
for i, voice in enumerate(voices):
|
||||||
|
voice_name = voice.split("|")[0] if "|" in voice else voice
|
||||||
|
if voice_name == current_voice:
|
||||||
|
current_index = i
|
||||||
|
break
|
||||||
|
|
||||||
|
# Cycle to next/previous
|
||||||
|
if direction == "next":
|
||||||
|
new_index = (current_index + 1) % len(voices)
|
||||||
|
else: # prev
|
||||||
|
new_index = (current_index - 1) % len(voices)
|
||||||
|
|
||||||
|
new_voice = voices[new_index]
|
||||||
|
|
||||||
|
# Parse voice name and language for modules like Voxin
|
||||||
|
voice_name = new_voice
|
||||||
|
voice_lang = None
|
||||||
|
if "|" in new_voice:
|
||||||
|
# Format: "voicename|language" (e.g., "daniel-embedded-high|en-GB")
|
||||||
|
voice_name, voice_lang = new_voice.split("|", 1)
|
||||||
|
|
||||||
|
# Update setting (runtime only) - store the voice name only
|
||||||
|
self.env["runtime"]["SettingsManager"].set_setting(
|
||||||
|
"speech", "voice", voice_name
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply voice to speech driver immediately
|
||||||
|
if "SpeechDriver" in self.env["runtime"]:
|
||||||
|
try:
|
||||||
|
# Set language first if available
|
||||||
|
if voice_lang:
|
||||||
|
self.env["runtime"]["SpeechDriver"].set_language(
|
||||||
|
voice_lang
|
||||||
|
)
|
||||||
|
# Then set voice
|
||||||
|
self.env["runtime"]["SpeechDriver"].set_voice(voice_name)
|
||||||
|
except Exception as e:
|
||||||
|
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||||
|
(f"QuickMenuManager cycle_speech_voice: "
|
||||||
|
f"Error applying voice: {e}"),
|
||||||
|
debug.DebugLevel.ERROR
|
||||||
|
)
|
||||||
|
|
||||||
|
# Announce new voice (voice name only, not language)
|
||||||
|
self.env["runtime"]["OutputManager"].present_text(
|
||||||
|
voice_name, interrupt=True
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.env["runtime"]["DebugManager"].write_debug_out(
|
||||||
|
f"QuickMenuManager cycle_speech_voice: Error: {e}",
|
||||||
|
debug.DebugLevel.ERROR
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
def get_current_entry(self):
|
def get_current_entry(self):
|
||||||
if len(self.quickMenu) == 0:
|
if len(self.quickMenu) == 0:
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -217,6 +217,16 @@ class VmenuManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error adding dynamic voice menus: {e}")
|
print(f"Error adding dynamic voice menus: {e}")
|
||||||
|
|
||||||
|
# Add dynamic keyboard layout menus
|
||||||
|
try:
|
||||||
|
from fenrirscreenreader.core.dynamicKeyboardLayoutMenu import (
|
||||||
|
add_dynamic_keyboard_layout_menus,
|
||||||
|
)
|
||||||
|
|
||||||
|
add_dynamic_keyboard_layout_menus(self)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error adding dynamic keyboard layout menus: {e}")
|
||||||
|
|
||||||
# index still valid?
|
# index still valid?
|
||||||
if self.curr_index is not None:
|
if self.curr_index is not None:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -4,5 +4,5 @@
|
|||||||
# Fenrir TTY screen reader
|
# Fenrir TTY screen reader
|
||||||
# By Chrys, Storm Dragon, and contributors.
|
# By Chrys, Storm Dragon, and contributors.
|
||||||
|
|
||||||
version = "2025.11.24"
|
version = "2025.12.02"
|
||||||
code_name = "master"
|
code_name = "master"
|
||||||
|
|||||||
Reference in New Issue
Block a user