2 new features, silence speech until prompt returns and progress bar beeps.

This commit is contained in:
Storm Dragon 2025-06-07 00:52:13 -04:00
parent 0930a86ce7
commit 97e2da614b
9 changed files with 430 additions and 26 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 <duration> tri <frequency> 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

View File

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

View File

@ -4,5 +4,5 @@
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributers.
version = "2025.06.06"
version = "2025.06.07"
codeName = "testing"