1 Commits

Author SHA1 Message Date
Storm Dragon 81f1757357 Release candidate. 2026-07-15 00:20:57 -04:00
11 changed files with 105 additions and 289 deletions
@@ -4,7 +4,6 @@ import subprocess
import time import time
from fenrirscreenreader.core.i18n import _ from fenrirscreenreader.core.i18n import _
from fenrirscreenreader.utils.speechd_utils import get_synthesis_voice_name
class command: class command:
@@ -274,13 +273,29 @@ class command:
for line in lines[1:]: for line in lines[1:]:
if not line.strip(): if not line.strip():
continue continue
voice = get_synthesis_voice_name(module, line) if module.lower() == "espeak-ng":
if voice: voice = self.process_espeak_voice(line)
voices.append(voice) if voice:
voices.append(voice)
else:
voices.append(line.strip())
return voices return voices
except Exception: except Exception:
pass pass
return [] 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): def set_callback(self, callback):
pass pass
@@ -5,7 +5,6 @@ import threading
import time import time
from fenrirscreenreader.core.i18n import _ from fenrirscreenreader.core.i18n import _
from fenrirscreenreader.utils.speechd_utils import get_synthesis_voice_name
class command: class command:
@@ -180,9 +179,12 @@ class command:
for line in lines[1:]: for line in lines[1:]:
if not line.strip(): if not line.strip():
continue continue
voice = get_synthesis_voice_name(module, line) if module.lower() == "espeak-ng":
if voice: voice = self.process_espeak_voice(line)
voices.append(voice) if voice:
voices.append(voice)
else:
voices.append(line.strip())
# Limit voice count to prevent memory issues # Limit voice count to prevent memory issues
if len(voices) > 1000: if len(voices) > 1000:
@@ -203,5 +205,21 @@ class command:
) )
return [] 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): def set_callback(self, callback):
pass pass
@@ -27,8 +27,6 @@ class command:
return return
if not self.env["runtime"]["ScreenManager"].is_delta(): if not self.env["runtime"]["ScreenManager"].is_delta():
return return
if not self.env["screen"].get("new_delta_is_typing", False):
return
x_move = ( x_move = (
self.env["screen"]["new_cursor"]["x"] self.env["screen"]["new_cursor"]["x"]
@@ -79,11 +79,10 @@ class command:
# updates # updates
delta_text = self.env["screen"]["new_delta"] delta_text = self.env["screen"]["new_delta"]
delta_length = len(delta_text) delta_length = len(delta_text)
explicit_progress_lines = self.get_explicit_progress_lines(delta_text)
if ( if (
delta_length > 200 delta_length > 200
): # Allow longer progress lines such as terminal status output ): # Allow longer progress lines such as terminal status output
if not explicit_progress_lines: if not self.is_explicit_progress_delta(delta_text):
self.env["runtime"]["DebugManager"].write_debug_out( self.env["runtime"]["DebugManager"].write_debug_out(
f"Progress filter: delta too long ({delta_length})", f"Progress filter: delta too long ({delta_length})",
debug.DebugLevel.INFO, debug.DebugLevel.INFO,
@@ -92,11 +91,7 @@ class command:
# If delta contains newlines and is substantial, let incoming handler # If delta contains newlines and is substantial, let incoming handler
# deal with it to avoid interfering with multi-line text output # deal with it to avoid interfering with multi-line text output
if ( if '\n' in delta_text and delta_length > 50:
"\n" in delta_text
and delta_length > 50
and not explicit_progress_lines
):
self.env["runtime"]["DebugManager"].write_debug_out( self.env["runtime"]["DebugManager"].write_debug_out(
f"Progress filter: multiline delta ({delta_length} chars)", f"Progress filter: multiline delta ({delta_length} chars)",
debug.DebugLevel.INFO, debug.DebugLevel.INFO,
@@ -124,36 +119,14 @@ class command:
if not has_percentage: if not has_percentage:
return self.has_progress_status_fields(text) return self.has_progress_status_fields(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( return bool(
bracketed_bar re.search(
or pipe_bar r"[|\[\]#=*>█▉▊▋▌▍▎▏▒▓░]"
or transfer_rate r"|\b\d+(?:\.\d+)?\s*[kKmMgGtT](?:i?B)?/s\b",
text,
)
) )
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): def reset_progress_state(self):
"""Reset progress state when a prompt is detected, allowing new progress operations to start fresh""" """Reset progress state when a prompt is detected, allowing new progress operations to start fresh"""
self.env["runtime"]["DebugManager"].write_debug_out( self.env["runtime"]["DebugManager"].write_debug_out(
@@ -167,15 +140,6 @@ class command:
import re import re
import time 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() current_time = time.time()
# Debug: Print what we're checking # Debug: Print what we're checking
@@ -6,7 +6,6 @@ import subprocess
import time import time
from fenrirscreenreader.core import debug from fenrirscreenreader.core import debug
from fenrirscreenreader.utils.speechd_utils import get_synthesis_voice_name
class DynamicVoiceCommand: class DynamicVoiceCommand:
@@ -355,10 +354,30 @@ def get_module_voices(module):
for line in lines[1:]: for line in lines[1:]:
if not line.strip(): if not line.strip():
continue continue
voice = get_synthesis_voice_name(module, line) if module.lower() == "espeak-ng":
if voice: voice = process_espeak_voice(line)
voices.append(voice) if voice:
voices.append(voice)
else:
voices.append(line.strip())
return voices return voices
except Exception: except Exception:
pass pass
return [] 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
+32 -16
View File
@@ -4,16 +4,11 @@
# Fenrir TTY screen reader # Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors. # By Chrys, Storm Dragon, and contributors.
import subprocess
import time
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
from fenrirscreenreader.utils.speechd_utils import ( import subprocess
get_synthesis_voice_name, import time
parse_synthesis_voice_line,
)
class SpeechHelperMixin: class SpeechHelperMixin:
@@ -94,15 +89,20 @@ class SpeechHelperMixin:
for line in lines[1:]: for line in lines[1:]:
if not line.strip(): if not line.strip():
continue continue
if module.lower() == "voxin": 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 # For Voxin, store voice name with language
voice_data = self._process_voxin_voice(line) voice_data = self._process_voxin_voice(line)
if voice_data: if voice_data:
voices.append(voice_data) voices.append(voice_data)
else: else:
voice = get_synthesis_voice_name(module, line) # For other modules, extract first field (voice name)
if voice: parts = line.strip().split()
voices.append(voice) if parts:
voices.append(parts[0])
self._voices_cache[module] = voices self._voices_cache[module] = voices
return voices return voices
@@ -115,6 +115,23 @@ class SpeechHelperMixin:
return [] 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): def _process_voxin_voice(self, voice_line):
"""Process Voxin voice format with language information. """Process Voxin voice format with language information.
@@ -125,12 +142,11 @@ class SpeechHelperMixin:
Returns: Returns:
str: Voice name with language encoded (e.g., 'daniel-embedded-high|en-GB') str: Voice name with language encoded (e.g., 'daniel-embedded-high|en-GB')
""" """
voice_data = parse_synthesis_voice_line(voice_line) parts = [p for p in voice_line.split() if p]
if voice_data is None: if len(parts) < 2:
return None
voice_name, language, _variant = voice_data
if not language:
return None return None
voice_name = parts[0]
language = parts[1]
# Encode language with voice for later extraction # Encode language with voice for later extraction
return f"{voice_name}|{language}" return f"{voice_name}|{language}"
+2 -2
View File
@@ -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 = "2026.07.25" version = "2026.07.15"
code_name = "testing" code_name = "master"
@@ -1,36 +0,0 @@
#!/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
-52
View File
@@ -238,55 +238,3 @@ def test_progress_detector_ignores_generic_key_value_text():
command.play_activity_beep.assert_not_called() command.play_activity_beep.assert_not_called()
command.play_progress_tone.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"
)
@@ -1,90 +0,0 @@
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"
-36
View File
@@ -344,7 +344,6 @@ def test_large_insertion_echo_speaks_pasted_cursor_text():
"old_cursor": {"x": 2, "y": 0}, "old_cursor": {"x": 2, "y": 0},
"new_cursor": {"x": 13, "y": 0}, "new_cursor": {"x": 13, "y": 0},
"new_delta": "hello world", "new_delta": "hello world",
"new_delta_is_typing": True,
}, },
} }
command = large_insertion_module.command() command = large_insertion_module.command()
@@ -360,41 +359,6 @@ def test_large_insertion_echo_speaks_pasted_cursor_text():
) )
@pytest.mark.unit
def test_large_insertion_echo_ignores_incoming_cursor_progress():
large_insertion_module = _load_large_insertion_module()
output_manager = Mock(present_text=Mock())
settings_manager = Mock()
settings_manager.get_setting_as_bool.return_value = False
input_manager = Mock()
input_manager.get_last_deepest_input.return_value = []
input_manager.get_last_event.return_value = None
screen_manager = Mock(
is_screen_change=Mock(return_value=False),
is_delta=Mock(return_value=True),
)
env = {
"runtime": {
"InputManager": input_manager,
"OutputManager": output_manager,
"ScreenManager": screen_manager,
"SettingsManager": settings_manager,
},
"screen": {
"old_cursor": {"x": 0, "y": 0},
"new_cursor": {"x": 12, "y": 0},
"new_delta": "upgrading 1/3",
"new_delta_is_typing": False,
},
}
command = large_insertion_module.command()
command.initialize(env)
command.run()
output_manager.present_text.assert_not_called()
@pytest.mark.unit @pytest.mark.unit
def test_large_insertion_echo_defers_recent_tab_to_tab_completion(): def test_large_insertion_echo_defers_recent_tab_to_tab_completion():
large_insertion_module = _load_large_insertion_module() large_insertion_module = _load_large_insertion_module()