One more feature addition before hopefully releasing the new version.
This commit is contained in:
@@ -217,6 +217,13 @@ list=
|
||||
|
||||
[menu]
|
||||
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
|
||||
|
||||
[prompt]
|
||||
|
||||
@@ -7,25 +7,194 @@
|
||||
from fenrirscreenreader.core import debug
|
||||
from fenrirscreenreader.core.i18n import _
|
||||
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):
|
||||
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()
|
||||
]
|
||||
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)
|
||||
else:
|
||||
# For non-espeak 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 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.quickMenu = []
|
||||
self.settings = settings_data
|
||||
|
||||
def initialize(self, environment):
|
||||
self.env = environment
|
||||
self.load_menu(
|
||||
self.env["runtime"]["SettingsManager"].get_setting(
|
||||
"menu", "quickMenu"
|
||||
)
|
||||
|
||||
# Load base menu from config
|
||||
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):
|
||||
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):
|
||||
self.position = 0
|
||||
self.quickMenu = []
|
||||
@@ -86,8 +255,15 @@ class QuickMenuManager:
|
||||
|
||||
try:
|
||||
if isinstance(self.settings[section][setting], str):
|
||||
value = str(value_string)
|
||||
return False
|
||||
# Check for special string cycling cases
|
||||
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):
|
||||
if value_string not in ["True", "False"]:
|
||||
return False
|
||||
@@ -132,8 +308,15 @@ class QuickMenuManager:
|
||||
return False
|
||||
try:
|
||||
if isinstance(self.settings[section][setting], str):
|
||||
value = str(value_string)
|
||||
return False
|
||||
# Check for special string cycling cases
|
||||
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):
|
||||
if value_string not in ["True", "False"]:
|
||||
return False
|
||||
@@ -161,6 +344,138 @@ class QuickMenuManager:
|
||||
return False
|
||||
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
|
||||
)
|
||||
|
||||
# Reset voice to first available for new module
|
||||
voices = self.get_module_voices(new_module)
|
||||
if voices:
|
||||
self.env["runtime"]["SettingsManager"].set_setting(
|
||||
"speech", "voice", voices[0]
|
||||
)
|
||||
|
||||
# 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
|
||||
try:
|
||||
current_index = (voices.index(current_voice)
|
||||
if current_voice else 0)
|
||||
except ValueError:
|
||||
current_index = 0
|
||||
|
||||
# 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]
|
||||
|
||||
# Update setting (runtime only)
|
||||
self.env["runtime"]["SettingsManager"].set_setting(
|
||||
"speech", "voice", new_voice
|
||||
)
|
||||
|
||||
# Announce new voice
|
||||
self.env["runtime"]["OutputManager"].present_text(
|
||||
new_voice, 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):
|
||||
if len(self.quickMenu) == 0:
|
||||
return ""
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
# Fenrir TTY screen reader
|
||||
# By Chrys, Storm Dragon, and contributors.
|
||||
|
||||
version = "2025.11.27"
|
||||
version = "2025.12.02"
|
||||
code_name = "master"
|
||||
|
||||
Reference in New Issue
Block a user