Add ability to switch speech-dispatcher module and voice to the speeach keys.

This commit is contained in:
Storm Dragon
2025-12-02 16:11:47 -05:00
parent 7f7faa17d3
commit 1650eec768
3 changed files with 332 additions and 10 deletions

View File

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

View File

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

View File

@@ -4,5 +4,5 @@
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
version = "2025.11.27"
version = "2025.12.02"
code_name = "testing"