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

View File

@@ -1,7 +1,6 @@
# Fenrir
A modern, modular, flexible and fast console screen reader.
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.
A modern, modular, flexible and fast console screen reader for Linux.
This software is licensed under the LGPL v3.
**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
## OS Requirements
## Platform Support
- Linux (ptyDriver, vcsaDriver, evdevDriver) - Primary platform with full support
- macOS (ptyDriver) - Limited support
- BSD (ptyDriver) - Limited support
- Windows (ptyDriver) - Limited support
Fenrir is a Linux screen reader. Linux is the only officially supported platform.
**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.
- 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

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_BACKSLASH=toggle_output
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_CTRL,KEY_T=toggle_auto_time
KEY_FENRIR,KEY_KPASTERISK=toggle_highlight_tracking

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_CTRL,KEY_SHIFT,KEY_ENTER=toggle_output
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_Y=toggle_highlight_tracking
#=toggle_barrier

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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):

View File

@@ -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):

View File

@@ -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"
):

View File

@@ -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

View File

@@ -305,12 +305,12 @@ class command:
self.env["commandBuffer"]["lastProgressTime"] = current_time
return
# Pattern 6: Claude Code working indicators (various symbols + activity text + "esc to interrupt")
# Matches any: [symbol] [Task description]… (... esc to interrupt ...)
# Pattern 6: Claude Code working indicators (various symbols + activity text + "esc/ctrl+c to interrupt")
# Matches any: [symbol] [Task description]… (... to interrupt ...)
# 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(
r'[*✢✽✶✻·•◦○●◆]\s+\w+.*?…\s*\(.*esc to interrupt.*\)',
r'[*✢✽✶✻·•◦○●◆]\s+\w+.*?…\s*\(.*(?:esc|ctrl\+c) to interrupt.*\)',
text,
re.IGNORECASE,
)

View File

@@ -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

View File

@@ -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

View File

@@ -65,7 +65,7 @@ class OutputManager:
return
if (len(text) > 1) and (text.strip(string.whitespace) == ""):
return
is_capital = announce_capital and text[0].isupper()
is_capital = self._should_announce_capital(text, announce_capital)
use_pitch_for_capital = False
if is_capital:
@@ -92,6 +92,17 @@ class OutputManager:
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):
return self.last_echo

View File

@@ -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,

View File

@@ -4,5 +4,5 @@
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
version = "2025.12.30"
version = "2026.01.28"
code_name = "master"