From 97e2da614bdd198fc705caa754d12835d06cf798 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sat, 7 Jun 2025 00:52:13 -0400 Subject: [PATCH] 2 new features, silence speech until prompt returns and progress bar beeps. --- config/keyboard/desktop.conf | 3 +- config/keyboard/nvda-desktop.conf | 3 +- config/settings/settings.conf | 3 + .../commands/commands/last_incoming.py | 23 --- .../commands/commands/progress_bar_monitor.py | 121 +++++++++++++ .../commands/commands/silence_until_prompt.py | 69 +++++++ .../onScreenUpdate/65000-progress_detector.py | 168 ++++++++++++++++++ .../onScreenUpdate/66000-prompt_detector.py | 64 +++++++ src/fenrirscreenreader/fenrirVersion.py | 2 +- 9 files changed, 430 insertions(+), 26 deletions(-) delete mode 100644 src/fenrirscreenreader/commands/commands/last_incoming.py create mode 100644 src/fenrirscreenreader/commands/commands/progress_bar_monitor.py create mode 100644 src/fenrirscreenreader/commands/commands/silence_until_prompt.py create mode 100644 src/fenrirscreenreader/commands/onScreenUpdate/65000-progress_detector.py create mode 100644 src/fenrirscreenreader/commands/onScreenUpdate/66000-prompt_detector.py diff --git a/config/keyboard/desktop.conf b/config/keyboard/desktop.conf index 561c4754..29b3eb02 100644 --- a/config/keyboard/desktop.conf +++ b/config/keyboard/desktop.conf @@ -73,7 +73,8 @@ KEY_FENRIR,KEY_SHIFT,KEY_0=set_bookmark_10 KEY_FENRIR,KEY_0=bookmark_10 KEY_FENRIR,KEY_KPSLASH=set_window_application 2,KEY_FENRIR,KEY_KPSLASH=clear_window_application -KEY_KPPLUS=last_incoming +KEY_KPPLUS=progress_bar_monitor +KEY_FENRIR,KEY_KPPLUS=silence_until_prompt KEY_FENRIR,KEY_F2=toggle_braille KEY_FENRIR,KEY_F3=toggle_sound KEY_FENRIR,KEY_F4=toggle_speech diff --git a/config/keyboard/nvda-desktop.conf b/config/keyboard/nvda-desktop.conf index 6c98c440..8c9ab1d9 100644 --- a/config/keyboard/nvda-desktop.conf +++ b/config/keyboard/nvda-desktop.conf @@ -72,7 +72,8 @@ KEY_FENRIR,KEY_SHIFT,KEY_0=set_bookmark_10 KEY_FENRIR,KEY_0=bookmark_10 KEY_FENRIR,KEY_KPSLASH=set_window_application 2,KEY_FENRIR,KEY_KPSLASH=clear_window_application -KEY_KPPLUS=last_incoming +KEY_KPPLUS=progress_bar_monitor +KEY_FENRIR,KEY_KPPLUS=silence_until_prompt #=toggle_braille KEY_FENRIR,KEY_F3=toggle_sound KEY_FENRIR,KEY_F4=toggle_speech diff --git a/config/settings/settings.conf b/config/settings/settings.conf index d65bdf3a..81439351 100644 --- a/config/settings/settings.conf +++ b/config/settings/settings.conf @@ -28,6 +28,9 @@ genericPlayFileCommand=play -q -v fenrirVolume fenrirSoundFile #the following command is used to generate a frequency beep genericFrequencyCommand=play -q -v fenrirVolume -n -c1 synth fenrirDuration sine fenrirFrequence +# Enable progress bar monitoring with ascending tones by default +progressMonitoring=False + [speech] # Turn speech on or off: enabled=True diff --git a/src/fenrirscreenreader/commands/commands/last_incoming.py b/src/fenrirscreenreader/commands/commands/last_incoming.py deleted file mode 100644 index 75cef645..00000000 --- a/src/fenrirscreenreader/commands/commands/last_incoming.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -# Fenrir TTY screen reader -# By Chrys, Storm Dragon, and contributers. - -from fenrirscreenreader.core import debug - -class command(): - def __init__(self): - pass - def initialize(self, environment): - self.env = environment - def shutdown(self): - pass - def getDescription(self): - return _('Presents the text which was last received') - - def run(self): - self.env['runtime']['outputManager'].presentText(self.env['screen']['newDelta'], interrupt=True) - - def setCallback(self, callback): - pass diff --git a/src/fenrirscreenreader/commands/commands/progress_bar_monitor.py b/src/fenrirscreenreader/commands/commands/progress_bar_monitor.py new file mode 100644 index 00000000..083e4b6e --- /dev/null +++ b/src/fenrirscreenreader/commands/commands/progress_bar_monitor.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# Fenrir TTY screen reader +# By Chrys, Storm Dragon, and contributers. + +from fenrirscreenreader.core import debug +import re +import time +import threading + +class command(): + def __init__(self): + pass + + def initialize(self, environment): + self.env = environment + # Use commandBuffer like other commands + if 'progressMonitoring' not in self.env['commandBuffer']: + # Check if progress monitoring should be enabled by default from settings + try: + defaultEnabled = self.env['runtime']['settingsManager'].getSettingAsBool('sound', 'progressMonitoring') + except: + # If setting doesn't exist, default to False + defaultEnabled = False + self.env['commandBuffer']['progressMonitoring'] = defaultEnabled + self.env['commandBuffer']['lastProgressTime'] = 0 + self.env['commandBuffer']['lastProgressValue'] = -1 + + def shutdown(self): + self.stopProgressMonitoring() + + def getDescription(self): + return _('Toggle progress bar monitoring with ascending tones') + + def run(self): + # Check if commandBuffer exists + if 'progressMonitoring' not in self.env['commandBuffer']: + self.env['commandBuffer']['progressMonitoring'] = False + self.env['commandBuffer']['lastProgressTime'] = 0 + self.env['commandBuffer']['lastProgressValue'] = -1 + + if self.env['commandBuffer']['progressMonitoring']: + self.stopProgressMonitoring() + self.env['runtime']['outputManager'].presentText(_("Progress monitoring disabled"), interrupt=True) + else: + self.startProgressMonitoring() + self.env['runtime']['outputManager'].presentText(_("Progress monitoring enabled"), interrupt=True) + + def startProgressMonitoring(self): + self.env['commandBuffer']['progressMonitoring'] = True + self.env['commandBuffer']['lastProgressTime'] = time.time() + self.env['commandBuffer']['lastProgressValue'] = -1 + # Don't control speech - let user decide with silence_until_prompt + + def stopProgressMonitoring(self): + self.env['commandBuffer']['progressMonitoring'] = False + # Don't control speech - progress monitor is beep-only + + def detectProgress(self, text): + if not self.env['runtime']['progressMonitoring']: + return + + currentTime = time.time() + + # Pattern 1: Percentage (50%, 25.5%, etc.) + percentMatch = re.search(r'(\d+(?:\.\d+)?)\s*%', text) + if percentMatch: + percentage = float(percentMatch.group(1)) + if percentage != self.env['runtime']['lastProgressValue']: + self.playProgressTone(percentage) + self.env['runtime']['lastProgressValue'] = percentage + self.env['runtime']['lastProgressTime'] = currentTime + return + + # Pattern 2: Fraction (15/100, 3 of 10, etc.) + fractionMatch = re.search(r'(\d+)\s*(?:of|/)\s*(\d+)', text) + if fractionMatch: + current = int(fractionMatch.group(1)) + total = int(fractionMatch.group(2)) + if total > 0: + percentage = (current / total) * 100 + if percentage != self.env['runtime']['lastProgressValue']: + self.playProgressTone(percentage) + self.env['runtime']['lastProgressValue'] = percentage + self.env['runtime']['lastProgressTime'] = currentTime + return + + # Pattern 3: Progress bars ([#### ], [====> ], etc.) + barMatch = re.search(r'\[([#=\-\*]+)([^\]]*)\]', text) + if barMatch: + filled = len(barMatch.group(1)) + total = filled + len(barMatch.group(2)) + if total > 0: + percentage = (filled / total) * 100 + if percentage != self.env['runtime']['lastProgressValue']: + self.playProgressTone(percentage) + self.env['runtime']['lastProgressValue'] = percentage + self.env['runtime']['lastProgressTime'] = currentTime + return + + # Pattern 4: Generic activity indicators (Loading..., Working..., etc.) + activityPattern = re.search(r'(loading|processing|working|installing|downloading|compiling|building).*\.{2,}', text, re.IGNORECASE) + if activityPattern: + # Play a steady beep every 2 seconds for ongoing activity + if currentTime - self.env['runtime']['lastProgressTime'] >= 2.0: + self.playActivityBeep() + self.env['runtime']['lastProgressTime'] = currentTime + + def playProgressTone(self, percentage): + # Map 0-100% to 400-1200Hz frequency range + frequency = 400 + (percentage * 8) + frequency = max(400, min(1200, frequency)) # Clamp to safe range + self.env['runtime']['outputManager'].playFrequence(frequency, 0.15, interrupt=False) + + def playActivityBeep(self): + # Single tone for generic activity + self.env['runtime']['outputManager'].playFrequence(800, 0.1, interrupt=False) + + def setCallback(self, callback): + pass \ No newline at end of file diff --git a/src/fenrirscreenreader/commands/commands/silence_until_prompt.py b/src/fenrirscreenreader/commands/commands/silence_until_prompt.py new file mode 100644 index 00000000..ebcf6d56 --- /dev/null +++ b/src/fenrirscreenreader/commands/commands/silence_until_prompt.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# Fenrir TTY screen reader +# By Chrys, Storm Dragon, and contributers. + +from fenrirscreenreader.core import debug +import re + +class command(): + def __init__(self): + pass + + def initialize(self, environment): + self.env = environment + # Use commandBuffer like other commands + if 'silenceUntilPrompt' not in self.env['commandBuffer']: + self.env['commandBuffer']['silenceUntilPrompt'] = False + + def shutdown(self): + pass + + def getDescription(self): + return _('Toggle speech silence until shell prompt returns') + + def run(self): + if self.env['commandBuffer']['silenceUntilPrompt']: + self.disableSilence() + else: + self.enableSilence() + + def enableSilence(self): + self.env['commandBuffer']['silenceUntilPrompt'] = True + self.env['runtime']['outputManager'].presentText(_("Speech silenced until prompt returns"), soundIcon='SpeechOff', interrupt=True) + # Disable speech but don't use the normal temp disable that reactivates on keypress + self.env['runtime']['settingsManager'].setSetting('speech', 'enabled', 'False') + + def disableSilence(self): + self.env['commandBuffer']['silenceUntilPrompt'] = False + # Re-enable speech + self.env['runtime']['settingsManager'].setSetting('speech', 'enabled', 'True') + self.env['runtime']['outputManager'].presentText(_("Speech restored"), soundIcon='SpeechOn', interrupt=True) + + def checkForPrompt(self, text): + """Check if the current line contains a shell prompt pattern""" + if not self.env['commandBuffer']['silenceUntilPrompt']: + return False + + # Look for common shell prompt patterns + # $ prompt (user) + # # prompt (root) + # > prompt (some shells) + # Also check for common prompt prefixes like user@host:path$ + promptPatterns = [ + r'[^$]*\$$', # Ends with $ (user prompt) + r'[^#]*#$', # Ends with # (root prompt) + r'[^>]*>$', # Ends with > (some shells) + r'.*[\w@]+:.*[$#>]\s*$', # user@host:path$ style + ] + + for pattern in promptPatterns: + if re.search(pattern, text.strip()): + self.disableSilence() + return True + + return False + + def setCallback(self, callback): + pass \ No newline at end of file diff --git a/src/fenrirscreenreader/commands/onScreenUpdate/65000-progress_detector.py b/src/fenrirscreenreader/commands/onScreenUpdate/65000-progress_detector.py new file mode 100644 index 00000000..ea32fa36 --- /dev/null +++ b/src/fenrirscreenreader/commands/onScreenUpdate/65000-progress_detector.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# Fenrir TTY screen reader +# By Chrys, Storm Dragon, and contributers. + +from fenrirscreenreader.core import debug + +class command(): + def __init__(self): + pass + + def initialize(self, environment): + self.env = environment + + def shutdown(self): + pass + + def getDescription(self): + return 'Detects progress patterns for progress bar monitoring' + + def run(self): + # Only run if progress monitoring is enabled + try: + if 'progressMonitoring' in self.env['commandBuffer'] and self.env['commandBuffer']['progressMonitoring']: + # Only check new incoming text (newDelta), but filter out screen changes + if self.env['screen']['newDelta'] and self.isRealProgressUpdate(): + self.detectProgress(self.env['screen']['newDelta']) + except Exception as e: + # Silently ignore errors to avoid disrupting normal operation + pass + + def isRealProgressUpdate(self): + """Check if this is a real progress update vs screen change/window switch""" + # If the screen/application changed, it's not a progress update + if self.env['runtime']['screenManager'].isScreenChange(): + return False + + # If there was a large cursor movement, it's likely navigation, not progress + if self.env['runtime']['cursorManager'].isCursorVerticalMove(): + xMove = abs(self.env['screen']['newCursor']['x'] - self.env['screen']['oldCursor']['x']) + yMove = abs(self.env['screen']['newCursor']['y'] - self.env['screen']['oldCursor']['y']) + # Large movements suggest navigation, not progress output + if yMove > 2 or xMove > 20: + return False + + # Check if delta is too large (screen change) vs small incremental updates + deltaLength = len(self.env['screen']['newDelta']) + if deltaLength > 200: # Allow longer progress lines like Claude Code's status + return False + + return True + + def detectProgress(self, text): + import re + import time + + currentTime = time.time() + + # Debug: Print what we're checking + self.env['runtime']['debug'].writeDebugOut("Progress detector checking: '" + text + "'", debug.debugLevel.INFO) + + # Check if progress monitoring should automatically stop + self.checkProgressCompletion(text, currentTime) + + # Pattern 1: Percentage (50%, 25.5%, etc.) + percentMatch = re.search(r'(\d+(?:\.\d+)?)\s*%', text) + if percentMatch: + percentage = float(percentMatch.group(1)) + self.env['runtime']['debug'].writeDebugOut("Found percentage: " + str(percentage), debug.debugLevel.INFO) + if percentage != self.env['commandBuffer']['lastProgressValue']: + self.env['runtime']['debug'].writeDebugOut("Playing tone for: " + str(percentage), debug.debugLevel.INFO) + self.playProgressTone(percentage) + self.env['commandBuffer']['lastProgressValue'] = percentage + self.env['commandBuffer']['lastProgressTime'] = currentTime + return + + # Pattern 1b: Time/token activity (not percentage-based, so use single beep) + timeMatch = re.search(r'(\d+)s\s', text) + tokenMatch = re.search(r'(\d+)\s+tokens', text) + + if timeMatch or tokenMatch: + # For non-percentage progress, use a single activity beep every 2 seconds + if currentTime - self.env['commandBuffer']['lastProgressTime'] >= 2.0: + self.env['runtime']['debug'].writeDebugOut("Playing activity beep for Claude Code progress", debug.debugLevel.INFO) + self.playActivityBeep() + self.env['commandBuffer']['lastProgressTime'] = currentTime + return + + # Pattern 2: Fraction (15/100, 3 of 10, etc.) + fractionMatch = re.search(r'(\d+)\s*(?:of|/)\s*(\d+)', text) + if fractionMatch: + current = int(fractionMatch.group(1)) + total = int(fractionMatch.group(2)) + if total > 0: + percentage = (current / total) * 100 + if percentage != self.env['commandBuffer']['lastProgressValue']: + self.playProgressTone(percentage) + self.env['commandBuffer']['lastProgressValue'] = percentage + self.env['commandBuffer']['lastProgressTime'] = currentTime + return + + # Pattern 3: Progress bars ([#### ], [====> ], etc.) + barMatch = re.search(r'\[([#=\-\*]+)([^\]]*)\]', text) + if barMatch: + filled = len(barMatch.group(1)) + total = filled + len(barMatch.group(2)) + if total > 0: + percentage = (filled / total) * 100 + if percentage != self.env['commandBuffer']['lastProgressValue']: + self.playProgressTone(percentage) + self.env['commandBuffer']['lastProgressValue'] = percentage + self.env['commandBuffer']['lastProgressTime'] = currentTime + return + + # Pattern 4: Generic activity indicators (Loading..., Working..., etc.) + activityPattern = re.search(r'(loading|processing|working|installing|downloading|compiling|building).*\.{2,}', text, re.IGNORECASE) + if activityPattern: + # Play a steady beep every 2 seconds for ongoing activity + if currentTime - self.env['commandBuffer']['lastProgressTime'] >= 2.0: + self.playActivityBeep() + self.env['commandBuffer']['lastProgressTime'] = currentTime + + def playProgressTone(self, percentage): + # Map 0-100% to 400-1200Hz frequency range + frequency = 400 + (percentage * 8) + frequency = max(400, min(1200, frequency)) # Clamp to safe range + + # Use Sox directly for clean quiet tones like: play -qn synth .1 tri 400 gain -8 + self.playQuietTone(frequency, 0.1) + + def playActivityBeep(self): + # Single tone for generic activity + self.playQuietTone(800, 0.08) + + def playQuietTone(self, frequency, duration): + """Play a quiet tone using Sox directly""" + import subprocess + import shlex + + # Build the Sox command: play -qn synth tri gain -8 + command = f"play -qn synth {duration} tri {frequency} gain -8" + + try: + # Only play if sound is enabled + if self.env['runtime']['settingsManager'].getSettingAsBool('sound', 'enabled'): + subprocess.Popen(shlex.split(command), stdin=None, stdout=None, stderr=None, shell=False) + except Exception as e: + self.env['runtime']['debug'].writeDebugOut("Sox tone error: " + str(e), debug.debugLevel.ERROR) + + def checkProgressCompletion(self, text, currentTime): + """Check if progress is complete and should auto-disable monitoring""" + # Progress monitor is now beep-only - user controls speech separately + # Only auto-disable on clear 100% completion for convenience + import re + + if re.search(r'100\s*%', text): + self.env['runtime']['debug'].writeDebugOut("Progress complete: 100%", debug.debugLevel.INFO) + self.stopProgressMonitoring() + return + + def stopProgressMonitoring(self): + """Stop progress monitoring - beep-only, no speech control""" + self.env['commandBuffer']['progressMonitoring'] = False + # Just disable monitoring, don't touch speech settings + + def setCallback(self, callback): + pass \ No newline at end of file diff --git a/src/fenrirscreenreader/commands/onScreenUpdate/66000-prompt_detector.py b/src/fenrirscreenreader/commands/onScreenUpdate/66000-prompt_detector.py new file mode 100644 index 00000000..5ccc083a --- /dev/null +++ b/src/fenrirscreenreader/commands/onScreenUpdate/66000-prompt_detector.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# Fenrir TTY screen reader +# By Chrys, Storm Dragon, and contributers. + +from fenrirscreenreader.core import debug + +class command(): + def __init__(self): + pass + + def initialize(self, environment): + self.env = environment + + def shutdown(self): + pass + + def getDescription(self): + return 'Detects shell prompts for silence until prompt feature' + + def run(self): + # Only run if silence until prompt is active + try: + if 'silenceUntilPrompt' in self.env['commandBuffer'] and self.env['commandBuffer']['silenceUntilPrompt']: + # Check the current line for prompt patterns + if self.env['screen']['newContentText']: + lines = self.env['screen']['newContentText'].split('\n') + if lines and self.env['screen']['newCursor']['y'] < len(lines): + currentLine = lines[self.env['screen']['newCursor']['y']] + self.checkForPrompt(currentLine) + except Exception as e: + # Silently ignore errors to avoid disrupting normal operation + pass + + def checkForPrompt(self, text): + """Check if the current line contains a shell prompt pattern""" + import re + + # Debug: Print what we're checking + self.env['runtime']['debug'].writeDebugOut("Prompt detector checking: '" + text + "'", debug.debugLevel.INFO) + + # Look for common shell prompt patterns + promptPatterns = [ + r'[^$]*\$$', # Ends with $ (user prompt) + r'[^#]*#$', # Ends with # (root prompt) + r'[^>]*>$', # Ends with > (some shells) + r'.*[\w@]+:.*[$#>]\s*$', # user@host:path$ style + ] + + for pattern in promptPatterns: + if re.search(pattern, text.strip()): + self.env['runtime']['debug'].writeDebugOut("Found prompt pattern: " + pattern, debug.debugLevel.INFO) + # Disable silence mode + self.env['commandBuffer']['silenceUntilPrompt'] = False + # Re-enable speech + self.env['runtime']['settingsManager'].setSetting('speech', 'enabled', 'True') + self.env['runtime']['outputManager'].presentText(_("Speech restored"), soundIcon='SpeechOn', interrupt=True) + return True + + return False + + def setCallback(self, callback): + pass \ No newline at end of file diff --git a/src/fenrirscreenreader/fenrirVersion.py b/src/fenrirscreenreader/fenrirVersion.py index 01b1fbc0..333f665a 100644 --- a/src/fenrirscreenreader/fenrirVersion.py +++ b/src/fenrirscreenreader/fenrirVersion.py @@ -4,5 +4,5 @@ # Fenrir TTY screen reader # By Chrys, Storm Dragon, and contributers. -version = "2025.06.06" +version = "2025.06.07" codeName = "testing"