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.

This commit is contained in:
Storm Dragon
2026-07-25 04:36:39 -04:00
parent 385356e745
commit 6d9260e79b
9 changed files with 251 additions and 105 deletions
@@ -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)
voice = get_synthesis_voice_name(module, line)
if voice:
voices.append(voice)
else:
voices.append(line.strip())
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
@@ -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)
voice = get_synthesis_voice_name(module, line)
if voice:
voices.append(voice)
else:
voices.append(line.strip())
# 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
@@ -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",
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
@@ -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)
voice = get_synthesis_voice_name(module, line)
if voice:
voices.append(voice)
else:
voices.append(line.strip())
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
+16 -32
View File
@@ -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}"
+1 -1
View File
@@ -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"
@@ -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
+52
View File
@@ -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"
)
@@ -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"