14 Commits

Author SHA1 Message Date
Storm Dragon 2e10c1c43b Version bump for new release. 2026-01-28 16:41:44 -05:00
Storm Dragon 1e67876883 RC1 for next release. 2026-01-11 23:19:12 -05:00
Storm Dragon 14cf6b6088 Clarify other OS than Linux support. 2026-01-11 23:15:28 -05:00
Storm Dragon 900a027643 Merge branch 'testing' 2026-01-10 23:12:13 -05:00
Storm Dragon 0e50175463 Removed sound for echo switching. 2026-01-10 23:11:53 -05:00
Storm Dragon 7283f04778 Merge branch 'testing' 2026-01-10 23:03:50 -05:00
Storm Dragon 8d3495f74f Character echo settings toggle key added. Keyboard files updated. 2026-01-10 23:03:22 -05:00
Storm Dragon a6cd47dafc latest code. 2026-01-10 21:55:25 -05:00
Storm Dragon 0bb2e52deb Fixed fluttery caps speech shifts. 2026-01-10 20:49:22 -05:00
Storm Dragon b8eb815a86 Merged latest from testing. 2026-01-08 16:16:20 -05:00
Storm Dragon beca468338 Finally! Fixed bug that was causing interruption when prompt comes back. 2026-01-08 12:37:55 -05:00
Storm Dragon a26fe26c8c Log level now set to 0 by default so there's no longer a ton of log files created that aren't normally needed. 2026-01-05 08:32:07 -05:00
Storm Dragon 508fd11610 Redesigned the flood protection for incoming text, should hopefully be much better. 2026-01-04 00:33:06 -05:00
Storm Dragon afe0e71a1d A tiny bug fix in prompt checker. 2026-01-04 00:05:52 -05:00
16 changed files with 303 additions and 25 deletions
+10 -7
View File
@@ -1,7 +1,6 @@
# Fenrir # Fenrir
A modern, modular, flexible and fast console screen reader. A modern, modular, flexible and fast console screen reader for Linux.
It should run on any operating system. If you want to help, or write drivers to make it work on other systems, just let me know.
This software is licensed under the LGPL v3. This software is licensed under the LGPL v3.
**Current maintainer:** Storm Dragon **Current maintainer:** Storm Dragon
@@ -24,12 +23,16 @@ This software is licensed under the LGPL v3.
- **Tutorial Mode**: Built-in help system for learning keyboard shortcuts - **Tutorial Mode**: Built-in help system for learning keyboard shortcuts
## OS Requirements ## Platform Support
- Linux (ptyDriver, vcsaDriver, evdevDriver) - Primary platform with full support Fenrir is a Linux screen reader. Linux is the only officially supported platform.
- macOS (ptyDriver) - Limited support
- BSD (ptyDriver) - Limited support **Other platforms (macOS, BSD, Windows):** Pull requests adding support for other operating systems may be accepted provided they do not break Linux functionality. However, no special care will be taken to preserve functionality on secondary platforms. If changes to Fenrir break support on a non-Linux OS, it is the responsibility of third-party contributors to submit fixes.
- Windows (ptyDriver) - Limited support
- Linux (ptyDriver, vcsaDriver, evdevDriver) - Full support
- macOS (ptyDriver) - Community-maintained, no guarantees
- BSD (ptyDriver) - Community-maintained, no guarantees
- Windows (ptyDriver) - Community-maintained, no guarantees
## Core Requirements ## Core Requirements
+1
View File
@@ -83,6 +83,7 @@ KEY_FENRIR,KEY_CTRL,KEY_P=toggle_punctuation_level
KEY_FENRIR,KEY_RIGHTBRACE=toggle_auto_spell_check KEY_FENRIR,KEY_RIGHTBRACE=toggle_auto_spell_check
KEY_FENRIR,KEY_BACKSLASH=toggle_output KEY_FENRIR,KEY_BACKSLASH=toggle_output
KEY_FENRIR,KEY_CTRL,KEY_E=toggle_emoticons KEY_FENRIR,KEY_CTRL,KEY_E=toggle_emoticons
KEY_FENRIR,KEY_CTRL,KEY_SHIFT,KEY_E=cycle_key_echo
key_FENRIR,KEY_KPENTER=toggle_auto_read key_FENRIR,KEY_KPENTER=toggle_auto_read
KEY_FENRIR,KEY_CTRL,KEY_T=toggle_auto_time KEY_FENRIR,KEY_CTRL,KEY_T=toggle_auto_time
KEY_FENRIR,KEY_KPASTERISK=toggle_highlight_tracking KEY_FENRIR,KEY_KPASTERISK=toggle_highlight_tracking
+1
View File
@@ -81,6 +81,7 @@ KEY_FENRIR,KEY_SHIFT,KEY_CTRL,KEY_P=toggle_punctuation_level
KEY_FENRIR,KEY_RIGHTBRACE=toggle_auto_spell_check KEY_FENRIR,KEY_RIGHTBRACE=toggle_auto_spell_check
KEY_FENRIR,KEY_CTRL,KEY_SHIFT,KEY_ENTER=toggle_output KEY_FENRIR,KEY_CTRL,KEY_SHIFT,KEY_ENTER=toggle_output
KEY_FENRIR,KEY_SHIFT,KEY_E=toggle_emoticons KEY_FENRIR,KEY_SHIFT,KEY_E=toggle_emoticons
KEY_FENRIR,KEY_CTRL,KEY_SHIFT,KEY_E=cycle_key_echo
KEY_FENRIR,KEY_CTRL,KEY_T=toggle_auto_time KEY_FENRIR,KEY_CTRL,KEY_T=toggle_auto_time
KEY_FENRIR,KEY_Y=toggle_highlight_tracking KEY_FENRIR,KEY_Y=toggle_highlight_tracking
#=toggle_barrier #=toggle_barrier
+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
@@ -0,0 +1,57 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
from fenrirscreenreader.core.i18n import _
class command:
def __init__(self):
pass
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def get_description(self):
return _("Cycle through key echo modes: character, word, off")
def run(self):
settings_manager = self.env["runtime"]["SettingsManager"]
output_manager = self.env["runtime"]["OutputManager"]
# Get current settings
char_echo_mode = settings_manager.get_setting("keyboard", "char_echo_mode")
word_echo = settings_manager.get_setting_as_bool("keyboard", "word_echo")
# Determine current state and cycle to next
# States: character (char=1, word=False) -> word (char=0, word=True) -> off (char=0, word=False)
if char_echo_mode == "1" and not word_echo:
# Currently character echo, switch to word echo
settings_manager.set_setting("keyboard", "char_echo_mode", "0")
settings_manager.set_setting("keyboard", "word_echo", "True")
output_manager.present_text(
_("Echo by word"), interrupt=True
)
elif word_echo:
# Currently word echo, switch to off
settings_manager.set_setting("keyboard", "char_echo_mode", "0")
settings_manager.set_setting("keyboard", "word_echo", "False")
output_manager.present_text(
_("Echo off"), interrupt=True
)
else:
# Currently off (or caps mode), switch to character echo
settings_manager.set_setting("keyboard", "char_echo_mode", "1")
settings_manager.set_setting("keyboard", "word_echo", "False")
output_manager.present_text(
_("Echo by character"), interrupt=True
)
def set_callback(self, callback):
pass
@@ -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
@@ -305,12 +305,12 @@ class command:
self.env["commandBuffer"]["lastProgressTime"] = current_time self.env["commandBuffer"]["lastProgressTime"] = current_time
return return
# Pattern 6: Claude Code working indicators (various symbols + activity text + "esc to interrupt") # Pattern 6: Claude Code working indicators (various symbols + activity text + "esc/ctrl+c to interrupt")
# Matches any: [symbol] [Task description]… (... esc to interrupt ...) # Matches any: [symbol] [Task description]… (... to interrupt ...)
# Symbols include: * ✢ ✽ ✶ ✻ · • ◦ ○ ● ◆ and similar decorative characters # Symbols include: * ✢ ✽ ✶ ✻ · • ◦ ○ ● ◆ and similar decorative characters
# Example: ✽ Reviewing script for issues… (esc to interrupt · ctrl+t to show todos · 1m 25s · ↑ 887 tokens) # Example: ✽ Reviewing script for issues… (ctrl+c to interrupt · 33s · ↑ 1.6k tokens · thought for 4s)
claude_progress_match = re.search( claude_progress_match = re.search(
r'[*✢✽✶✻·•◦○●◆]\s+\w+.*?…\s*\(.*esc to interrupt.*\)', r'[*✢✽✶✻·•◦○●◆]\s+\w+.*?…\s*\(.*(?:esc|ctrl\+c) to interrupt.*\)',
text, text,
re.IGNORECASE, re.IGNORECASE,
) )
@@ -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
+12 -1
View File
@@ -65,7 +65,7 @@ class OutputManager:
return return
if (len(text) > 1) and (text.strip(string.whitespace) == ""): if (len(text) > 1) and (text.strip(string.whitespace) == ""):
return return
is_capital = announce_capital and text[0].isupper() is_capital = self._should_announce_capital(text, announce_capital)
use_pitch_for_capital = False use_pitch_for_capital = False
if is_capital: if is_capital:
@@ -92,6 +92,17 @@ class OutputManager:
text, interrupt, ignore_punctuation, use_pitch_for_capital, flush text, interrupt, ignore_punctuation, use_pitch_for_capital, flush
) )
def _should_announce_capital(self, text, announce_capital):
if not announce_capital or not text:
return False
if len(text) == 1:
return text.isupper()
if any(char.isspace() for char in text):
return False
if not any(char.isalpha() for char in text):
return False
return text.isupper()
def get_last_echo(self): def get_last_echo(self):
return self.last_echo return self.last_echo
@@ -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.28"
code_name = "master" code_name = "master"