5 Commits

10 changed files with 218 additions and 13 deletions
+19 -2
View File
@@ -75,6 +75,22 @@ auto_read_incoming=True
# Speak individual numbers instead of whole string. # Speak individual numbers instead of whole string.
read_numbers_as_digits = False read_numbers_as_digits = False
# Flood control: batch rapid updates instead of speaking each one
# Number of updates within rapid_update_window to trigger batching
rapid_update_threshold=5
# Time window (seconds) for detecting rapid updates
rapid_update_window=0.3
# How often to speak batched content (seconds)
batch_flush_interval=0.5
# Maximum lines to keep when batching (keeps newest, drops oldest)
max_batch_lines=100
# Only enable flood control if this many new lines appear in the window
flood_line_threshold=500
# genericSpeechCommand is the command that is executed for talking # genericSpeechCommand is the command that is executed for talking
# the following variables are replaced with values # the following variables are replaced with values
# fenrirText = is the text that should be spoken # fenrirText = is the text that should be spoken
@@ -137,9 +153,10 @@ interrupt_on_key_press_filter=
double_tap_timeout=0.2 double_tap_timeout=0.2
[general] [general]
# Debug levels: 0=DEACTIVE, 1=ERROR, 2=WARNING, 3=INFO (most verbose) # Debug levels: 0=NONE, 1=ERROR, 2=WARNING, 3=INFO (most verbose)
# For production use, WARNING (2) provides good balance of useful info without spam # For production use, WARNING (2) provides good balance of useful info without spam
debug_level=2 # The default is 0, no logging.
debug_level=0
# debugMode sets where the debug output should send to: # debugMode sets where the debug output should send to:
# debugMode=File writes to debug_file (Default:/tmp/fenrir-PID.log) # debugMode=File writes to debug_file (Default:/tmp/fenrir-PID.log)
# debugMode=Print just prints on the screen # debugMode=Print just prints on the screen
@@ -71,6 +71,13 @@ class command:
self.env["screen"]["new_cursor"]["y"], self.env["screen"]["new_cursor"]["y"],
self.env["screen"]["new_content_text"], self.env["screen"]["new_content_text"],
) )
# Don't interrupt ongoing auto-read announcements
do_interrupt = True
if self.env["runtime"]["SettingsManager"].get_setting_as_bool(
"speech", "auto_read_incoming"
):
do_interrupt = False
if curr_char.isspace(): if curr_char.isspace():
# Only announce spaces during pure navigation (arrow keys) # Only announce spaces during pure navigation (arrow keys)
# Check if this is really navigation by looking at input history # Check if this is really navigation by looking at input history
@@ -87,14 +94,14 @@ class command:
char_utils.present_char_for_review( char_utils.present_char_for_review(
self.env, self.env,
curr_char, curr_char,
interrupt=True, interrupt=do_interrupt,
announce_capital=True, announce_capital=True,
flush=False, flush=False,
) )
else: else:
self.env["runtime"]["OutputManager"].present_text( self.env["runtime"]["OutputManager"].present_text(
curr_char, curr_char,
interrupt=True, interrupt=do_interrupt,
ignore_punctuation=True, ignore_punctuation=True,
announce_capital=True, announce_capital=True,
flush=False, flush=False,
@@ -153,10 +153,17 @@ class command:
if (len(curr_delta.strip()) != len(curr_delta) and curr_delta.strip() != ""): if (len(curr_delta.strip()) != len(curr_delta) and curr_delta.strip() != ""):
curr_delta = curr_delta.strip() curr_delta = curr_delta.strip()
# Don't interrupt ongoing auto-read announcements
do_interrupt = True
if self.env["runtime"]["SettingsManager"].get_setting_as_bool(
"speech", "auto_read_incoming"
):
do_interrupt = False
# Enhanced announcement with better handling of empty completions # Enhanced announcement with better handling of empty completions
if curr_delta: if curr_delta:
self.env["runtime"]["OutputManager"].present_text( self.env["runtime"]["OutputManager"].present_text(
curr_delta, interrupt=True, announce_capital=True, flush=False curr_delta, interrupt=do_interrupt, announce_capital=True, flush=False
) )
def set_callback(self, callback): def set_callback(self, callback):
@@ -66,8 +66,15 @@ class command:
): ):
return return
# Don't interrupt ongoing auto-read announcements
do_interrupt = True
if self.env["runtime"]["SettingsManager"].get_setting_as_bool(
"speech", "auto_read_incoming"
):
do_interrupt = False
self.env["runtime"]["OutputManager"].present_text( self.env["runtime"]["OutputManager"].present_text(
curr_word, interrupt=True, flush=False curr_word, interrupt=do_interrupt, flush=False
) )
def set_callback(self, callback): def set_callback(self, callback):
@@ -30,8 +30,8 @@ class command:
if self.env["runtime"]["ScreenManager"].is_screen_change(): if self.env["runtime"]["ScreenManager"].is_screen_change():
self.lastIdent = 0 self.lastIdent = 0
return return
# this leads to problems in vim -> status line change -> no # Don't announce cursor movements when auto-read is handling incoming text
# announcement, so we do check the lengh as hack # This prevents interrupting ongoing auto-read announcements
if self.env["runtime"]["ScreenManager"].is_delta(): if self.env["runtime"]["ScreenManager"].is_delta():
return return
@@ -44,16 +44,22 @@ class command:
self.env["screen"]["new_cursor"]["y"], self.env["screen"]["new_cursor"]["y"],
self.env["screen"]["new_content_text"], self.env["screen"]["new_content_text"],
) )
# Don't interrupt ongoing auto-read announcements with cursor movement
do_interrupt = True
if self.env["runtime"]["SettingsManager"].get_setting_as_bool(
"speech", "auto_read_incoming"
):
do_interrupt = False
if curr_line.isspace(): if curr_line.isspace():
self.env["runtime"]["OutputManager"].present_text( self.env["runtime"]["OutputManager"].present_text(
_("blank"), sound_icon="EmptyLine", interrupt=True, flush=False _("blank"), sound_icon="EmptyLine", interrupt=do_interrupt, flush=False
) )
else: else:
# ident # ident
curr_ident = len(curr_line) - len(curr_line.lstrip()) curr_ident = len(curr_line) - len(curr_line.lstrip())
if self.lastIdent == -1: if self.lastIdent == -1:
self.lastIdent = curr_ident self.lastIdent = curr_ident
do_interrupt = True
if self.env["runtime"]["SettingsManager"].get_setting_as_bool( if self.env["runtime"]["SettingsManager"].get_setting_as_bool(
"general", "auto_present_indent" "general", "auto_present_indent"
): ):
@@ -0,0 +1,49 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
import time
class command:
def __init__(self):
pass
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def get_description(self):
return ""
def run(self):
if not self.env["runtime"]["SettingsManager"].get_setting_as_bool(
"speech", "auto_read_incoming"
):
return
if "pendingPromptText" not in self.env["commandBuffer"]:
return
pending_text = self.env["commandBuffer"]["pendingPromptText"]
if not pending_text:
return
pending_time = self.env["commandBuffer"].get("pendingPromptTime", 0)
delay = self.env["runtime"]["SettingsManager"].get_setting_as_float(
"speech", "batch_flush_interval"
)
if time.time() - pending_time < delay:
return
self.env["runtime"]["OutputManager"].present_text(
pending_text, interrupt=False, flush=False
)
self.env["commandBuffer"]["pendingPromptText"] = ""
def set_callback(self, callback):
pass
@@ -158,6 +158,16 @@ class command:
def _restore_speech(self): def _restore_speech(self):
"""Helper method to restore speech when prompt is detected""" """Helper method to restore speech when prompt is detected"""
# If speech is already enabled, just clear flags to avoid unnecessary
# interrupts on prompt return
if self.env["runtime"]["SettingsManager"].get_setting_as_bool(
"speech", "enabled"
):
self.env["commandBuffer"]["silenceUntilPrompt"] = False
if "enableSpeechOnKeypress" in self.env["commandBuffer"]:
self.env["commandBuffer"]["enableSpeechOnKeypress"] = False
return
# Disable silence mode # Disable silence mode
self.env["commandBuffer"]["silenceUntilPrompt"] = False self.env["commandBuffer"]["silenceUntilPrompt"] = False
# Also disable the keypress-based speech restoration since we're # Also disable the keypress-based speech restoration since we're
@@ -10,7 +10,11 @@ from fenrirscreenreader.core.i18n import _
class command: class command:
def __init__(self): def __init__(self):
pass self._update_times = []
self._line_count_times = []
self._batched_text = []
self._last_flush_time = 0
self._in_flood_mode = False
def initialize(self, environment): def initialize(self, environment):
self.env = environment self.env = environment
@@ -21,6 +25,73 @@ class command:
def get_description(self): def get_description(self):
return _("Announces incoming text changes") return _("Announces incoming text changes")
def _reset_flood_state(self):
self._update_times = []
self._line_count_times = []
self._batched_text = []
self._last_flush_time = 0
self._in_flood_mode = False
def _is_rapid_updates(self):
current_time = time.time()
window = self.env["runtime"]["SettingsManager"].get_setting_as_float(
"speech", "rapid_update_window"
)
threshold = self.env["runtime"]["SettingsManager"].get_setting_as_int(
"speech", "rapid_update_threshold"
)
self._update_times = [
ts for ts in self._update_times if current_time - ts < window
]
self._update_times.append(current_time)
return len(self._update_times) >= threshold
def _is_high_volume(self, delta_text):
current_time = time.time()
window = self.env["runtime"]["SettingsManager"].get_setting_as_float(
"speech", "rapid_update_window"
)
threshold = self.env["runtime"]["SettingsManager"].get_setting_as_int(
"speech", "flood_line_threshold"
)
line_count = max(1, delta_text.count("\n") + 1)
self._line_count_times = [
(ts, count)
for ts, count in self._line_count_times
if current_time - ts < window
]
self._line_count_times.append((current_time, line_count))
total_lines = sum(count for _, count in self._line_count_times)
return total_lines >= threshold
def _add_to_batch(self, text):
new_lines = text.splitlines()
if text.endswith("\n"):
new_lines.append("")
self._batched_text.extend(new_lines)
max_lines = self.env["runtime"]["SettingsManager"].get_setting_as_int(
"speech", "max_batch_lines"
)
if len(self._batched_text) > max_lines:
self._batched_text = self._batched_text[-max_lines:]
def _flush_batch(self):
if not self._batched_text:
return
text = "\n".join(self._batched_text)
self._batched_text = []
self._last_flush_time = time.time()
self.env["runtime"]["OutputManager"].present_text(
text, interrupt=False, flush=False
)
def _was_handled_by_tab_completion(self, delta_text): def _was_handled_by_tab_completion(self, delta_text):
"""Check if this delta was already handled by tab completion to avoid duplicates""" """Check if this delta was already handled by tab completion to avoid duplicates"""
if "tabCompletion" not in self.env["commandBuffer"]: if "tabCompletion" not in self.env["commandBuffer"]:
@@ -51,6 +122,9 @@ class command:
delta_text = self.env["screen"]["new_delta"] delta_text = self.env["screen"]["new_delta"]
if self.env["runtime"]["ScreenManager"].is_screen_change():
self._reset_flood_state()
# Skip if tab completion already handled this delta # Skip if tab completion already handled this delta
if self._was_handled_by_tab_completion(delta_text): if self._was_handled_by_tab_completion(delta_text):
return return
@@ -71,6 +145,29 @@ class command:
# <= 2: # <= 2:
if "\n" not in delta_text: if "\n" not in delta_text:
return return
rapid = self._is_rapid_updates()
high_volume = self._is_high_volume(delta_text)
if (rapid and high_volume) or self._in_flood_mode:
if not self._in_flood_mode:
self._last_flush_time = time.time()
self._in_flood_mode = True
self._add_to_batch(delta_text)
interval = self.env["runtime"][
"SettingsManager"
].get_setting_as_float("speech", "batch_flush_interval")
if time.time() - self._last_flush_time >= interval:
self._flush_batch()
if not rapid or not high_volume:
if self._batched_text:
self._flush_batch()
self._in_flood_mode = False
return
# print(x_move, y_move, len(self.env['screen']['new_delta']), len(self.env['screen']['newNegativeDelta'])) # print(x_move, y_move, len(self.env['screen']['new_delta']), len(self.env['screen']['newNegativeDelta']))
self.env["runtime"]["OutputManager"].present_text( self.env["runtime"]["OutputManager"].present_text(
delta_text, interrupt=False, flush=False delta_text, interrupt=False, flush=False
@@ -30,6 +30,11 @@ settings_data = {
"language": "", "language": "",
"auto_read_incoming": True, "auto_read_incoming": True,
"read_numbers_as_digits": False, "read_numbers_as_digits": False,
"rapid_update_threshold": 5,
"rapid_update_window": 0.3,
"batch_flush_interval": 0.5,
"max_batch_lines": 100,
"flood_line_threshold": 500,
"generic_speech_command": 'espeak -a fenrirVolume -s fenrirRate -p fenrirPitch -v fenrirVoice "fenrirText"', "generic_speech_command": 'espeak -a fenrirVolume -s fenrirRate -p fenrirPitch -v fenrirVoice "fenrirText"',
"fenrir_min_volume": 0, "fenrir_min_volume": 0,
"fenrir_max_volume": 200, "fenrir_max_volume": 200,
+1 -1
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 = "2025.12.30" version = "2026.01.08"
code_name = "master" code_name = "master"