diff --git a/config/settings/settings.conf b/config/settings/settings.conf index d66d0934..6ff31dc4 100644 --- a/config/settings/settings.conf +++ b/config/settings/settings.conf @@ -75,6 +75,22 @@ auto_read_incoming=True # Speak individual numbers instead of whole string. 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 # the following variables are replaced with values # fenrirText = is the text that should be spoken @@ -137,9 +153,10 @@ interrupt_on_key_press_filter= double_tap_timeout=0.2 [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 -debug_level=2 +# The default is 0, no logging. +debug_level=0 # debugMode sets where the debug output should send to: # debugMode=File writes to debug_file (Default:/tmp/fenrir-PID.log) # debugMode=Print just prints on the screen diff --git a/src/fenrirscreenreader/commands/onCursorChange/50000-present_char_if_cursor_change_horizontal.py b/src/fenrirscreenreader/commands/onCursorChange/50000-present_char_if_cursor_change_horizontal.py index a1969ca0..061753b3 100644 --- a/src/fenrirscreenreader/commands/onCursorChange/50000-present_char_if_cursor_change_horizontal.py +++ b/src/fenrirscreenreader/commands/onCursorChange/50000-present_char_if_cursor_change_horizontal.py @@ -71,6 +71,13 @@ class command: self.env["screen"]["new_cursor"]["y"], 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(): # Only announce spaces during pure navigation (arrow keys) # Check if this is really navigation by looking at input history @@ -87,14 +94,14 @@ class command: char_utils.present_char_for_review( self.env, curr_char, - interrupt=True, + interrupt=do_interrupt, announce_capital=True, flush=False, ) else: self.env["runtime"]["OutputManager"].present_text( curr_char, - interrupt=True, + interrupt=do_interrupt, ignore_punctuation=True, announce_capital=True, flush=False, diff --git a/src/fenrirscreenreader/commands/onCursorChange/55000-tab_completion.py b/src/fenrirscreenreader/commands/onCursorChange/55000-tab_completion.py index fa2e035e..40a197ba 100644 --- a/src/fenrirscreenreader/commands/onCursorChange/55000-tab_completion.py +++ b/src/fenrirscreenreader/commands/onCursorChange/55000-tab_completion.py @@ -152,11 +152,18 @@ class command: curr_delta = delta_text if (len(curr_delta.strip()) != len(curr_delta) and 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 if curr_delta: 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): diff --git a/src/fenrirscreenreader/commands/onCursorChange/60000-word_echo_navigation.py b/src/fenrirscreenreader/commands/onCursorChange/60000-word_echo_navigation.py index 32c8d4ec..d2de92c3 100644 --- a/src/fenrirscreenreader/commands/onCursorChange/60000-word_echo_navigation.py +++ b/src/fenrirscreenreader/commands/onCursorChange/60000-word_echo_navigation.py @@ -66,8 +66,15 @@ class command: ): 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( - curr_word, interrupt=True, flush=False + curr_word, interrupt=do_interrupt, flush=False ) def set_callback(self, callback): diff --git a/src/fenrirscreenreader/commands/onCursorChange/65000-present_line_if_cursor_change_vertical.py b/src/fenrirscreenreader/commands/onCursorChange/65000-present_line_if_cursor_change_vertical.py index 0f94793a..0ec5fd66 100644 --- a/src/fenrirscreenreader/commands/onCursorChange/65000-present_line_if_cursor_change_vertical.py +++ b/src/fenrirscreenreader/commands/onCursorChange/65000-present_line_if_cursor_change_vertical.py @@ -30,8 +30,8 @@ class command: if self.env["runtime"]["ScreenManager"].is_screen_change(): self.lastIdent = 0 return - # this leads to problems in vim -> status line change -> no - # announcement, so we do check the lengh as hack + # Don't announce cursor movements when auto-read is handling incoming text + # This prevents interrupting ongoing auto-read announcements if self.env["runtime"]["ScreenManager"].is_delta(): return @@ -44,16 +44,22 @@ class command: self.env["screen"]["new_cursor"]["y"], 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(): self.env["runtime"]["OutputManager"].present_text( - _("blank"), sound_icon="EmptyLine", interrupt=True, flush=False + _("blank"), sound_icon="EmptyLine", interrupt=do_interrupt, flush=False ) else: # ident curr_ident = len(curr_line) - len(curr_line.lstrip()) if self.lastIdent == -1: self.lastIdent = curr_ident - do_interrupt = True if self.env["runtime"]["SettingsManager"].get_setting_as_bool( "general", "auto_present_indent" ): diff --git a/src/fenrirscreenreader/commands/onHeartBeat/65000-prompt_flush.py b/src/fenrirscreenreader/commands/onHeartBeat/65000-prompt_flush.py new file mode 100644 index 00000000..d9ecdcd1 --- /dev/null +++ b/src/fenrirscreenreader/commands/onHeartBeat/65000-prompt_flush.py @@ -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 diff --git a/src/fenrirscreenreader/commands/onScreenUpdate/66000-prompt_detector.py b/src/fenrirscreenreader/commands/onScreenUpdate/66000-prompt_detector.py index 080b9f34..eee8dafa 100644 --- a/src/fenrirscreenreader/commands/onScreenUpdate/66000-prompt_detector.py +++ b/src/fenrirscreenreader/commands/onScreenUpdate/66000-prompt_detector.py @@ -158,6 +158,16 @@ class command: def _restore_speech(self): """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 self.env["commandBuffer"]["silenceUntilPrompt"] = False # Also disable the keypress-based speech restoration since we're diff --git a/src/fenrirscreenreader/commands/onScreenUpdate/70000-incoming.py b/src/fenrirscreenreader/commands/onScreenUpdate/70000-incoming.py index 96dcf562..1e9e0ea3 100644 --- a/src/fenrirscreenreader/commands/onScreenUpdate/70000-incoming.py +++ b/src/fenrirscreenreader/commands/onScreenUpdate/70000-incoming.py @@ -10,7 +10,11 @@ from fenrirscreenreader.core.i18n import _ class command: 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): self.env = environment @@ -21,6 +25,73 @@ class command: def get_description(self): 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): """Check if this delta was already handled by tab completion to avoid duplicates""" if "tabCompletion" not in self.env["commandBuffer"]: @@ -50,6 +121,9 @@ class command: return 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 if self._was_handled_by_tab_completion(delta_text): @@ -71,6 +145,29 @@ class command: # <= 2: if "\n" not in delta_text: 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'])) self.env["runtime"]["OutputManager"].present_text( delta_text, interrupt=False, flush=False diff --git a/src/fenrirscreenreader/core/settingsData.py b/src/fenrirscreenreader/core/settingsData.py index f0d4b85c..e87503eb 100644 --- a/src/fenrirscreenreader/core/settingsData.py +++ b/src/fenrirscreenreader/core/settingsData.py @@ -30,6 +30,11 @@ settings_data = { "language": "", "auto_read_incoming": True, "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"', "fenrir_min_volume": 0, "fenrir_max_volume": 200, diff --git a/src/fenrirscreenreader/fenrirVersion.py b/src/fenrirscreenreader/fenrirVersion.py index 2b0249f6..230b87a8 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 = "2025.12.30" +version = "2026.01.08" code_name = "master"