From 6d9260e79b51252ba83e15a285f8c1f7429b9649 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sat, 25 Jul 2026 04:36:39 -0400 Subject: [PATCH] Fixed 2 speech-dispatcher bugs. First, multiword voice names were not available. Next, when the module changed the old voice name was carried in causing some modules to fail. --- .../commands/commands/voice_browser.py | 23 +---- .../commands/commands/voice_browser_safe.py | 26 +----- .../onScreenUpdate/65000-progress_detector.py | 52 +++++++++-- .../core/dynamicVoiceMenu.py | 27 +----- .../core/quickMenuManager.py | 48 ++++------ src/fenrirscreenreader/fenrirVersion.py | 2 +- src/fenrirscreenreader/utils/speechd_utils.py | 36 ++++++++ tests/unit/test_progress_detector.py | 52 +++++++++++ tests/unit/test_speechd_voice_selection.py | 90 +++++++++++++++++++ 9 files changed, 251 insertions(+), 105 deletions(-) create mode 100644 src/fenrirscreenreader/utils/speechd_utils.py create mode 100644 tests/unit/test_speechd_voice_selection.py diff --git a/src/fenrirscreenreader/commands/commands/voice_browser.py b/src/fenrirscreenreader/commands/commands/voice_browser.py index b12ed037..7d201e75 100644 --- a/src/fenrirscreenreader/commands/commands/voice_browser.py +++ b/src/fenrirscreenreader/commands/commands/voice_browser.py @@ -4,6 +4,7 @@ import subprocess import time from fenrirscreenreader.core.i18n import _ +from fenrirscreenreader.utils.speechd_utils import get_synthesis_voice_name class command: @@ -273,29 +274,13 @@ class command: 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: - voices.append(line.strip()) + voice = get_synthesis_voice_name(module, line) + if voice: + voices.append(voice) return voices except Exception: pass return [] - def process_espeak_voice(self, voiceLine): - """Process espeak voice format""" - parts = [p for p in voiceLine.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 set_callback(self, callback): pass diff --git a/src/fenrirscreenreader/commands/commands/voice_browser_safe.py b/src/fenrirscreenreader/commands/commands/voice_browser_safe.py index 934cf628..0e76e8e8 100644 --- a/src/fenrirscreenreader/commands/commands/voice_browser_safe.py +++ b/src/fenrirscreenreader/commands/commands/voice_browser_safe.py @@ -5,6 +5,7 @@ import threading import time from fenrirscreenreader.core.i18n import _ +from fenrirscreenreader.utils.speechd_utils import get_synthesis_voice_name class command: @@ -179,12 +180,9 @@ class command: 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: - voices.append(line.strip()) + voice = get_synthesis_voice_name(module, line) + if voice: + voices.append(voice) # Limit voice count to prevent memory issues if len(voices) > 1000: @@ -205,21 +203,5 @@ class command: ) return [] - def process_espeak_voice(self, voiceLine): - """Process espeak voice format""" - try: - parts = [p for p in voiceLine.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 - ) - except Exception: - return None - def set_callback(self, callback): pass diff --git a/src/fenrirscreenreader/commands/onScreenUpdate/65000-progress_detector.py b/src/fenrirscreenreader/commands/onScreenUpdate/65000-progress_detector.py index 10b7c350..bf23e908 100644 --- a/src/fenrirscreenreader/commands/onScreenUpdate/65000-progress_detector.py +++ b/src/fenrirscreenreader/commands/onScreenUpdate/65000-progress_detector.py @@ -79,10 +79,11 @@ class command: # updates delta_text = self.env["screen"]["new_delta"] delta_length = len(delta_text) + explicit_progress_lines = self.get_explicit_progress_lines(delta_text) if ( delta_length > 200 ): # Allow longer progress lines such as terminal status output - if not self.is_explicit_progress_delta(delta_text): + if not explicit_progress_lines: self.env["runtime"]["DebugManager"].write_debug_out( f"Progress filter: delta too long ({delta_length})", debug.DebugLevel.INFO, @@ -91,7 +92,11 @@ class command: # If delta contains newlines and is substantial, let incoming handler # deal with it to avoid interfering with multi-line text output - if '\n' in delta_text and delta_length > 50: + if ( + "\n" in delta_text + and delta_length > 50 + and not explicit_progress_lines + ): self.env["runtime"]["DebugManager"].write_debug_out( f"Progress filter: multiline delta ({delta_length} chars)", debug.DebugLevel.INFO, @@ -119,13 +124,35 @@ class command: if not has_percentage: return self.has_progress_status_fields(text) - return bool( - re.search( - r"[|\[\]#=*>█▉▊▋▌▍▎▏▒▓░]" - r"|\b\d+(?:\.\d+)?\s*[kKmMgGtT](?:i?B)?/s\b", - text, - ) + bracketed_bar = re.search( + r"\[(?=[#=*>█▉▊▋▌▍▎▏▒▓░.\s-]{2,}\])" + r"(?=[^\]]*[#=*>█▉▊▋▌▍▎▏▒▓░])" + r"[#=*>█▉▊▋▌▍▎▏▒▓░.\s-]+\]", + text, ) + pipe_bar = re.search( + r"\|(?=[#=*>█▉▊▋▌▍▎▏▒▓░.\s-]{2,}\|)" + r"(?=[^|]*[#=*>█▉▊▋▌▍▎▏▒▓░])" + r"[#=*>█▉▊▋▌▍▎▏▒▓░.\s-]+\|", + text, + ) + transfer_rate = re.search( + r"\b\d+(?:\.\d+)?\s*[kKmMgGtT](?:i?B)?/s\b", + text, + ) + return bool( + bracketed_bar + or pipe_bar + or transfer_rate + ) + + def get_explicit_progress_lines(self, text): + """Return structurally explicit progress rows from a screen delta.""" + return [ + line + for line in text.splitlines() + if self.is_explicit_progress_delta(line) + ] def reset_progress_state(self): """Reset progress state when a prompt is detected, allowing new progress operations to start fresh""" @@ -140,6 +167,15 @@ class command: import re import time + if "\n" in text: + explicit_progress_lines = self.get_explicit_progress_lines(text) + if not explicit_progress_lines: + return + # Parallel transfer tools commonly repaint several package rows, + # followed by an aggregate row. One tone per update is enough, and + # the final explicit row is normally that aggregate. + text = explicit_progress_lines[-1] + current_time = time.time() # Debug: Print what we're checking diff --git a/src/fenrirscreenreader/core/dynamicVoiceMenu.py b/src/fenrirscreenreader/core/dynamicVoiceMenu.py index c9b0118b..3d91d994 100644 --- a/src/fenrirscreenreader/core/dynamicVoiceMenu.py +++ b/src/fenrirscreenreader/core/dynamicVoiceMenu.py @@ -6,6 +6,7 @@ import subprocess import time from fenrirscreenreader.core import debug +from fenrirscreenreader.utils.speechd_utils import get_synthesis_voice_name class DynamicVoiceCommand: @@ -354,30 +355,10 @@ def get_module_voices(module): for line in lines[1:]: if not line.strip(): continue - if module.lower() == "espeak-ng": - voice = process_espeak_voice(line) - if voice: - voices.append(voice) - else: - voices.append(line.strip()) + voice = get_synthesis_voice_name(module, line) + if voice: + voices.append(voice) return voices except Exception: pass return [] - - -def process_espeak_voice(voiceLine): - """Process espeak voice format""" - try: - parts = [p for p in voiceLine.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 - ) - except Exception: - return None diff --git a/src/fenrirscreenreader/core/quickMenuManager.py b/src/fenrirscreenreader/core/quickMenuManager.py index a859d41b..b4f6eaed 100644 --- a/src/fenrirscreenreader/core/quickMenuManager.py +++ b/src/fenrirscreenreader/core/quickMenuManager.py @@ -4,11 +4,16 @@ # Fenrir TTY screen reader # By Chrys, Storm Dragon, and contributors. +import subprocess +import time + from fenrirscreenreader.core import debug from fenrirscreenreader.core.i18n import _ from fenrirscreenreader.core.settingsData import settings_data -import subprocess -import time +from fenrirscreenreader.utils.speechd_utils import ( + get_synthesis_voice_name, + parse_synthesis_voice_line, +) class SpeechHelperMixin: @@ -89,20 +94,15 @@ class SpeechHelperMixin: 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": + if 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]) + voice = get_synthesis_voice_name(module, line) + if voice: + voices.append(voice) self._voices_cache[module] = voices return voices @@ -115,23 +115,6 @@ class SpeechHelperMixin: 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. @@ -142,11 +125,12 @@ class SpeechHelperMixin: 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: + voice_data = parse_synthesis_voice_line(voice_line) + if voice_data is None: + return None + voice_name, language, _variant = voice_data + if not language: return None - voice_name = parts[0] - language = parts[1] # Encode language with voice for later extraction return f"{voice_name}|{language}" diff --git a/src/fenrirscreenreader/fenrirVersion.py b/src/fenrirscreenreader/fenrirVersion.py index 8191581c..e0b63a49 100644 --- a/src/fenrirscreenreader/fenrirVersion.py +++ b/src/fenrirscreenreader/fenrirVersion.py @@ -4,5 +4,5 @@ # Fenrir TTY screen reader # By Chrys, Storm Dragon, and contributors. -version = "2026.07.19" +version = "2026.07.25" code_name = "testing" diff --git a/src/fenrirscreenreader/utils/speechd_utils.py b/src/fenrirscreenreader/utils/speechd_utils.py new file mode 100644 index 00000000..712645ff --- /dev/null +++ b/src/fenrirscreenreader/utils/speechd_utils.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# Fenrir TTY screen reader +# By Chrys, Storm Dragon, and contributors. + + +def parse_synthesis_voice_line(voice_line): + """Parse one column-aligned spd-say synthesis voice row.""" + fields = voice_line.rsplit(maxsplit=2) + if len(fields) != 3: + return None + + name, language, variant = (field.strip() for field in fields) + if not name: + return None + return name, language, variant + + +def get_synthesis_voice_name(module, voice_line): + """Return the value Speech Dispatcher expects for voice selection.""" + voice_data = parse_synthesis_voice_line(voice_line) + if voice_data is None: + return None + + name, language, variant = voice_data + if module.lower() != "espeak-ng": + return name + + language = language.lower() + variant = variant.lower() + if not language: + return None + if variant and variant != "none": + return f"{language}+{variant}" + return language diff --git a/tests/unit/test_progress_detector.py b/tests/unit/test_progress_detector.py index 928fb59e..caebad6f 100644 --- a/tests/unit/test_progress_detector.py +++ b/tests/unit/test_progress_detector.py @@ -238,3 +238,55 @@ def test_progress_detector_ignores_generic_key_value_text(): command.play_activity_beep.assert_not_called() command.play_progress_tone.assert_not_called() + + +@pytest.mark.unit +def test_progress_detector_beeps_for_pacman_total_in_multiline_delta(): + progress_module = _load_progress_module() + command = progress_module.command() + total_line = ( + "Total ( 3/749) " + "830.7 MiB 4.70 MiB/s 04:05 " + "[#################################################" + "-----------------------------------------------------------------------] 41%" + ) + sample = ( + "package-one 125.0 MiB 2.10 MiB/s 00:12 " + "[####################--------------------] 50%\n" + + total_line + ) + command.env = { + "commandBuffer": { + "progress_monitoring": True, + "lastProgressValue": -1, + "lastProgressTime": 0, + }, + "runtime": { + "DebugManager": Mock(write_debug_out=Mock()), + "ScreenManager": Mock(is_screen_change=Mock(return_value=False)), + "CursorManager": Mock(is_cursor_vertical_move=Mock(return_value=False)), + }, + "screen": { + "new_delta": sample, + "new_delta_is_typing": False, + "new_content_text": sample, + "old_cursor": {"x": 0, "y": 0}, + "new_cursor": {"x": 0, "y": 0}, + }, + } + command.play_progress_tone = Mock() + + command.run() + + command.play_progress_tone.assert_called_once_with(41.0) + assert command.env["commandBuffer"]["lastProgressValue"] == 41.0 + + +@pytest.mark.unit +def test_explicit_progress_delta_ignores_percentage_in_bracketed_prose(): + progress_module = _load_progress_module() + command = progress_module.command() + + assert not command.is_explicit_progress_delta( + "Release notes [issue #749] are 41% complete" + ) diff --git a/tests/unit/test_speechd_voice_selection.py b/tests/unit/test_speechd_voice_selection.py new file mode 100644 index 00000000..f1e137f4 --- /dev/null +++ b/tests/unit/test_speechd_voice_selection.py @@ -0,0 +1,90 @@ +from types import SimpleNamespace +from unittest.mock import Mock + +from fenrirscreenreader.commands.commands import voice_browser +from fenrirscreenreader.commands.commands import voice_browser_safe +from fenrirscreenreader.core import dynamicVoiceMenu +from fenrirscreenreader.core.quickMenuManager import SpeechHelperMixin +from fenrirscreenreader.utils.speechd_utils import get_synthesis_voice_name + + +VOICE_LIST = ( + " NAME LANGUAGE VARIANT\n" + " Perfect Paul en-US none\n" + " Big Bob en-US none\n" +) + + +def completed_voice_list(): + return SimpleNamespace(returncode=0, stdout=VOICE_LIST) + + +def test_quick_menu_preserves_multiword_synthesis_voice_names(monkeypatch): + monkeypatch.setattr( + "fenrirscreenreader.core.quickMenuManager.subprocess.run", + Mock(return_value=completed_voice_list()), + ) + helper = SpeechHelperMixin() + helper.env = {"runtime": {"DebugManager": Mock()}} + + assert helper.get_module_voices("doubletalk") == [ + "Perfect Paul", + "Big Bob", + ] + + +def test_safe_voice_browser_preserves_multiword_synthesis_voice_names( + monkeypatch, +): + monkeypatch.setattr( + voice_browser_safe.subprocess, + "run", + Mock(return_value=completed_voice_list()), + ) + browser = voice_browser_safe.command() + browser.initialize({"runtime": {}}) + + assert browser.get_module_voices_with_timeout("doubletalk") == [ + "Perfect Paul", + "Big Bob", + ] + + +def test_interactive_voice_browser_preserves_multiword_synthesis_voice_names( + monkeypatch, +): + monkeypatch.setattr( + voice_browser.subprocess, + "run", + Mock(return_value=completed_voice_list()), + ) + browser = voice_browser.command() + + assert browser.get_module_voices("doubletalk") == [ + "Perfect Paul", + "Big Bob", + ] + + +def test_dynamic_voice_menu_preserves_multiword_synthesis_voice_names( + monkeypatch, +): + monkeypatch.setattr( + dynamicVoiceMenu.subprocess, + "run", + Mock(return_value=completed_voice_list()), + ) + + assert dynamicVoiceMenu.get_module_voices("doubletalk") == [ + "Perfect Paul", + "Big Bob", + ] + + +def test_espeak_voice_selection_keeps_language_and_variant_behavior(): + voice = get_synthesis_voice_name( + "espeak-ng", + "English (America)+female3 en-US female3", + ) + + assert voice == "en-us+female3"