36 Commits
Author SHA1 Message Date
Storm Dragon 9e6e806947 Latest Codex app exposed a bug where redrawing the screen could cause speech to slip through even when it's turned off. Hopefully fixed now. 2026-08-21 18:36:04 -04:00
Storm Dragon 45019091ab I think I fixed the import and export clipboard manual buttons. They work for me for now at least. 2026-08-17 03:25:57 -04:00
Storm Dragon 68462d00cb Fixed crash bug. Wide characters were sometimes causing an index out of range error. Now they are just treated as spaces. 2026-08-17 02:26:04 -04:00
Storm Dragon 93a2745065 Move auxiliary work off the main event loop
Run voice discovery, voice testing, clipboard I/O, and external scripts in isolated background worker lanes. Deliver results through Fenrir's event queue so manager state remains owned by the main loop.
2026-08-14 23:29:15 -04:00
Storm Dragon 4ed431640e Improve tutorial help navigation 2026-08-13 09:29:43 -04:00
Storm Dragon e7f69b2159 Fenrir should no longer fail to report its current speech-dispatcher module. 2026-08-13 08:32:47 -04:00
Storm Dragon a2e4a8a086 Improve review spelling shortcuts 2026-08-13 00:42:13 -04:00
Storm Dragon cca688328b Support modifier-first Fenrir shortcuts 2026-08-12 23:42:01 -04:00
Storm Dragon ee441c319a Improved progress bar detections. 2026-08-06 13:49:53 -04:00
Storm Dragon 7593c8d0b3 Fix to socket routing for self-voicing. 2026-07-30 04:52:52 -04:00
Storm Dragon 122a6a38b3 Fix Unix remote watchdog socket leak 2026-07-29 14:13:53 -04:00
Storm Dragon 76b8f2f2cf Very scary test here. Trying to see if we can boost speed on very large terminals. 2026-07-26 22:01:11 -04:00
Storm Dragon d81565fbf5 Still attempting to kill off this delay. 2026-07-26 21:49:20 -04:00
Storm Dragon 186512f79a Another try for lag. 2026-07-26 21:42:53 -04:00
Storm Dragon 17b6e6f909 Attempt to decrease dectalk lag. Hoping you don't have to send settings over every time you want it to say something. 2026-07-26 21:34:04 -04:00
Storm Dragon fac3065c6a More tweaks to dectalk driver. Think we're nearly there. Hopefully. 2026-07-26 21:24:28 -04:00
Storm Dragon e93aba109a Fixes with the dectalk driver with rate, etc. 2026-07-26 21:10:39 -04:00
Storm Dragon 11a4244cc3 Fixed some errors with my dectalk protocol implementation. Thanks Codex. :) 2026-07-26 20:59:14 -04:00
Storm Dragon 9ac2e9e844 Found and hopefullyfixed a bug with fenrir watchdog. 2026-07-26 20:44:50 -04:00
Storm Dragon c863245c24 More attempts at getting this usb dectalk working. 2026-07-26 20:18:17 -04:00
Storm Dragon 45bd33f756 Improvements to Dectalk driver. 2026-07-26 19:44:10 -04:00
Storm Dragon 6d9260e79b Fixed 2 speech-dispatcher bugs. First, multiword voice names were not available. Next, when the module changed the old voice name was carried in causing some modules to fail. 2026-07-25 04:36:39 -04:00
Storm Dragon 385356e745 Prevent incoming output from bypassing auto-read toggle 2026-07-19 15:59:51 -04:00
Storm Dragon 28e4f8ab0c Progress bar beeps now work on ffmpeg output. 2026-07-12 15:06:44 -04:00
lilmike 4d94b56ee9 Fix very high cpu usage when a console fenrir under root and an x fenrir under your user are running at the same time. 2026-07-01 23:44:12 -07:00
Storm Dragon eb93a8aa62 Fixed a typo in tests/unit/test_hardware_speech_drivers.py. It was reading 9 bytes while comparing against a 10-byte payload. Thanks Samuel Thibault for finding and pointing this out. 2026-06-27 16:59:05 -04:00
Storm Dragon ad863ad706 Add ability for X clibpard and Fenrir's clipboard to stay in sync. 2026-06-27 16:55:00 -04:00
Storm Dragon 447e2cc896 Missed one commented setting. 2026-06-18 10:51:44 -04:00
Storm Dragon 2b7d205f06 Fixed some half-completed work in generic drivers. Minor -x bug fixes. 2026-06-18 10:35:48 -04:00
Storm Dragon fd5fe5b328 More progressbar updates. Removed Claud specific progress bar detection, hopefully caught now by generic progress bar updates. They do change it all the time, so it may work, but shouldn't be expected to do so. 2026-06-01 03:04:47 -04:00
Storm Dragon 4ed3f4d6ab Fixed version. 2026-05-31 22:37:59 -04:00
Storm Dragon 2cb83632f9 Fixed keyboard handling regression. 2026-05-31 22:36:35 -04:00
Storm Dragon 0c4fe50606 Updated log names. Should be easier to find if you forget to delete old logs. Now just called fenrir.log, we don't tend to need to have multiple logs anyway. 2026-05-30 14:16:23 -04:00
Storm Dragon 15f2435749 Hopefully fix some weirdness on tab completeion where it would read the entire screen instead of suggested tab completions. 2026-05-30 13:56:25 -04:00
Storm Dragon 3897b63068 Iproved logging for startup flags. 2026-05-29 20:22:06 -04:00
Storm Dragon f1a8e6af21 Fixed long standing bug where bottom of screen played for both top and bottom, found a couple other things that were off in the process. 2026-05-29 19:50:38 -04:00
105 changed files with 7562 additions and 1847 deletions
+23 -9
View File
@@ -81,13 +81,16 @@ Fenrir is a Linux screen reader. Linux is the only officially supported platform
- espeak or espeak-ng (or any TTS command)
3. **dectalkDriver** - Serial DECtalk-compatible hardware speech driver
- RPITalk gadget mode or a DECtalk-compatible serial device
4. **litetalkDriver** - Serial LiteTalk-compatible hardware speech driver
4. **dectalkUsbDriver** - Native Access Solutions DECtalk USB driver
- USB ID `0dd0:2001`
- PyUSB and a libusb backend
5. **litetalkDriver** - Serial LiteTalk-compatible hardware speech driver
- RPITalk gadget mode or a LiteTalk-compatible serial device
5. **doubletalkDriver** - Serial DoubleTalk LT-compatible hardware speech driver
6. **doubletalkDriver** - Serial DoubleTalk LT-compatible hardware speech driver
- DoubleTalk LT; does not support the internal DoubleTalk PC card
6. **tripletalkDriver** - Serial TripleTalk-compatible hardware speech driver
7. **tripletalkDriver** - Serial TripleTalk-compatible hardware speech driver
- External DB9 serial TripleTalk devices, or USB models that expose a tty serial device
7. **debugDriver** - Debug speech driver for testing
8. **debugDriver** - Debug speech driver for testing
- No dependencies
@@ -459,15 +462,22 @@ setting <action> [parameters]
- `speech#volume=0.1-1.0` - Speech volume
- `speech#voice=voice_name` - Voice selection (e.g., "en-us+f3")
- `speech#module=module_name` - TTS module (e.g., "espeak-ng")
- `speech#driver=driver_name` - Speech driver (speechdDriver/genericDriver/dectalkDriver/litetalkDriver/doubletalkDriver/tripletalkDriver)
- `speech#driver=driver_name` - Speech driver (speechdDriver/genericDriver/dectalkDriver/dectalkUsbDriver/litetalkDriver/doubletalkDriver/tripletalkDriver)
- `speech#hardware_device=/dev/ttyS0` - Hardware synth serial device for dectalkDriver/litetalkDriver
- `speech#hardware_baud_rate=9600` - Hardware synth serial baud rate
- `speech#history_size=50` - Number of spoken items kept in runtime speech history
The native Access Solutions DECtalk USB, USB ID `0dd0:2001`, uses
`dectalkUsbDriver`. It is autodetected and does not use
`speech#hardware_device` or `speech#hardware_baud_rate`. Install PyUSB and a
libusb backend first. From a source checkout, use
`python3 -m pip install '.[dectalk_usb]'`; distributions may package PyUSB as
`python3-usb` or `python-pyusb`. Root-run Fenrir can access the device directly.
A non-root Fenrir process needs a local udev rule granting its account
read/write access to `0dd0:2001`; do not make the device world-writable. Other
USB hardware synths are supported only when Linux exposes them as a serial tty
such as `/dev/ttyACM0` or `/dev/ttyUSB0`. A USB-only TripleTalk with no tty
device would require a separate USB protocol driver. Use an explicit
`speech#hardware_device` path for hardware speech.
such as `/dev/ttyACM0` or `/dev/ttyUSB0`. Use an explicit
`speech#hardware_device` path for serial hardware speech.
- `speech#auto_read_incoming=True/False` - Auto-read new text
*Sound Settings:*
@@ -653,6 +663,10 @@ Building...
- **Non-blocking**: Progress tones don't interrupt speech or other functionality
- **Configurable**: Can be enabled/disabled as needed
Fenrir detects stable progress structures rather than application-specific
status formats. Application-specific formats change too frequently to support
reliably.
### Usage Examples
```bash
@@ -736,7 +750,7 @@ send_fenrir_command("setting set speech#rate=0.9")
**Commands not working:**
- Verify `enable_command_remote=True` in settings
- Check Fenrir debug logs: `/var/log/fenrir.log`
- Check Fenrir debug logs: `/tmp/fenrir.log`
- Test with simple command: `echo "command interrupt" | socat - UNIX-CLIENT:/tmp/fenrirscreenreader-daemon.sock`
## Command Line Options
+5 -5
View File
@@ -3,13 +3,13 @@ https://git.stormux.org/storm/fenrir/issues
For bugs, please provide a debug file that shows the issue.
How to create a debug file:
1. first delete old stuff:
sudo rm /var/log/fenrir.log
2. start fenrir in debug mode
1. start fenrir in debug mode
sudo fenrir -d
<do your stuff>
3.
2.
stop fenrir (fenrirKey + q)
the debug file is in /var/log/fenrir.log
the debug file is in /tmp/fenrir.log
if another Fenrir debug instance is already using it, check /tmp/fenrir2.log,
/tmp/fenrir3.log, etc.
please be as precise as possible to make it easy to solve the problem.
+3 -7
View File
@@ -15,15 +15,12 @@ KEY_FENRIR,KEY_ALT,KEY_2=present_last_line
KEY_KP5=review_curr_word
KEY_KP4=review_prev_word
KEY_KP6=review_next_word
KEY_FENRIR,KEY_SHIFT,KEY_KP5=review_curr_word_phonetic
KEY_FENRIR,KEY_SHIFT,KEY_KP4=review_prev_word_phonetic
KEY_FENRIR,KEY_SHIFT,KEY_KP6=review_next_word_phonetic
2,KEY_KP5=review_curr_word_spell
3,KEY_KP5=review_curr_word_phonetic
KEY_KP2=review_curr_char
KEY_KP1=review_prev_char
KEY_KP3=review_next_char
KEY_FENRIR,KEY_SHIFT,KEY_KP2=review_curr_char_phonetic
KEY_FENRIR,KEY_SHIFT,KEY_KP1=review_prev_char_phonetic
KEY_FENRIR,KEY_SHIFT,KEY_KP3=review_next_char_phonetic
2,KEY_KP2=review_curr_char_phonetic
KEY_FENRIR,KEY_CTRL,KEY_KP8=review_up
KEY_FENRIR,KEY_CTRL,KEY_KP2=review_down
KEY_FENRIR,KEY_KPDOT=exit_review
@@ -133,4 +130,3 @@ KEY_FENRIR,KEY_F8=export_clipboard_to_x
KEY_FENRIR,KEY_CTRL,KEY_DOWN=read_all_by_line
KEY_FENRIR,KEY_CTRL,KEY_PAGEDOWN=read_all_by_page
KEY_FENRIR,KEY_SHIFT,KEY_V=announce_fenrir_version
KEY_FENRIR,KEY_LEFTCTRL,KEY_F4=cycle_keyboard_layout
+3 -7
View File
@@ -15,15 +15,12 @@ KEY_FENRIR,KEY_ALT,KEY_2=present_last_line
KEY_FENRIR,KEY_K=review_curr_word
KEY_FENRIR,KEY_J=review_prev_word
KEY_FENRIR,KEY_L=review_next_word
KEY_FENRIR,KEY_ALT,KEY_K=review_curr_word_phonetic
KEY_FENRIR,KEY_ALT,KEY_J=review_prev_word_phonetic
KEY_FENRIR,KEY_ALT,KEY_L=review_next_word_phonetic
2,KEY_FENRIR,KEY_K=review_curr_word_spell
3,KEY_FENRIR,KEY_K=review_curr_word_phonetic
KEY_FENRIR,KEY_COMMA=review_curr_char
KEY_FENRIR,KEY_M=review_prev_char
KEY_FENRIR,KEY_DOT=review_next_char
KEY_FENRIR,KEY_ALT,KEY_COMMA=curr_char_phonetic
KEY_FENRIR,KEY_ALT,KEY_M=prev_char_phonetic
KEY_FENRIR,KEY_ALT,KEY_DOT=next_char_phonetic
2,KEY_FENRIR,KEY_COMMA=review_curr_char_phonetic
KEY_FENRIR,KEY_CTRL,KEY_I=review_up
KEY_FENRIR,KEY_CTRL,KEY_COMMA=review_down
KEY_FENRIR,KEY_SLASH=exit_review
@@ -133,4 +130,3 @@ KEY_FENRIR,KEY_F8=export_clipboard_to_x
KEY_FENRIR,KEY_CTRL,KEY_DOWN=read_all_by_line
KEY_FENRIR,KEY_CTRL,KEY_PAGEDOWN=read_all_by_page
KEY_FENRIR,KEY_SHIFT,KEY_V=announce_fenrir_version
KEY_FENRIR,KEY_LEFTCTRL,KEY_F4=cycle_keyboard_layout
+34 -21
View File
@@ -19,14 +19,14 @@ volume=0.7
# shell commands for generic sound driver
# the folowing variable are substituted
# fenrirVolume = the current volume setting
# fenrirSoundFile = the soundfile for an soundicon
# fenrirFrequence = the frequency to play
# fenrirDuration = the duration of the frequency
# fenrir_volume = the current volume setting
# fenrir_sound_file = the soundfile for an soundicon
# fenrir_frequency = the frequency to play
# fenrir_duration = the duration of the frequency
# the following command is used to play a soundfile
generic_play_file_command=play -q -v fenrirVolume fenrirSoundFile
generic_play_file_command=play -q -v fenrir_volume fenrir_sound_file
#the following command is used to generate a frequency beep
generic_frequency_command=play -q -v fenrirVolume -n -c1 synth fenrirDuration sine fenrirFrequence
generic_frequency_command=play -q -v fenrir_volume -n -c1 synth fenrir_duration sine fenrir_frequency
# Enable progress bar monitoring with ascending tones by default
progress_monitoring=True
@@ -36,10 +36,12 @@ progress_monitoring=True
enabled=True
# Select speech driver, options are speechdDriver, genericDriver,
# dectalkDriver, litetalkDriver, doubletalkDriver, or tripletalkDriver:
# dectalkDriver, dectalkUsbDriver, litetalkDriver, doubletalkDriver,
# or tripletalkDriver:
driver=speechdDriver
#driver=genericDriver
#driver=dectalkDriver
#driver=dectalkUsbDriver
#driver=litetalkDriver
#driver=doubletalkDriver
#driver=tripletalkDriver
@@ -77,6 +79,7 @@ volume=1.0
# Hardware speech synthesizer serial device.
# Used by dectalkDriver, litetalkDriver, doubletalkDriver, and tripletalkDriver.
# dectalkUsbDriver autodetects the Access Solutions device and ignores this.
# USB serial devices are supported if Linux exposes them as /dev/ttyACM*
# or /dev/ttyUSB*. USB-only synths with no tty device need a separate driver.
# Set an explicit device for hardware speech.
@@ -114,17 +117,17 @@ 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
# generic_speech_command is the command that is executed for talking
# the following variables are replaced with values
# fenrirText = is the text that should be spoken
# fenrirModule = may be the speech module like used in speech-dispatcher, not every TTY need this
# fenrirLanguage = the language
# fenrirVoice = is the current voice that should be used. Set the voice variable above.
# fenrir_text = is the text that should be spoken
# fenrir_module = may be the speech module like used in speech-dispatcher, not every TTY need this
# fenrir_language = the language
# fenrir_voice = is the current voice that should be used. Set the voice variable above.
# the current volume, pitch and rate is calculated like this
# value = min + settingValue * (min - max )
# fenrirVolume = is replaced with the current volume
# fenrirPitch = is replaced with the current pitch
# fenrirRate = is replaced with the current speed (speech rate)
# fenrir_volume = is replaced with the current volume
# fenrir_pitch = is replaced with the current pitch
# fenrir_rate = is replaced with the current speed (speech rate)
generic_speech_command=espeak-ng -a fenrir_volume -s fenrir_rate -p fenrir_pitch -v fenrir_voice -- "fenrir_text"
# min and max values of the TTS system that is used in generic_speech_command
@@ -183,7 +186,8 @@ double_tap_timeout=0.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=File writes to debug_file (Default:/tmp/fenrir.log)
# If the default log is already in use, Fenrir uses /tmp/fenrir2.log, etc.
# debugMode=Print just prints on the screen
debug_mode=File
debug_file=
@@ -197,11 +201,6 @@ respect_punctuation_pause=True
replace_undefined_punctuation_with_space=True
# Pause speech briefly at newline characters for better readability
new_line_pause=True
number_of_clipboards=50
# used path for "export_clipboard_to_file"
# $user is replaced by username
#clipboardExportPath=/home/$user/fenrirClipboard
clipboard_export_path=/tmp/fenrirClipboard
# Convert text emoticons like :) to descriptive text (e.g., "smiling face")
emoticons=True
# Define the Fenrir modifier key(s) - used to trigger Fenrir commands
@@ -262,6 +261,20 @@ diff_presentation=both
# verbose = include diff line content during navigation
diff_verbosity=compact
[clipboard]
# Number of clipboard history entries Fenrir keeps.
number_of_clipboards=50
# used path for "export_clipboard_to_file"
# $user is replaced by username
#clipboard_export_path=/home/$user/fenrirClipboard
clipboard_export_path=/tmp/fenrirClipboard
# Keep Fenrir's clipboard history and the X clipboard synchronized.
# In fenrir -x, an empty sync_display uses the current DISPLAY.
# In console/TTY mode, set sync_display explicitly, for example :0.
sync_enabled=False
sync_display=
sync_interval=0.5
[focus]
# Follow and announce text cursor position changes
cursor=True
+1 -1
View File
@@ -114,7 +114,7 @@ sudo ./fenrir -f -d -p
# Debug output goes to:
# - Console (with -p flag)
# - /var/log/fenrir.log
# - /tmp/fenrir.log
```
## Creating Commands
+6 -2
View File
@@ -50,7 +50,9 @@ Multiple settings can be separated by semicolons.
.TP
.BR \-d ", " \-\-debug
Enable debug mode. Debug information will be logged to /var/log/fenrir.log.
Enable debug mode. Debug information will be logged to /tmp/fenrir.log by
default. If another Fenrir debug instance is already using it, Fenrir uses
/tmp/fenrir2.log, /tmp/fenrir3.log, etc.
.TP
.BR \-p ", " \-\-print
@@ -240,6 +242,8 @@ genericDriver - Command-line TTS (espeak, etc.)
.IP \[bu] 4
dectalkDriver - DECtalk-compatible serial hardware speech
.IP \[bu] 4
dectalkUsbDriver - Native Access Solutions DECtalk USB hardware speech
.IP \[bu] 4
litetalkDriver - LiteTalk-compatible serial hardware speech
.IP \[bu] 4
doubletalkDriver - DoubleTalk LT-compatible serial hardware speech
@@ -476,7 +480,7 @@ User sound themes
User scripts
.TP
.B /var/log/fenrir.log
.B /tmp/fenrir.log
Debug log file
.TP
+30 -21
View File
@@ -1506,38 +1506,38 @@ Values: `+0.0+` is quietest, `+1.0+` is loudest.
The generic sound driver uses shell commands for play sound and
frequencies.
`+genericPlayFileCommand+` defines the command that is used to play a
`+generic_play_file_command+` defines the command that is used to play a
sound file.
....
generic_play_file_command=<your command for playing a file>
....
`+genericFrequencyCommand+` defines the command that is used playing
`+generic_frequency_command+` defines the command that is used playing
frequencies.
....
generic_frequency_command=<your command for playing a frequence>
....
The following variables are substituted in `+genericPlayFileCommand+`
and `+genericFrequencyCommand+`:
The following variables are substituted in `+generic_play_file_command+`
and `+generic_frequency_command+`:
* `+fenrirVolume+` = the current volume setting
* `+fenrirSoundFile+` = the sound file for an sound icon
* `+fenrirFrequence+` = the frequency to play
* `+fenrirDuration+` = the duration of the frequency
* `+fenrir_volume+` = the current volume setting
* `+fenrir_sound_file+` = the sound file for an sound icon
* `+fenrir_frequency+` = the frequency to play
* `+fenrir_duration+` = the duration of the frequency
Example genericPlayFileCommand (default)
Example generic_play_file_command (default)
....
generic_play_file_command=play -q -v fenrirVolume fenrirSoundFile
generic_play_file_command=play -q -v fenrir_volume fenrir_sound_file
....
Example genericFrequencyCommand (default)
Example generic_frequency_command (default)
....
generic_frequency_command=play -q -v fenrirVolume -n -c1 synth fenrirDuration sine fenrirFrequence
generic_frequency_command=play -q -v fenrir_volume -n -c1 synth fenrir_duration sine fenrir_frequency
....
==== Speech
@@ -1551,9 +1551,11 @@ enabled=True
Values: on=`+True+`, off=`+False+`
# Select speech driver, options are speechdDriver (default),
genericDriver, dectalkDriver, litetalkDriver, doubletalkDriver or tripletalkDriver: driver=speechdDriver
genericDriver, dectalkDriver, dectalkUsbDriver, litetalkDriver,
doubletalkDriver or tripletalkDriver: driver=speechdDriver
#driver=genericDriver
#driver=dectalkDriver
#driver=dectalkUsbDriver
#driver=litetalkDriver
#driver=doubletalkDriver
#driver=tripletalkDriver
@@ -1684,7 +1686,7 @@ the pico module:
language=de-DE
....
Hardware speech drivers use a serial device. Set an explicit path.
Serial hardware speech drivers use a serial device. Set an explicit path.
....
hardware_device=/dev/ttyACM0
@@ -1700,9 +1702,14 @@ hardware_baud_rate=9600
The `+doubletalkDriver+` targets DoubleTalk LT-style serial devices. It does
not support the internal DoubleTalk PC ISA card.
USB hardware speech synthesizers are supported only when Linux exposes them as
a serial tty such as `+/dev/ttyACM0+` or `+/dev/ttyUSB0+`. USB-only TripleTalk
models with no tty device need a separate driver.
The `+dectalkUsbDriver+` autodetects the native Access Solutions DECtalk USB,
USB ID `+0dd0:2001+`. It requires PyUSB and a libusb backend and ignores the
serial device and baud-rate settings. From a source checkout, install the USB
dependencies with `+python3 -m pip install '.[dectalk_usb]'+`. A non-root
Fenrir process needs a local udev rule granting its account read/write access
to `+0dd0:2001+`; do not make the device world-writable. Other USB hardware
speech synthesizers are supported only when Linux exposes them as a serial tty
such as `+/dev/ttyACM0+` or `+/dev/ttyUSB0+`.
Read new text as it occurs auto_read_incoming=True Values: on=`+True+`,
off=`+False+`
@@ -1921,7 +1928,8 @@ link:#export clipboard to file[export clipboard to file]. The variable
`+$user+` is replaced by the current logged username.
....
clipboardExportPath=/tmp/fenrirClipboard
[clipboard]
clipboard_export_path=/tmp/fenrirClipboard
....
Values: Text, Systemfilepath
@@ -1929,6 +1937,7 @@ Values: Text, Systemfilepath
The number of available clipboards:
....
[clipboard]
number_of_clipboards=10
....
@@ -2276,13 +2285,13 @@ that shows the issue.
==== How-to create a debug file
. Delete old debug stuff +
`+sudo rm /var/log/fenrir.log+`
. Start fenrir in debug mode +
`+sudo fenrir -d+`
. Do your stuff to reproduce the problem
. Stop fenrir (`+fenrirKey + q+`)
the debug file is located in `+/var/log/fenrir.log+`
the debug file is located in `+/tmp/fenrir.log+`. If another Fenrir debug
instance is already using it, check `+/tmp/fenrir2.log+`,
`+/tmp/fenrir3.log+`, etc.
Please be as precise as possible to make it easy to solve the problem.
+14 -5
View File
@@ -312,6 +312,9 @@ Fenrir automatically detects and provides audio feedback for progress indicators
- **Automatic**: Works with downloads, compilations, installations
- **Remote control**: Enable via socket commands
Fenrir detects stable progress structures rather than application-specific
status formats, which change too frequently to support reliably.
### Spell Checking
- `Fenrir + S` - Spell check current word
- `Fenrir + S S` - Add word to dictionary
@@ -337,18 +340,24 @@ Fenrir automatically detects and provides audio feedback for progress indicators
- **speechdDriver** - Speech-dispatcher (recommended)
- **genericDriver** - Command-line TTS (espeak, etc.)
- **dectalkDriver** - Serial DECtalk-compatible hardware speech
- **dectalkUsbDriver** - Native Access Solutions DECtalk USB (`0dd0:2001`)
- **litetalkDriver** - Serial LiteTalk-compatible hardware speech
- **doubletalkDriver** - Serial DoubleTalk LT-compatible hardware speech
- **tripletalkDriver** - Serial TripleTalk-compatible hardware speech
For hardware speech, set `speech#hardware_device` to an explicit serial path.
For serial hardware speech, set `speech#hardware_device` to an explicit path.
RPITalk gadget mode usually appears as `/dev/ttyACM0`; USB serial adapters
usually appear as `/dev/ttyUSB0`; built-in serial ports may be `/dev/ttyS0`.
The default baud rate is `9600`. `doubletalkDriver` targets
DoubleTalk LT-style serial devices, not the internal DoubleTalk PC ISA card.
USB TripleTalk devices work only if Linux exposes them as a serial tty such as
`/dev/ttyACM0` or `/dev/ttyUSB0`; USB-only models with no tty device need a
separate driver.
The `dectalkUsbDriver` autodetects the native Access Solutions DECtalk USB and
ignores both serial settings. It requires PyUSB and a libusb backend. From a
source checkout, install them with
`python3 -m pip install '.[dectalk_usb]'`. A non-root Fenrir process also needs
a local udev rule granting its account read/write access to USB ID
`0dd0:2001`; do not make the device world-writable. USB TripleTalk devices work
only if Linux exposes them as a serial tty such as `/dev/ttyACM0` or
`/dev/ttyUSB0`.
### Sound Drivers
- **genericDriver** - Sox-based (default)
@@ -428,7 +437,7 @@ For a dedicated PTY/terminal screen reader, see TDSR: https://github.com/tspivey
### Debug Mode
```bash
sudo fenrir -f -d
# Debug output goes to /var/log/fenrir.log
# Debug output goes to /tmp/fenrir.log
```
## Getting Help
+35 -30
View File
@@ -857,31 +857,32 @@ Values: ''0.0'' is quietest, ''1.0'' is loudest.
=== Generic Driver ===
The generic sound driver uses shell commands for play sound and frequencies.
''genericPlayFileCommand'' defines the command that is used to play a sound file.
''generic_play_file_command'' defines the command that is used to play a sound file.
generic_play_file_command=<your command for playing a file>
''genericFrequencyCommand'' defines the command that is used playing frequencies.
''generic_frequency_command'' defines the command that is used playing frequencies.
generic_frequency_command=<your command for playing a frequence>
The following variables are substituted in ''genericPlayFileCommand'' and ''genericFrequencyCommand'':
* ''fenrirVolume'' = the current volume setting
* ''fenrirSoundFile'' = the sound file for an sound icon
* ''fenrirFrequence'' = the frequency to play
* ''fenrirDuration'' = the duration of the frequency
The following variables are substituted in ''generic_play_file_command'' and ''generic_frequency_command'':
* ''fenrir_volume'' = the current volume setting
* ''fenrir_sound_file'' = the sound file for an sound icon
* ''fenrir_frequency'' = the frequency to play
* ''fenrir_duration'' = the duration of the frequency
Example genericPlayFileCommand (default)
generic_play_file_command=play -q -v fenrirVolume fenrirSoundFile
Example genericFrequencyCommand (default)
generic_frequency_command=play -q -v fenrirVolume -n -c1 synth fenrirDuration sine fenrirFrequence
Example generic_play_file_command (default)
generic_play_file_command=play -q -v fenrir_volume fenrir_sound_file
Example generic_frequency_command (default)
generic_frequency_command=play -q -v fenrir_volume -n -c1 synth fenrir_duration sine fenrir_frequency
==== Speech ====
Speech is configured in section ''[speech]''.
Turn speech on or off:
enabled=True
Values: on=''True'', off=''False''
# Select speech driver, options are speechdDriver (default), genericDriver, dectalkDriver, litetalkDriver, doubletalkDriver or tripletalkDriver:
# Select speech driver, options are speechdDriver (default), genericDriver, dectalkDriver, dectalkUsbDriver, litetalkDriver, doubletalkDriver or tripletalkDriver:
driver=speechdDriver
#driver=genericDriver
#driver=dectalkDriver
#driver=dectalkUsbDriver
#driver=litetalkDriver
#driver=doubletalkDriver
#driver=tripletalkDriver
@@ -894,6 +895,7 @@ Available Drivers:
* ''genericDriver'' using the generic driver, for Fenrir <1.5 this is not available
* ''speechdDriver'' using speech-dispatcher, for Fenrir <1.5 just use ''speechd''
* ''dectalkDriver'' using DECtalk-compatible serial hardware or RPITalk
* ''dectalkUsbDriver'' using the native Access Solutions DECtalk USB
* ''litetalkDriver'' using LiteTalk-compatible serial hardware or RPITalk
* ''doubletalkDriver'' using DoubleTalk LT-compatible serial hardware
* ''tripletalkDriver'' using TripleTalk-compatible serial hardware
@@ -927,7 +929,7 @@ Select the language you want Fenrir to use.
language=english-us
Values: Text, see your TTS synths documentation what is available.
Hardware speech drivers use a serial device. Set an explicit path.
Serial hardware speech drivers use a serial device. Set an explicit path.
hardware_device=/dev/ttyACM0
hardware_device=/dev/ttyUSB0
hardware_device=/dev/ttyS0
@@ -936,7 +938,7 @@ Hardware speech drivers use 9600 baud by default.
hardware_baud_rate=9600
The doubletalkDriver targets DoubleTalk LT-style serial devices. It does not support the internal DoubleTalk PC ISA card.
USB hardware speech synthesizers are supported only when Linux exposes them as a serial tty such as /dev/ttyACM0 or /dev/ttyUSB0. USB-only TripleTalk models with no tty device need a separate driver.
The dectalkUsbDriver autodetects the native Access Solutions DECtalk USB, USB ID 0dd0:2001. It requires PyUSB and a libusb backend and ignores the serial device and baud-rate settings. Other USB hardware speech synthesizers are supported only when Linux exposes them as a serial tty such as /dev/ttyACM0 or /dev/ttyUSB0.
Read new text as it occurs
auto_read_incoming=True
@@ -945,22 +947,22 @@ Values: on=''True'', off=''False''
=== Generic Driver ===
The generic speech driver uses shell commands for speech synthisus.
''genericSpeechCommand'' defines the command that is executed for creating speech
The following variables are substituted in ''genericSpeechCommand'':
* ''FenrirText'' = is the text that should be spoken
* ''fenrirModule'' = may be the speech module like used in speech-dispatcher, not every TTY needs this
* ''fenrirLanguage'' = the language to speak in
* ''fenrirVoice'' = is the current voice that should be used
* ''fenrirVolume'' = is replaced with the current volume
* ''fenrirPitch'' = is replaced with the current pitch
* ''fenrirRate'' = is replaced with the current speed (speech rate)
''generic_speech_command'' defines the command that is executed for creating speech
The following variables are substituted in ''generic_speech_command'':
* ''fenrir_text'' = is the text that should be spoken
* ''fenrir_module'' = may be the speech module like used in speech-dispatcher, not every TTY needs this
* ''fenrir_language'' = the language to speak in
* ''fenrir_voice'' = is the current voice that should be used
* ''fenrir_volume'' = is replaced with the current volume
* ''fenrir_pitch'' = is replaced with the current pitch
* ''fenrir_rate'' = is replaced with the current speed (speech rate)
Example genericSpeechCommand (default):
generic_speech_command=espeak -a fenrirVolume -s fenrirRate -p fenrirPitch -v fenrirVoice "fenrirText"
Example generic_speech_command (default):
generic_speech_command=espeak -a fenrir_volume -s fenrir_rate -p fenrir_pitch -v fenrir_voice "fenrir_text"
These are the minimum and maximum values of the TTS system used in genericSpeechCommand. They are needed to calculate the abstract range in volume, rate and pitch 0.0 - 1.0.
These are the minimum and maximum values of the TTS system used in generic_speech_command. They are needed to calculate the abstract range in volume, rate and pitch 0.0 - 1.0.
FenrirMinVolume=0
fenrir_min_volume=0
fenrir_max_volume=200
fenrir_min_pitch=0
fenrir_max_pitch=99
@@ -1133,10 +1135,12 @@ Values: on=''True'', off=''False''
Specify the path where the clipboard should be exported to.
See [[#export clipboard to file|export clipboard to file]].
The variable ''$user'' is replaced by the current logged username.
clipboardExportPath=/tmp/fenrirClipboard
[clipboard]
clipboard_export_path=/tmp/fenrirClipboard
Values: Text, Systemfilepath
The number of available clipboards:
[clipboard]
number_of_clipboards=10
Values: Integer, 1 - 999
@@ -1315,10 +1319,11 @@ Please report Bugs and feature requests to:
for bugs please provide a [[#Howto create a debug file|debug]] file that shows the issue.
==== How-to create a debug file ====
- Delete old debug stuff\\ ''sudo rm /var/log/fenrir.log''
- Start fenrir in debug mode\\ ''sudo fenrir -d''
- Do your stuff to reproduce the problem
- Stop fenrir (''fenrirKey + q'')
the debug file is located in ''/var/log/fenrir.log''
the debug file is located in ''/tmp/fenrir.log''. If another Fenrir debug
instance is already using it, check ''/tmp/fenrir2.log'',
''/tmp/fenrir3.log'', etc.
Please be as precise as possible to make it easy to solve the problem.
+1
View File
@@ -106,6 +106,7 @@ setup(
"pyte>=0.7.0",
],
extras_require={
"dectalk_usb": ["pyusb>=1.2.1"],
"x11": ["python-xlib>=0.33"],
},
)
@@ -13,6 +13,8 @@ from fenrirscreenreader.core.i18n import _
class command:
help_visible = False
def __init__(self):
pass
@@ -22,7 +24,7 @@ class command:
self.env["runtime"]["MemoryManager"].add_index_list(
"clipboardHistory",
self.env["runtime"]["SettingsManager"].get_setting_as_int(
"general", "number_of_clipboards"
"clipboard", "number_of_clipboards"
),
)
@@ -34,6 +34,7 @@ class command:
module = self.env["commandBuffer"]["lastTestedModule"]
voice = self.env["commandBuffer"]["lastTestedVoice"]
language = self.env["commandBuffer"].get("lastTestedLanguage")
self.env["runtime"]["OutputManager"].present_text(
f"Applying {voice} from {module}", interrupt=True
@@ -46,6 +47,7 @@ class command:
old_driver = SettingsManager.get_setting("speech", "driver")
old_module = SettingsManager.get_setting("speech", "module")
old_voice = SettingsManager.get_setting("speech", "voice")
old_language = SettingsManager.get_setting("speech", "language")
try:
# Apply new settings to runtime only (use set_setting to update
@@ -55,6 +57,10 @@ class command:
)
SettingsManager.set_setting("speech", "module", module)
SettingsManager.set_setting("speech", "voice", voice)
if language:
SettingsManager.set_setting(
"speech", "language", language
)
# Apply to speech driver instance directly
if "SpeechDriver" in self.env["runtime"]:
@@ -62,6 +68,8 @@ class command:
# Set the module and voice on the driver instance
SpeechDriver.set_module(module)
if language:
SpeechDriver.set_language(language)
SpeechDriver.set_voice(voice)
self.env["runtime"]["OutputManager"].present_text(
@@ -77,6 +85,9 @@ class command:
SettingsManager.set_setting("speech", "driver", old_driver)
SettingsManager.set_setting("speech", "module", old_module)
SettingsManager.set_setting("speech", "voice", old_voice)
SettingsManager.set_setting(
"speech", "language", old_language
)
self.env["runtime"]["OutputManager"].present_text(
f"Failed to apply voice, reverted: {str(e)}",
@@ -1,105 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
import os
from fenrirscreenreader.core import debug
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 _("cycles between available keyboard layouts")
def get_available_layouts(self):
"""Get list of available keyboard layout files"""
layouts = []
# Check standard locations for keyboard layouts
settings_root = "/etc/fenrirscreenreader/"
if not os.path.exists(settings_root):
# Fallback to source directory
import fenrirscreenreader
fenrir_path = os.path.dirname(fenrirscreenreader.__file__)
settings_root = fenrir_path + "/../../config/"
keyboard_path = settings_root + "keyboard/"
if os.path.exists(keyboard_path):
for file in os.listdir(keyboard_path):
if (
file.endswith(".conf")
and not file.startswith("__")
and not file.lower().startswith("pty")
):
layout_name = file.replace(".conf", "")
if layout_name not in layouts:
layouts.append(layout_name)
# Ensure we have at least basic layouts
if not layouts:
layouts = ["desktop", "laptop"]
else:
layouts.sort()
return layouts
def run(self):
current_layout = self.env["runtime"]["SettingsManager"].get_setting(
"keyboard", "keyboard_layout"
)
# Extract layout name from full path if needed
if "/" in current_layout:
current_layout = os.path.basename(current_layout).replace(
".conf", ""
)
# Get available layouts
available_layouts = self.get_available_layouts()
# Find next layout in cycle
try:
current_index = available_layouts.index(current_layout)
next_index = (current_index + 1) % len(available_layouts)
except ValueError:
# If current layout not found, start from beginning
next_index = 0
next_layout = available_layouts[next_index]
# Update setting and reload shortcuts
self.env["runtime"]["SettingsManager"].set_setting(
"keyboard", "keyboard_layout", next_layout
)
# Reload shortcuts with new layout
try:
self.env["runtime"]["InputManager"].reload_shortcuts()
self.env["runtime"]["OutputManager"].present_text(
_("Switched to {} keyboard layout").format(next_layout),
interrupt=True,
)
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
"Error reloading shortcuts: " + str(e), debug.DebugLevel.ERROR
)
self.env["runtime"]["OutputManager"].present_text(
_("Error switching keyboard layout"), interrupt=True
)
def set_callback(self, callback):
pass
@@ -26,7 +26,7 @@ class command:
def run(self):
clipboard_file_path = self.env["runtime"][
"SettingsManager"
].get_setting("general", "clipboard_export_path")
].get_setting("clipboard", "clipboard_export_path")
clipboard_file_path = clipboard_file_path.replace(
"$user", self.env["general"]["curr_user"]
)
@@ -4,33 +4,35 @@
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
import _thread
import importlib
import os
import pyperclip
from fenrirscreenreader.core import debug
from fenrirscreenreader.core.i18n import _
from fenrirscreenreader.utils import x_clipboard
def write_clipboard(clipboard):
"""Write the graphical clipboard without accessing Fenrir state."""
return x_clipboard.write_text(clipboard, scan_displays=True)
class command:
def __init__(self):
pass
self._task_ids = set()
def initialize(self, environment, script_path=""):
self.env = environment
self.script_path = script_path
def shutdown(self):
pass
task_manager = self.env["runtime"].get("BackgroundTaskManager")
if task_manager:
for task_id in self._task_ids:
task_manager.cancel_task(task_id)
self._task_ids.clear()
def get_description(self):
return _("Export current fenrir clipboard to X or GUI clipboard")
def run(self):
_thread.start_new_thread(self._thread_run, ())
def _thread_run(self):
try:
# Check if clipboard is empty
if self.env["runtime"]["MemoryManager"].is_index_list_empty(
@@ -46,52 +48,43 @@ class command:
"MemoryManager"
].get_index_list_element("clipboardHistory")
# Remember original display environment variable if it exists
original_display = os.environ.get("DISPLAY", "")
success = False
# Try different display options
for i in range(10):
display = f":{i}"
try:
# Set display environment variable
os.environ["DISPLAY"] = display
# Attempt to set clipboard content
# Weird workaround for some distros
importlib.reload(pyperclip)
pyperclip.copy(clipboard)
# If we get here without exception, we found a working
# display
success = True
break
except Exception:
# Failed for this display, try next one
continue
# Restore original display setting
if original_display:
os.environ["DISPLAY"] = original_display
else:
os.environ.pop("DISPLAY", None)
# Notify the user of the result
if success:
self.env["runtime"]["OutputManager"].present_text(
_("exported to the X session."), interrupt=True
)
else:
self.env["runtime"]["OutputManager"].present_text(
_(
"failed to export to X clipboard. No available display "
"found."
),
interrupt=True,
)
task_id = self.env["runtime"][
"BackgroundTaskManager"
].submit_task(
write_clipboard,
lambda result: self._handle_result(clipboard, result),
clipboard,
)
if task_id is not None:
self._task_ids.add(task_id)
except Exception as e:
self.env["runtime"]["OutputManager"].present_text(
str(e), sound_icon="", interrupt=False
)
def _handle_result(self, clipboard, result):
self._task_ids.discard(result.get("task_id"))
if result.get("succeeded") and result.get("value"):
sync_manager = self.env["runtime"].get("ClipboardSyncManager")
if sync_manager:
sync_manager.mark_written_to_x(clipboard)
self.env["runtime"]["OutputManager"].present_text(
_("exported to the X session."), interrupt=True
)
return
if not result.get("succeeded") and result.get("error"):
self.env["runtime"]["DebugManager"].write_debug_out(
"Clipboard export failed: " + result["error"],
debug.DebugLevel.ERROR,
)
self.env["runtime"]["OutputManager"].present_text(
_(
"failed to export to X clipboard. No available display found."
),
interrupt=True,
)
def set_callback(self, callback):
pass
@@ -26,7 +26,7 @@ class command:
def run(self):
clipboard_file_path = self.env["runtime"][
"SettingsManager"
].get_setting("general", "clipboard_export_path")
].get_setting("clipboard", "clipboard_export_path")
clipboard_file_path = clipboard_file_path.replace(
"$user", self.env["general"]["curr_user"]
)
@@ -4,84 +4,68 @@
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
import _thread
import importlib
import os
import pyperclip
from fenrirscreenreader.core.i18n import _
from fenrirscreenreader.utils import x_clipboard
def read_clipboard():
"""Read the graphical clipboard without accessing Fenrir state."""
return x_clipboard.read_text(scan_displays=True)
class command:
def __init__(self):
pass
self._task_ids = set()
def initialize(self, environment, script_path=""):
self.env = environment
self.script_path = script_path
def shutdown(self):
pass
task_manager = self.env["runtime"].get("BackgroundTaskManager")
if task_manager:
for task_id in self._task_ids:
task_manager.cancel_task(task_id)
self._task_ids.clear()
def get_description(self):
return _("imports the graphical clipboard to Fenrir's clipboard")
def run(self):
_thread.start_new_thread(self._thread_run, ())
task_id = self.env["runtime"]["BackgroundTaskManager"].submit_task(
read_clipboard, self._handle_result
)
if task_id is not None:
self._task_ids.add(task_id)
def _thread_run(self):
try:
# Remember original display environment variable if it exists
original_display = os.environ.get("DISPLAY", "")
clipboard_content = None
# Try different display options
for i in range(10):
display = f":{i}"
try:
# Set display environment variable
os.environ["DISPLAY"] = display
# Attempt to get clipboard content
# Weird workaround for some distros
importlib.reload(pyperclip)
clipboard_content = pyperclip.paste()
# If we get here without exception, we found a working
# display
if clipboard_content:
break
except Exception:
# Failed for this display, try next one
continue
# Restore original display setting
if original_display:
os.environ["DISPLAY"] = original_display
else:
os.environ.pop("DISPLAY", None)
# Process the clipboard content if we found any
if clipboard_content and isinstance(clipboard_content, str):
self.env["runtime"]["MemoryManager"].add_value_to_first_index(
"clipboardHistory", clipboard_content
)
self.env["runtime"]["OutputManager"].present_text(
"Import to Clipboard",
sound_icon="CopyToClipboard",
interrupt=True,
)
self.env["runtime"]["OutputManager"].present_text(
clipboard_content, sound_icon="", interrupt=False
)
else:
self.env["runtime"]["OutputManager"].present_text(
"No text found in clipboard or no accessible display",
interrupt=True,
)
except Exception as e:
def _handle_result(self, result):
self._task_ids.discard(result.get("task_id"))
clipboard_content = result.get("value")
if not result.get("succeeded"):
self.env["runtime"]["OutputManager"].present_text(
str(e), sound_icon="", interrupt=False
result.get("error", "Clipboard import failed"),
sound_icon="",
interrupt=False,
)
return
if not clipboard_content or not isinstance(clipboard_content, str):
self.env["runtime"]["OutputManager"].present_text(
"No text found in clipboard or no accessible display",
interrupt=True,
)
return
self.env["runtime"]["MemoryManager"].add_value_to_first_index(
"clipboardHistory", clipboard_content
)
self.env["runtime"]["OutputManager"].present_text(
"Import to Clipboard",
sound_icon="CopyToClipboard",
interrupt=True,
)
self.env["runtime"]["OutputManager"].present_text(
clipboard_content, sound_icon="", interrupt=False
)
def set_callback(self, callback):
pass
@@ -18,7 +18,7 @@ class command:
self.env["runtime"]["MemoryManager"].add_index_list(
"clipboardHistory",
self.env["runtime"]["SettingsManager"].get_setting_as_int(
"general", "number_of_clipboards"
"clipboard", "number_of_clipboards"
),
)
@@ -38,7 +38,7 @@ class command:
self.env["screen"]["new_content_text"],
)
if curr_word.isspace():
if not curr_word or curr_word.isspace():
self.env["runtime"]["OutputManager"].present_text(
_("blank"), interrupt=True, flush=False
)
@@ -20,7 +20,7 @@ class command:
pass
def get_description(self):
return _("Phonetically spells the next word and moves review to it")
return _("Spells the current word")
def run(self):
self.env["runtime"][
@@ -29,30 +29,28 @@ class command:
(
self.env["screen"]["newCursorReview"]["x"],
self.env["screen"]["newCursorReview"]["y"],
next_word,
curr_word,
end_of_screen,
line_break,
) = word_utils.get_next_word(
) = word_utils.get_current_word(
self.env["screen"]["newCursorReview"]["x"],
self.env["screen"]["newCursorReview"]["y"],
self.env["screen"]["new_content_text"],
)
if next_word.isspace():
if not curr_word or curr_word.isspace():
self.env["runtime"]["OutputManager"].present_text(
_("blank"), interrupt=True, flush=False
)
else:
first_sequence = True
for c in next_word:
curr_char = char_utils.get_phonetic(c)
self.env["runtime"]["OutputManager"].present_text(
for index, curr_char in enumerate(curr_word):
char_utils.present_char_for_review(
self.env,
curr_char,
interrupt=first_sequence,
interrupt=index == 0,
announce_capital=True,
flush=False,
)
first_sequence = False
if end_of_screen:
if self.env["runtime"]["SettingsManager"].get_setting_as_bool(
"review", "end_of_screen"
@@ -1,65 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
from fenrirscreenreader.core.i18n import _
from fenrirscreenreader.utils import char_utils
class command:
def __init__(self):
pass
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def get_description(self):
return _(
"phonetically presents the next character and set review to it"
)
def run(self):
self.env["runtime"][
"CursorManager"
].enter_review_mode_curr_text_cursor()
(
self.env["screen"]["newCursorReview"]["x"],
self.env["screen"]["newCursorReview"]["y"],
next_char,
end_of_screen,
line_break,
) = char_utils.get_next_char(
self.env["screen"]["newCursorReview"]["x"],
self.env["screen"]["newCursorReview"]["y"],
self.env["screen"]["new_content_text"],
)
next_char = char_utils.get_phonetic(next_char)
self.env["runtime"]["OutputManager"].present_text(
next_char, interrupt=True, announce_capital=True, flush=False
)
if end_of_screen:
if self.env["runtime"]["SettingsManager"].get_setting_as_bool(
"review", "end_of_screen"
):
self.env["runtime"]["OutputManager"].present_text(
_("end of screen"),
interrupt=True,
sound_icon="EndOfScreen",
)
if line_break:
if self.env["runtime"]["SettingsManager"].get_setting_as_bool(
"review", "line_break"
):
self.env["runtime"]["OutputManager"].present_text(
_("line break"), interrupt=False, sound_icon="EndOfLine"
)
def set_callback(self, callback):
pass
@@ -112,9 +112,9 @@ class command:
"review", "end_of_screen"
):
self.env["runtime"]["OutputManager"].present_text(
_("end of screen"),
_("start of screen"),
interrupt=True,
sound_icon="EndOfScreen",
sound_icon="StartOfScreen",
)
if line_break:
if self.env["runtime"]["SettingsManager"].get_setting_as_bool(
@@ -1,65 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
from fenrirscreenreader.core.i18n import _
from fenrirscreenreader.utils import char_utils
class command:
def __init__(self):
pass
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def get_description(self):
return _(
"phonetically presents the previous character and set review to it"
)
def run(self):
self.env["runtime"][
"CursorManager"
].enter_review_mode_curr_text_cursor()
(
self.env["screen"]["newCursorReview"]["x"],
self.env["screen"]["newCursorReview"]["y"],
prev_char,
end_of_screen,
line_break,
) = char_utils.get_prev_char(
self.env["screen"]["newCursorReview"]["x"],
self.env["screen"]["newCursorReview"]["y"],
self.env["screen"]["new_content_text"],
)
prev_char = char_utils.get_phonetic(prev_char)
self.env["runtime"]["OutputManager"].present_text(
prev_char, interrupt=True, announce_capital=True, flush=False
)
if end_of_screen:
if self.env["runtime"]["SettingsManager"].get_setting_as_bool(
"review", "end_of_screen"
):
self.env["runtime"]["OutputManager"].present_text(
_("end of screen"),
interrupt=True,
sound_icon="EndOfScreen",
)
if line_break:
if self.env["runtime"]["SettingsManager"].get_setting_as_bool(
"review", "line_break"
):
self.env["runtime"]["OutputManager"].present_text(
_("line break"), interrupt=False, sound_icon="EndOfLine"
)
def set_callback(self, callback):
pass
@@ -50,9 +50,9 @@ class command:
"review", "end_of_screen"
):
self.env["runtime"]["OutputManager"].present_text(
_("end of screen"),
_("start of screen"),
interrupt=True,
sound_icon="EndOfScreen",
sound_icon="StartOfScreen",
)
def set_callback(self, callback):
@@ -95,9 +95,9 @@ class command:
"review", "end_of_screen"
):
self.env["runtime"]["OutputManager"].present_text(
_("end of screen"),
_("start of screen"),
interrupt=False,
sound_icon="EndOfScreen",
sound_icon="StartOfScreen",
)
if line_break:
if self.env["runtime"]["SettingsManager"].get_setting_as_bool(
@@ -1,76 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
from fenrirscreenreader.core.i18n import _
from fenrirscreenreader.utils import char_utils
from fenrirscreenreader.utils import word_utils
class command:
def __init__(self):
pass
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
def get_description(self):
return _(
"Phonetically spells the previous word and moves review to it"
)
def run(self):
self.env["runtime"][
"CursorManager"
].enter_review_mode_curr_text_cursor()
(
self.env["screen"]["newCursorReview"]["x"],
self.env["screen"]["newCursorReview"]["y"],
prev_word,
end_of_screen,
line_break,
) = word_utils.get_prev_word(
self.env["screen"]["newCursorReview"]["x"],
self.env["screen"]["newCursorReview"]["y"],
self.env["screen"]["new_content_text"],
)
if prev_word.isspace():
self.env["runtime"]["OutputManager"].present_text(
_("blank"), interrupt=True, flush=False
)
else:
first_sequence = True
for c in prev_word:
curr_char = char_utils.get_phonetic(c)
self.env["runtime"]["OutputManager"].present_text(
curr_char,
interrupt=first_sequence,
announce_capital=True,
flush=False,
)
first_sequence = False
if end_of_screen:
if self.env["runtime"]["SettingsManager"].get_setting_as_bool(
"review", "end_of_screen"
):
self.env["runtime"]["OutputManager"].present_text(
_("end of screen"),
interrupt=True,
sound_icon="EndOfScreen",
)
if line_break:
if self.env["runtime"]["SettingsManager"].get_setting_as_bool(
"review", "line_break"
):
self.env["runtime"]["OutputManager"].present_text(
_("line break"), interrupt=False, sound_icon="EndOfLine"
)
def set_callback(self, callback):
pass
@@ -50,9 +50,9 @@ class command:
"review", "end_of_screen"
):
self.env["runtime"]["OutputManager"].present_text(
_("end of screen"),
_("start of screen"),
interrupt=True,
sound_icon="EndOfScreen",
sound_icon="StartOfScreen",
)
if line_break:
if self.env["runtime"]["SettingsManager"].get_setting_as_bool(
@@ -4,24 +4,42 @@
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
import _thread
import os
from subprocess import PIPE
from subprocess import Popen
import subprocess
from fenrirscreenreader.core.i18n import _
def run_script(script_path, current_user):
"""Run an external command without accessing Fenrir's shared state."""
process = subprocess.Popen(
[script_path, current_user],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
stdout, stderr = process.communicate()
return {
"return_code": process.returncode,
"stdout": stdout,
"stderr": stderr,
}
class command:
def __init__(self):
pass
self._task_ids = set()
def initialize(self, environment, script_path=""):
self.env = environment
self.script_path = script_path
def shutdown(self):
pass
task_manager = self.env["runtime"].get("BackgroundTaskManager")
if task_manager:
for task_id in self._task_ids:
task_manager.cancel_task(task_id)
self._task_ids.clear()
def get_description(self):
return _("script: {0} fullpath: {1}").format(
@@ -48,30 +66,39 @@ class command:
interrupt=False,
)
return
_thread.start_new_thread(self._thread_run, ())
def _thread_run(self):
try:
p = Popen(
[self.script_path, self.env["general"]["curr_user"]],
stdout=PIPE,
stderr=PIPE,
)
stdout, stderr = p.communicate()
stdout = stdout.decode("utf-8")
stderr = stderr.decode("utf-8")
self.env["runtime"]["OutputManager"].interrupt_output()
if stderr != "":
self.env["runtime"]["OutputManager"].present_text(
str(stderr), sound_icon="", interrupt=False
)
if stdout != "":
self.env["runtime"]["OutputManager"].present_text(
str(stdout), sound_icon="", interrupt=False
)
except Exception as e:
task_id = self.env["runtime"][
"BackgroundTaskManager"
].submit_external_task(
run_script,
self._handle_result,
self.script_path,
self.env["general"]["curr_user"],
)
if task_id is not None:
self._task_ids.add(task_id)
def _handle_result(self, result):
self._task_ids.discard(result.get("task_id"))
if not result.get("succeeded"):
self.env["runtime"]["OutputManager"].present_text(
str(e), sound_icon="", interrupt=False
result.get("error", _("Script failed")),
sound_icon="",
interrupt=False,
)
return
output = result.get("value") or {}
stderr = output.get("stderr", "")
stdout = output.get("stdout", "")
self.env["runtime"]["OutputManager"].interrupt_output()
if stderr:
self.env["runtime"]["OutputManager"].present_text(
stderr, sound_icon="", interrupt=False
)
if stdout:
self.env["runtime"]["OutputManager"].present_text(
stdout, sound_icon="", interrupt=False
)
def set_callback(self, callback):
@@ -19,19 +19,47 @@ class command:
pass
def get_description(self):
self.env["runtime"]["HelpManager"].toggle_tutorial_mode()
return _(
"Exiting tutorial mode. To enter tutorial mode again press Fenrir+f1"
)
return _("enter or leave tutorial mode")
def run(self):
self.env["runtime"]["HelpManager"].toggle_tutorial_mode()
if self.env["runtime"]["HelpManager"].is_tutorial_mode():
help_manager = self.env["runtime"]["HelpManager"]
was_active = help_manager.is_tutorial_mode()
help_manager.toggle_tutorial_mode()
is_active = help_manager.is_tutorial_mode()
if was_active and is_active:
self.env["runtime"]["OutputManager"].present_text(
_(
"Entering tutorial mode. In this mode commands are described but not "
"executed. You can move through the list of commands with the up and "
"down arrow keys. To Exit tutorial mode press Fenrir+f1."
"Unable to exit tutorial mode because exclusive keyboard "
"capture could not be released. Press Fenrir+F1 or Escape "
"to try again."
),
interrupt=True,
)
elif is_active:
self.env["runtime"]["OutputManager"].present_text(
_(
"Entering tutorial mode. In this mode commands are "
"described but not executed. Use up and down to browse "
"actions and left and right to switch between Active "
"actions, Unbound actions, and Plugins. Press Space to "
"repeat the current item. To exit tutorial mode press "
"Fenrir+F1 or Escape. Active actions."
),
interrupt=True,
)
if self.env["runtime"]["HelpManager"].is_capture_degraded():
self.env["runtime"]["OutputManager"].present_text(
_(
"Warning: full keyboard capture is unavailable. "
"Unbound keys may reach the active application."
),
interrupt=False,
)
else:
self.env["runtime"]["OutputManager"].present_text(
_(
"Exiting tutorial mode. To enter tutorial mode again "
"press Fenrir+F1"
),
interrupt=True,
)
@@ -19,7 +19,6 @@ class command:
pass
def get_description(self):
self.env["runtime"]["VmenuManager"].toggle_vmenu_mode()
return _("Entering or Leaving v menu mode.")
def run(self):
@@ -1,6 +1,5 @@
#!/usr/bin/env python3
import subprocess
import time
from fenrirscreenreader.core.i18n import _
@@ -8,11 +7,14 @@ from fenrirscreenreader.core.i18n import _
class command:
def __init__(self):
pass
self._request_generation = 0
self._loading = False
def initialize(self, environment):
self.env = environment
self.testMessage = "This is a voice test. The quick brown fox jumps over the lazy dog."
self.test_message = (
"This is a voice test. The quick brown fox jumps over the lazy dog."
)
self.modules = []
self.voices = []
self.module_index = 0
@@ -22,7 +24,9 @@ class command:
self.lastAnnounceTime = 0
def shutdown(self):
pass
self._request_generation += 1
self._loading = False
self._leave_voice_browser(False)
def get_description(self):
return "Interactive voice browser with arrow key navigation"
@@ -36,34 +40,59 @@ class command:
"Starting voice browser", interrupt=True
)
# Load modules
self.modules = self.get_speechd_modules()
if not self.modules:
if self._loading:
return
self._loading = True
self._request_generation += 1
generation = self._request_generation
self.env["runtime"]["SpeechDiscoveryManager"].request_modules(
lambda modules, error: self._modules_ready(
generation, modules, error
)
)
def _modules_ready(self, generation, modules, error):
if generation != self._request_generation:
return
if error or not modules:
self._loading = False
self.env["runtime"]["OutputManager"].present_text(
"No speech modules found", interrupt=True
)
self.env["runtime"]["OutputManager"].play_sound("Error")
return
# Set current module
self.modules = modules
current_module = self.env["runtime"]["SettingsManager"].get_setting(
"speech", "module"
)
if current_module and current_module in self.modules:
self.module_index = self.modules.index(current_module)
self._request_current_module_voices(generation, True)
# Load voices
self.load_voices_for_current_module()
def _request_current_module_voices(self, generation, enter_browser=False):
module = self.modules[self.module_index]
self.env["runtime"]["OutputManager"].present_text(
f"Loading voices for {module}", interrupt=True
)
self.env["runtime"]["SpeechDiscoveryManager"].request_voices(
module,
lambda _module, voices, error: self._voices_ready(
generation, voices, error, enter_browser
),
)
# Set current voice
def _voices_ready(self, generation, voices, error, enter_browser):
if generation != self._request_generation:
return
self._loading = False
self.voices = voices if not error else []
self.voice_index = 0
current_voice = self.env["runtime"]["SettingsManager"].get_setting(
"speech", "voice"
)
if current_voice and current_voice in self.voices:
self.voice_index = self.voices.index(current_voice)
# Enter browser mode
self.enter_voice_browser()
if enter_browser:
self.enter_voice_browser()
self.announce_current_selection()
def enter_voice_browser(self):
@@ -112,6 +141,9 @@ class command:
def exit_voice_browser(self):
"""Exit voice browser and restore normal key bindings"""
self._leave_voice_browser(True)
def _leave_voice_browser(self, announce):
if not self.browserActive:
return
@@ -124,16 +156,10 @@ class command:
if "voiceBrowserInstance" in self.env["runtime"]:
del self.env["runtime"]["voiceBrowserInstance"]
self.env["runtime"]["OutputManager"].present_text(
"Voice browser exited", interrupt=True
)
def load_voices_for_current_module(self):
"""Load voices for current module"""
if self.module_index < len(self.modules):
module = self.modules[self.module_index]
self.voices = self.get_module_voices(module)
self.voice_index = 0 # Reset to first voice when changing modules
if announce:
self.env["runtime"]["OutputManager"].present_text(
"Voice browser exited", interrupt=True
)
def announce_current_selection(self):
"""Announce current module and voice"""
@@ -148,7 +174,7 @@ class command:
module = self.modules[self.module_index]
if self.voices and self.voice_index < len(self.voices):
voice = self.voices[self.voice_index]
voice = self.voices[self.voice_index].split("|", 1)[0]
self.env["runtime"]["OutputManager"].present_text(
f"{module}: {voice} ({self.voice_index + 1}/{len(self.voices)})",
interrupt=True,
@@ -173,16 +199,19 @@ class command:
self.announce_current_selection()
def next_module(self):
"""Move to next module"""
self.module_index = (self.module_index + 1) % len(self.modules)
self.load_voices_for_current_module()
self.announce_current_selection()
self._change_module(1)
def prev_module(self):
"""Move to previous module"""
self.module_index = (self.module_index - 1) % len(self.modules)
self.load_voices_for_current_module()
self.announce_current_selection()
self._change_module(-1)
def _change_module(self, offset):
if self._loading or not self.modules:
return
self._loading = True
self._request_generation += 1
generation = self._request_generation
self.module_index = (self.module_index + offset) % len(self.modules)
self._request_current_module_voices(generation)
def test_voice(self):
"""Test current voice"""
@@ -193,15 +222,38 @@ class command:
return
module = self.modules[self.module_index]
voice = self.voices[self.voice_index]
encoded_voice = self.voices[self.voice_index]
voice_name = encoded_voice.split("|", 1)[0]
self.env["runtime"]["OutputManager"].present_text(
"Testing...", interrupt=True
)
if self.preview_voice(module, voice):
# Store for apply command
self._request_generation += 1
generation = self._request_generation
self._loading = True
self.env["runtime"]["SpeechDiscoveryManager"].request_voice_test(
module,
voice_name,
self.test_message,
lambda succeeded, error: self._voice_test_ready(
generation, module, encoded_voice, succeeded, error
),
)
def _voice_test_ready(
self, generation, module, voice, succeeded, _error
):
if generation != self._request_generation:
return
self._loading = False
if succeeded:
self.env["commandBuffer"]["lastTestedModule"] = module
self.env["commandBuffer"]["lastTestedVoice"] = voice
voice_name, separator, language = voice.partition("|")
self.env["commandBuffer"]["lastTestedVoice"] = voice_name
if separator:
self.env["commandBuffer"]["lastTestedLanguage"] = language
else:
self.env["commandBuffer"].pop("lastTestedLanguage", None)
self.env["runtime"]["OutputManager"].play_sound("Accept")
else:
self.env["runtime"]["OutputManager"].play_sound("Error")
@@ -212,13 +264,17 @@ class command:
return
module = self.modules[self.module_index]
voice = self.voices[self.voice_index]
voice, separator, language = self.voices[self.voice_index].partition(
"|"
)
try:
SettingsManager = self.env["runtime"]["SettingsManager"]
SettingsManager.settings["speech"]["driver"] = "speechdDriver"
SettingsManager.settings["speech"]["module"] = module
SettingsManager.settings["speech"]["voice"] = voice
if separator:
SettingsManager.settings["speech"]["language"] = language
if "SpeechDriver" in self.env["runtime"]:
SpeechDriver = self.env["runtime"]["SpeechDriver"]
@@ -236,66 +292,5 @@ class command:
)
self.env["runtime"]["OutputManager"].play_sound("Error")
def preview_voice(self, module, voice):
"""Test voice with spd-say"""
try:
cmd = ["spd-say", "-o", module, "-y", voice, self.testMessage]
result = subprocess.run(cmd, timeout=10)
return result.returncode == 0
except Exception:
return False
def get_speechd_modules(self):
"""Get available speech modules"""
try:
result = subprocess.run(
["spd-say", "-O"], capture_output=True, text=True, timeout=10
)
if result.returncode == 0:
lines = result.stdout.strip().split("\n")
return [line.strip() for line in lines[1:] if line.strip()]
except Exception:
pass
return []
def get_module_voices(self, module):
"""Get voices for module"""
try:
result = subprocess.run(
["spd-say", "-o", module, "-L"],
capture_output=True,
text=True,
timeout=10,
)
if result.returncode == 0:
lines = result.stdout.strip().split("\n")
voices = []
for line in lines[1:]:
if not line.strip():
continue
if module.lower() == "espeak-ng":
voice = self.process_espeak_voice(line)
if voice:
voices.append(voice)
else:
voices.append(line.strip())
return voices
except Exception:
pass
return []
def process_espeak_voice(self, voiceLine):
"""Process espeak voice format"""
parts = [p for p in voiceLine.split() if p]
if len(parts) < 2:
return None
lang_code = parts[-2].lower()
variant = parts[-1].lower()
return (
f"{lang_code}+{variant}"
if variant and variant != "none"
else lang_code
)
def set_callback(self, callback):
pass
@@ -1,225 +1,160 @@
#!/usr/bin/env python3
import subprocess
import threading
import time
from fenrirscreenreader.core import debug
from fenrirscreenreader.core.i18n import _
class command:
def __init__(self):
pass
self._request_generation = 0
self._loading = False
def initialize(self, environment):
self.env = environment
self.testMessage = (
self.test_message = (
"Voice test: The quick brown fox jumps over the lazy dog."
)
def shutdown(self):
pass
self._request_generation += 1
self._loading = False
def get_description(self):
return "Safe voice browser - cycles through voices without hanging"
return _("browse and test Speech Dispatcher voices")
def run(self):
try:
if self._loading:
return
self._loading = True
self._request_generation += 1
generation = self._request_generation
self.env["runtime"]["OutputManager"].present_text(
_("Loading speech modules"), interrupt=True
)
self.env["runtime"]["SpeechDiscoveryManager"].request_modules(
lambda modules, error: self._modules_ready(
generation, modules, error
)
)
def _modules_ready(self, generation, modules, error):
if generation != self._request_generation:
return
if error or not modules:
self._loading = False
self._report_error(_("No speech modules found"), error)
return
module_index = self.env["commandBuffer"].get(
"safeBrowserModuleIndex", 0
)
if module_index >= len(modules):
module_index = 0
module = modules[module_index]
self.env["runtime"]["OutputManager"].present_text(
_("Loading voices for {module}").format(module=module),
interrupt=True,
)
self.env["runtime"]["SpeechDiscoveryManager"].request_voices(
module,
lambda _module, voices, voice_error: self._voices_ready(
generation,
modules,
module_index,
voices,
voice_error,
),
)
def _voices_ready(
self, generation, modules, module_index, voices, error
):
if generation != self._request_generation:
return
if error or not voices:
self._loading = False
next_module = (module_index + 1) % len(modules)
self.env["commandBuffer"]["safeBrowserModuleIndex"] = next_module
self.env["commandBuffer"]["safeBrowserVoiceIndex"] = 0
self._report_error(_("No voices found"), error)
return
voice_index = self.env["commandBuffer"].get(
"safeBrowserVoiceIndex", 0
)
if voice_index >= len(voices):
voice_index = 0
module = modules[module_index]
voice = voices[voice_index]
voice_name = voice.split("|", 1)[0]
self.env["runtime"]["OutputManager"].present_text(
_("Module: {module}, voice: {voice}").format(
module=module, voice=voice_name
),
interrupt=True,
)
self.env["runtime"]["OutputManager"].present_text(
_("Testing voice"), interrupt=True
)
self.env["runtime"]["SpeechDiscoveryManager"].request_voice_test(
module,
voice_name,
self.test_message,
lambda succeeded, test_error: self._test_ready(
generation,
modules,
module_index,
voices,
voice_index,
succeeded,
test_error,
),
)
def _test_ready(
self,
generation,
modules,
module_index,
voices,
voice_index,
succeeded,
error,
):
if generation != self._request_generation:
return
self._loading = False
module = modules[module_index]
voice_name, separator, language = voices[voice_index].partition("|")
if succeeded:
self.env["commandBuffer"]["lastTestedModule"] = module
self.env["commandBuffer"]["lastTestedVoice"] = voice_name
if separator:
self.env["commandBuffer"]["lastTestedLanguage"] = language
else:
self.env["commandBuffer"].pop("lastTestedLanguage", None)
self.env["runtime"]["OutputManager"].present_text(
"Starting safe voice browser", interrupt=True
_("Voice test completed successfully"), interrupt=True
)
else:
self._report_error(_("Voice test failed"), error)
# Get modules with timeout protection
modules = self.get_speechd_modules_with_timeout()
if not modules:
self.env["runtime"]["OutputManager"].present_text(
"No speech modules found", interrupt=True
)
return
voice_index += 1
if voice_index >= len(voices):
voice_index = 0
module_index = (module_index + 1) % len(modules)
self.env["commandBuffer"]["safeBrowserModuleIndex"] = module_index
self.env["commandBuffer"]["safeBrowserVoiceIndex"] = voice_index
# Get current position from commandBuffer or start fresh
module_index = self.env["commandBuffer"].get(
"safeBrowserModuleIndex", 0
def _report_error(self, message, detail):
if detail:
self.env["runtime"]["DebugManager"].write_debug_out(
f"voice_browser_safe: {detail}",
debug.DebugLevel.ERROR,
)
voice_index = self.env["commandBuffer"].get(
"safeBrowserVoiceIndex", 0
)
# Ensure valid module index
if module_index >= len(modules):
module_index = 0
current_module = modules[module_index]
self.env["runtime"]["OutputManager"].present_text(
f"Loading voices for {current_module}...", interrupt=True
)
# Get voices with timeout protection
voices = self.get_module_voices_with_timeout(current_module)
if not voices:
self.env["runtime"]["OutputManager"].present_text(
f"No voices in {current_module}, trying next module",
interrupt=True,
)
module_index = (module_index + 1) % len(modules)
self.env["commandBuffer"][
"safeBrowserModuleIndex"
] = module_index
self.env["commandBuffer"]["safeBrowserVoiceIndex"] = 0
return
# Ensure valid voice index
if voice_index >= len(voices):
voice_index = 0
current_voice = voices[voice_index]
# Announce current selection
self.env["runtime"]["OutputManager"].present_text(
f"Module: {current_module} ({module_index + 1}/{len(modules)})",
interrupt=True,
)
self.env["runtime"]["OutputManager"].present_text(
f"Voice: {current_voice} ({voice_index + 1}/{len(voices)})",
interrupt=True,
)
# Test voice in background thread to avoid blocking
self.env["runtime"]["OutputManager"].present_text(
"Testing voice...", interrupt=True
)
# Use threading to prevent freezing
test_thread = threading.Thread(
target=self.test_voice_async,
args=(current_module, current_voice),
)
test_thread.daemon = True
test_thread.start()
# Store tested voice for apply command
self.env["commandBuffer"]["lastTestedModule"] = current_module
self.env["commandBuffer"]["lastTestedVoice"] = current_voice
# Advance to next voice for next run
voice_index += 1
if voice_index >= len(voices):
voice_index = 0
module_index = (module_index + 1) % len(modules)
# Store position for next run
self.env["commandBuffer"]["safeBrowserModuleIndex"] = module_index
self.env["commandBuffer"]["safeBrowserVoiceIndex"] = voice_index
# Give instructions
self.env["runtime"]["OutputManager"].present_text(
"Run again for next voice, or use apply voice command",
interrupt=True,
)
except Exception as e:
self.env["runtime"]["OutputManager"].present_text(
f"Voice browser error: {str(e)}", interrupt=True
)
self.env["runtime"]["OutputManager"].play_sound("Error")
def test_voice_async(self, module, voice):
"""Test voice in background thread to avoid blocking"""
try:
# Run with strict timeout
cmd = ["spd-say", "-o", module, "-y", voice, self.testMessage]
result = subprocess.run(cmd, timeout=5, capture_output=True)
# Schedule success sound for main thread
if result.returncode == 0:
# We can't call OutputManager from background thread safely
# So we'll just let the main thread handle feedback
pass
except subprocess.TimeoutExpired:
# Voice test timed out - this is okay, don't crash
pass
except Exception:
# Any other error - also okay, don't crash
pass
def get_speechd_modules_with_timeout(self):
"""Get speech modules with timeout protection"""
try:
result = subprocess.run(
["spd-say", "-O"], capture_output=True, text=True, timeout=3
)
if result.returncode == 0:
lines = result.stdout.strip().split("\n")
modules = [line.strip() for line in lines[1:] if line.strip()]
# Limit to first 10 modules to prevent overload
return modules[:10]
except subprocess.TimeoutExpired:
self.env["runtime"]["OutputManager"].present_text(
"Module detection timed out", interrupt=True
)
except Exception as e:
self.env["runtime"]["OutputManager"].present_text(
f"Module detection failed: {str(e)}", interrupt=True
)
return []
def get_module_voices_with_timeout(self, module):
"""Get voices with timeout and limits"""
try:
result = subprocess.run(
["spd-say", "-o", module, "-L"],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0:
lines = result.stdout.strip().split("\n")
voices = []
for line in lines[1:]:
if not line.strip():
continue
if module.lower() == "espeak-ng":
voice = self.process_espeak_voice(line)
if voice:
voices.append(voice)
else:
voices.append(line.strip())
# Limit voice count to prevent memory issues
if len(voices) > 1000:
self.env["runtime"]["OutputManager"].present_text(
f"found {len(voices)} voices, limiting to first 1000",
interrupt=True,
)
voices = voices[:1000]
return voices
except subprocess.TimeoutExpired:
self.env["runtime"]["OutputManager"].present_text(
f"Voice detection for {module} timed out", interrupt=True
)
except Exception as e:
self.env["runtime"]["OutputManager"].present_text(
f"Voice detection failed: {str(e)}", interrupt=True
)
return []
def process_espeak_voice(self, voiceLine):
"""Process espeak voice format"""
try:
parts = [p for p in voiceLine.split() if p]
if len(parts) < 2:
return None
lang_code = parts[-2].lower()
variant = parts[-1].lower()
return (
f"{lang_code}+{variant}"
if variant and variant != "none"
else lang_code
)
except Exception:
return None
self.env["runtime"]["OutputManager"].present_text(
message, interrupt=True
)
def set_callback(self, callback):
pass
@@ -0,0 +1,30 @@
#!/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 _("show next help category")
def run(self):
text = self.env["runtime"]["HelpManager"].next_help_section()
self.env["runtime"]["OutputManager"].present_text(
text, interrupt=True
)
def set_callback(self, callback):
pass
@@ -0,0 +1,30 @@
#!/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 _("show previous help category")
def run(self):
text = self.env["runtime"]["HelpManager"].prev_help_section()
self.env["runtime"]["OutputManager"].present_text(
text, interrupt=True
)
def set_callback(self, callback):
pass
@@ -27,6 +27,8 @@ class command:
return
if not self.env["runtime"]["ScreenManager"].is_delta():
return
if not self.env["screen"].get("new_delta_is_typing", False):
return
x_move = (
self.env["screen"]["new_cursor"]["x"]
@@ -79,10 +79,11 @@ class command:
# updates
delta_text = self.env["screen"]["new_delta"]
delta_length = len(delta_text)
explicit_progress_lines = self.get_explicit_progress_lines(delta_text)
if (
delta_length > 200
): # Allow longer progress lines like Claude Code's status
if not self.is_explicit_progress_delta(delta_text):
): # Allow longer progress lines such as terminal status output
if not explicit_progress_lines:
self.env["runtime"]["DebugManager"].write_debug_out(
f"Progress filter: delta too long ({delta_length})",
debug.DebugLevel.INFO,
@@ -91,7 +92,11 @@ class command:
# If delta contains newlines and is substantial, let incoming handler
# deal with it to avoid interfering with multi-line text output
if '\n' in delta_text and delta_length > 50:
if (
"\n" in delta_text
and delta_length > 50
and not explicit_progress_lines
):
self.env["runtime"]["DebugManager"].write_debug_out(
f"Progress filter: multiline delta ({delta_length} chars)",
debug.DebugLevel.INFO,
@@ -108,6 +113,40 @@ class command:
return True
def get_curl_classic_percentage(self, text):
"""Return progress from a structurally valid curl classic row."""
import re
size_field = r"\d+(?:\.\d+)?(?:[kKmMgGtT](?:i?[bB])?)?"
time_field = r"(?:(?:\d+:)?\d{2}:\d{2}|--:--:--)"
curl_classic_match = re.match(
rf"^\s*(\d+)\s+{size_field}\s+"
rf"(\d+)\s+{size_field}\s+"
rf"(\d+)\s+{size_field}\s+"
rf"{size_field}\s+{size_field}\s+"
rf"{time_field}\s+{time_field}\s+{time_field}\s+"
rf"{size_field}(?=\s|$)",
text,
)
if not curl_classic_match:
return None
total_percentage = int(curl_classic_match.group(1))
received_percentage = int(curl_classic_match.group(2))
uploaded_percentage = int(curl_classic_match.group(3))
if not all(
0 <= percentage <= 100
for percentage in (
total_percentage,
received_percentage,
uploaded_percentage,
)
):
return None
if total_percentage != received_percentage:
return None
return float(total_percentage)
def is_explicit_progress_delta(self, text):
"""Allow long single-line deltas that still look like progress output."""
import re
@@ -115,17 +154,42 @@ class command:
if "\n" in text or self.contains_url(text):
return False
if self.get_curl_classic_percentage(text) is not None:
return True
has_percentage = re.search(r"(^|\s)\d+(?:\.\d+)?\s*%", text)
if not has_percentage:
return False
return self.has_progress_status_fields(text)
return bool(
re.search(
r"[|\[\]#=*>█▉▊▋▌▍▎▏▒▓░]"
r"|\b\d+(?:\.\d+)?\s*[kKmMgGtT](?:i?B)?/s\b",
text,
)
bracketed_bar = re.search(
r"\[(?=[#=*>█▉▊▋▌▍▎▏▒▓░.\s-]{2,}\])"
r"(?=[^\]]*[#=*>█▉▊▋▌▍▎▏▒▓░])"
r"[#=*>█▉▊▋▌▍▎▏▒▓░.\s-]+\]",
text,
)
pipe_bar = re.search(
r"\|(?=[#=*>█▉▊▋▌▍▎▏▒▓░.\s-]{2,}\|)"
r"(?=[^|]*[#=*>█▉▊▋▌▍▎▏▒▓░])"
r"[#=*>█▉▊▋▌▍▎▏▒▓░.\s-]+\|",
text,
)
transfer_rate = re.search(
r"\b\d+(?:\.\d+)?\s*[kKmMgGtT](?:i?B)?/s\b",
text,
)
return bool(
bracketed_bar
or pipe_bar
or transfer_rate
)
def get_explicit_progress_lines(self, text):
"""Return structurally explicit progress rows from a screen delta."""
return [
line
for line in text.splitlines()
if self.is_explicit_progress_delta(line)
]
def reset_progress_state(self):
"""Reset progress state when a prompt is detected, allowing new progress operations to start fresh"""
@@ -140,6 +204,15 @@ class command:
import re
import time
if "\n" in text:
explicit_progress_lines = self.get_explicit_progress_lines(text)
if not explicit_progress_lines:
return
# Parallel transfer tools commonly repaint several package rows,
# followed by an aggregate row. One tone per update is enough, and
# the final explicit row is normally that aggregate.
text = explicit_progress_lines[-1]
current_time = time.time()
# Debug: Print what we're checking
@@ -193,34 +266,21 @@ class command:
return
# Pattern 1a2: Curl classic progress format (percentage without % symbol)
# Extract percentage from curl's classic format
curl_classic_match = re.search(
r"^\s*(\d+)\s+\d+[kMGT]?\s+(\d+)\s+\d+[kMGT]?\s+\d+\s+\d+\s+\d+[kMGT]?\s+\d+\s+\d+:\d+:\d+\s+\d+:\d+:\d+\s+\d+:\d+:\d+\s+\d+[kMGT]?\s*$", text
)
if curl_classic_match:
# Use the first percentage (total progress)
percentage = float(curl_classic_match.group(1))
if 0 <= percentage <= 100:
percentage = self.get_curl_classic_percentage(text)
if percentage is not None:
self.env["runtime"]["DebugManager"].write_debug_out(
"found curl classic percentage: " + str(percentage),
debug.DebugLevel.INFO,
)
if percentage != self.env["commandBuffer"]["lastProgressValue"]:
self.env["runtime"]["DebugManager"].write_debug_out(
"found curl classic percentage: " + str(percentage),
"Playing tone for curl: " + str(percentage),
debug.DebugLevel.INFO,
)
if (
percentage
!= self.env["commandBuffer"]["lastProgressValue"]
):
self.env["runtime"]["DebugManager"].write_debug_out(
"Playing tone for curl: " + str(percentage),
debug.DebugLevel.INFO,
)
self.play_progress_tone(percentage)
self.env["commandBuffer"][
"lastProgressValue"
] = percentage
self.env["commandBuffer"][
"lastProgressTime"
] = current_time
return
self.play_progress_tone(percentage)
self.env["commandBuffer"]["lastProgressValue"] = percentage
self.env["commandBuffer"]["lastProgressTime"] = current_time
return
# Pattern 1b: Time/token activity (not percentage-based, so use single
# beep)
@@ -240,8 +300,20 @@ class command:
pacman_match = re.search(
r"\d+(?:\.\d+)?\s+[kKmMgGtT]iB\s+\d+(?:\.\d+)?\s+[kKmMgGtT]iB/s\s+\d+:\d+", text
)
# Pattern 1g: Key/value status lines from ffmpeg-like terminal tools.
# These do not expose a percentage in the status line, so treat them as
# activity rather than guessing progress from elapsed time.
status_fields_match = self.has_progress_status_fields(text)
if time_match or token_match or dd_match or curl_match or transfer_match or pacman_match:
if (
time_match
or token_match
or dd_match
or curl_match
or transfer_match
or pacman_match
or status_fields_match
):
# For non-percentage progress, use a single activity beep every 2
# seconds
if (
@@ -326,43 +398,41 @@ class command:
self.env["commandBuffer"]["lastProgressTime"] = current_time
return
# Pattern 6: Claude Code working indicators (various symbols + activity text + "esc/ctrl+c to interrupt")
# Matches any: [symbol] [Task description] (... to interrupt ...)
# Pattern 6: Interruptible terminal activity indicators
# Matches any: [symbol] [Task description][…] (... to interrupt ...)
# Symbols include: * ✢ ✽ ✶ ✻ · • ◦ ○ ● ◆ and similar decorative characters
# 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|ctrl\+c) to interrupt.*\)',
# Keep this structural rather than adding application-specific formats,
# which change too frequently to support reliably.
interruptible_activity_match = re.search(
r'[*✢✽✶✻·•◦○●◆]\s+\w+.*?(?:…\s*)?\(.*(?:esc|ctrl\+c) to interrupt.*\)',
text,
re.IGNORECASE,
)
if claude_progress_match:
if not interruptible_activity_match:
interruptible_activity_match = re.search(
(
r'^\s*(?:thinking|working|processing|analyzing|searching|'
r'reading|writing|planning|running|executing|updating|'
r'building|installing|compiling|downloading|reviewing|'
r'generating|responding|applying|fixing|editing|creating|'
r'preparing|checking|opening|loading|fetching|retrieving|'
r'scanning|indexing|summarizing)\b.*'
r'\([^)]*(?:esc|ctrl\+c) to interrupt[^)]*\)\s*$'
),
text,
re.IGNORECASE,
)
if interruptible_activity_match:
if current_time - self.env["commandBuffer"]["lastProgressTime"] >= 1.0:
self.env["runtime"]["DebugManager"].write_debug_out(
"Playing Claude Code activity beep",
"Playing interruptible activity beep",
debug.DebugLevel.INFO,
)
self.play_activity_beep()
self.env["commandBuffer"]["lastProgressTime"] = current_time
return
# Pattern 6b: Claude Code tool invocation indicators (● Tool Name(...))
# Example: ● Web Search("query here")
tool_invocation_match = re.search(
r'[●○◉•◦]\s+(?:Web\s*Search|Read|Write|Edit|Bash|Glob|Grep|Task|WebFetch)\s*\(',
text,
re.IGNORECASE,
)
if tool_invocation_match:
if current_time - self.env["commandBuffer"]["lastProgressTime"] >= 1.0:
self.env["runtime"]["DebugManager"].write_debug_out(
"Playing Claude Code tool invocation beep",
debug.DebugLevel.INFO,
)
self.play_activity_beep()
self.env["commandBuffer"]["lastProgressTime"] = current_time
return
# Pattern 6c: Bullet/white bullet activity lines (•/◦ ...)
# Pattern 6b: Bullet/white bullet activity lines (•/◦ ...)
bullet_activity_match = re.search(
(
r'^\s*[•◦]\s+.*(?:…|\.{3,}|\b(?:thinking|working|processing|'
@@ -593,5 +663,48 @@ class command:
return True
return False
def has_progress_status_fields(self, text):
"""Recognize dense key=value status lines from terminal tools."""
import re
if "\n" in text or self.contains_url(text):
return False
fields = re.findall(r"\b([a-zA-Z][a-zA-Z0-9_-]*)=\s*\S+", text)
if len(fields) < 3:
return False
field_names = {field.lower() for field in fields}
progress_fields = {
"frame",
"fps",
"size",
"time",
"bitrate",
"speed",
"elapsed",
"dup",
"drop",
}
if len(field_names & progress_fields) < 3:
return False
has_time_field = bool(
re.search(
r"\b(?:time|elapsed|eta)=\s*\d+(?::\d{2}){1,2}(?:\.\d+)?\b",
text,
re.IGNORECASE,
)
)
has_rate_field = bool(
re.search(
r"\b(?:speed|fps|bitrate)=\s*[\d.]+[a-zA-Z/]*\b",
text,
re.IGNORECASE,
)
)
return has_time_field and has_rate_field
def set_callback(self, callback):
pass
@@ -141,7 +141,12 @@ class command(config_command):
self.config["general"] = {
"punctuation_level": "some",
"debug_level": "0",
}
# Basic clipboard defaults
self.config["clipboard"] = {
"number_of_clipboards": "50",
"clipboard_export_path": "/tmp/fenrirClipboard",
}
# Write the configuration
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
import itertools
import queue
import threading
from fenrirscreenreader.core import debug
from fenrirscreenreader.core.eventData import FenrirEventType
class BackgroundTaskManager:
"""Run bounded auxiliary work without exposing Fenrir state to workers."""
def __init__(self, worker_count=4, external_worker_count=2):
self.worker_count = worker_count
self.external_worker_count = external_worker_count
self.env = None
self._accepting_tasks = False
self._callbacks = {}
self._callback_lock = threading.Lock()
self._task_ids = itertools.count(1)
self._task_queues = {
"default": queue.Queue(),
"external": queue.Queue(),
}
self._workers = []
def initialize(self, environment):
self.env = environment
self._accepting_tasks = True
self._start_workers("default", self.worker_count)
self._start_workers("external", self.external_worker_count)
def _start_workers(self, lane, worker_count):
task_queue = self._task_queues[lane]
for worker_index in range(worker_count):
worker = threading.Thread(
target=self._run_worker,
args=(task_queue,),
name=f"fenrir-{lane}-{worker_index + 1}",
daemon=True,
)
self._workers.append(worker)
worker.start()
def shutdown(self):
self._accepting_tasks = False
with self._callback_lock:
self._callbacks.clear()
for task_queue in self._task_queues.values():
try:
while True:
task_queue.get_nowait()
task_queue.task_done()
except queue.Empty:
pass
for _worker_index in range(self.worker_count):
self._task_queues["default"].put(None)
for _worker_index in range(self.external_worker_count):
self._task_queues["external"].put(None)
for worker in self._workers:
worker.join(timeout=0.25)
self._workers = []
def submit_task(self, function, callback, *args, **kwargs):
"""Schedule a pure worker function and return its task identifier."""
return self._submit_to_lane(
"default", function, callback, args, kwargs
)
def submit_external_task(self, function, callback, *args, **kwargs):
"""Schedule potentially long-running user scripts on an isolated lane."""
return self._submit_to_lane(
"external", function, callback, args, kwargs
)
def _submit_to_lane(self, lane, function, callback, args, kwargs):
if not self._accepting_tasks or not callable(function):
return None
if not callable(callback):
return None
task_id = next(self._task_ids)
with self._callback_lock:
self._callbacks[task_id] = callback
self._task_queues[lane].put((task_id, function, args, kwargs))
return task_id
def cancel_task(self, task_id):
"""Discard delivery for a task that is no longer relevant."""
if task_id is None:
return
with self._callback_lock:
self._callbacks.pop(task_id, None)
def handle_result(self, result):
"""Deliver a completed task on Fenrir's main event-loop thread."""
if not isinstance(result, dict):
return
task_id = result.get("task_id")
with self._callback_lock:
callback = self._callbacks.pop(task_id, None)
if callback is None:
return
try:
callback(result)
except Exception as error:
self.env["runtime"]["DebugManager"].write_debug_out(
"BackgroundTaskManager handle_result: " + str(error),
debug.DebugLevel.ERROR,
)
def _run_worker(self, task_queue):
while True:
task = task_queue.get()
try:
if task is None:
return
task_id, function, args, kwargs = task
try:
value = function(*args, **kwargs)
result = {
"task_id": task_id,
"succeeded": True,
"value": value,
"error": "",
}
except Exception as error:
result = {
"task_id": task_id,
"succeeded": False,
"value": None,
"error": str(error),
}
if self._accepting_tasks:
self.env["runtime"]["EventManager"].put_to_event_queue(
FenrirEventType.background_task_result,
result,
)
finally:
task_queue.task_done()
@@ -0,0 +1,292 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
import os
import threading
from fenrirscreenreader.core import debug
from fenrirscreenreader.core.eventData import FenrirEventType
from fenrirscreenreader.utils import x_clipboard
def synchronize_clipboards(
display, fenrir_text, last_observed_fenrir, last_observed_x
):
"""Perform one clipboard I/O cycle without accessing Fenrir managers."""
result = {}
try:
x_text = x_clipboard.read_text(display)
if not isinstance(x_text, str) or not x_text:
x_text = None
except Exception as error:
return {"error": "ClipboardSyncManager paste failed: " + str(error)}
if fenrir_text and x_text and fenrir_text == x_text:
return {
"last_observed_fenrir": fenrir_text,
"last_observed_x": x_text,
}
fenrir_changed = (
fenrir_text and fenrir_text != last_observed_fenrir
)
x_changed = x_text and x_text != last_observed_x
if fenrir_changed:
try:
written = x_clipboard.write_text(fenrir_text, display)
except Exception as error:
written = False
result["error"] = (
"ClipboardSyncManager copy failed: " + str(error)
)
result["last_observed_fenrir"] = fenrir_text
if written:
result["last_written_to_x"] = fenrir_text
result["last_observed_x"] = fenrir_text
return result
if x_changed:
return {
"import_text": x_text,
"last_imported_from_x": x_text,
"last_observed_fenrir": x_text,
"last_observed_x": x_text,
}
if fenrir_text:
result["last_observed_fenrir"] = fenrir_text
if x_text:
result["last_observed_x"] = x_text
return result
class ClipboardSyncManager:
def __init__(self):
self.env = None
self.enabled = False
self.display = ""
self.interval = 0.5
self.running = False
self._task_id = None
self._scheduler_stop = threading.Event()
self._sync_event_pending = threading.Event()
self._scheduler_thread = None
self.last_written_to_x = None
self.last_imported_from_x = None
self.last_observed_fenrir = None
self.last_observed_x = None
def initialize(self, environment):
self.env = environment
self.enabled, self.display = self._resolve_startup()
self.interval = max(
0.1,
self.env["runtime"]["SettingsManager"].get_setting_as_float(
"clipboard", "sync_interval"
),
)
if self.enabled:
self._debug(
"Clipboard sync enabled for display "
+ self.display
+ " at interval "
+ str(self.interval)
)
self.start()
else:
self._debug("Clipboard sync disabled at startup")
def shutdown(self):
self.stop()
def mark_written_to_x(self, text):
if not isinstance(text, str) or not text:
return
self.last_written_to_x = text
self.last_observed_fenrir = text
self.last_observed_x = text
def _resolve_startup(self):
settings_manager = self.env["runtime"]["SettingsManager"]
if not settings_manager.get_setting_as_bool(
"clipboard", "sync_enabled"
):
return False, ""
configured_display = settings_manager.get_setting(
"clipboard", "sync_display"
)
screen_driver = settings_manager.get_setting("screen", "driver")
keyboard_driver = settings_manager.get_setting("keyboard", "driver")
is_x_mode = (
screen_driver == "ptyDriver" and keyboard_driver == "x11Driver"
)
if configured_display:
return True, configured_display
if is_x_mode:
current_display = os.environ.get("DISPLAY", "")
return bool(current_display), current_display
return False, ""
def start(self):
if self.running:
return
self.running = True
self._scheduler_stop.clear()
self._scheduler_thread = threading.Thread(
target=self._schedule_sync_events,
name="fenrir-clipboard-scheduler",
daemon=True,
)
self._scheduler_thread.start()
def stop(self):
self.running = False
task_manager = self.env["runtime"].get("BackgroundTaskManager")
if task_manager and self._task_id is not None:
task_manager.cancel_task(self._task_id)
self._task_id = None
self._scheduler_stop.set()
self._sync_event_pending.clear()
if self._scheduler_thread:
self._scheduler_thread.join(timeout=1.0)
self._scheduler_thread = None
def _schedule_sync_events(self):
while self.running:
if not self._sync_event_pending.is_set():
self._sync_event_pending.set()
self.env["runtime"]["EventManager"].put_to_event_queue(
FenrirEventType.clipboard_sync, None
)
if self._scheduler_stop.wait(self.interval):
return
def handle_sync_event(self):
"""Schedule clipboard I/O while keeping manager state on the loop."""
if not self.running or self._task_id is not None:
return
fenrir_text = self._get_fenrir_clipboard_text()
self._task_id = self.env["runtime"][
"BackgroundTaskManager"
].submit_task(
synchronize_clipboards,
lambda result: self._handle_poll_result(fenrir_text, result),
self.display,
fenrir_text,
self.last_observed_fenrir,
self.last_observed_x,
)
if self._task_id is None:
self._sync_event_pending.clear()
def _handle_poll_result(self, fenrir_snapshot, task_result):
self._task_id = None
self._sync_event_pending.clear()
if not self.running:
return
if not task_result.get("succeeded"):
self._debug(
"ClipboardSyncManager poll failed: "
+ task_result.get("error", "unknown error")
)
return
result = task_result.get("value") or {}
if result.get("error"):
self._debug(result["error"])
import_text = result.get("import_text")
if import_text:
if self._get_fenrir_clipboard_text() != fenrir_snapshot:
return
self.env["runtime"]["MemoryManager"].add_value_to_first_index(
"clipboardHistory", import_text
)
for attribute in (
"last_written_to_x",
"last_imported_from_x",
"last_observed_fenrir",
"last_observed_x",
):
if attribute in result:
setattr(self, attribute, result[attribute])
def poll_once(self):
fenrir_text = self._get_fenrir_clipboard_text()
x_text = self._get_x_clipboard_text()
if fenrir_text and x_text and fenrir_text == x_text:
self.last_observed_fenrir = fenrir_text
self.last_observed_x = x_text
return
fenrir_changed = (
fenrir_text
and fenrir_text != self.last_observed_fenrir
)
x_changed = (
x_text
and x_text != self.last_observed_x
)
if fenrir_changed:
if self._write_x_clipboard_text(fenrir_text):
self.last_written_to_x = fenrir_text
self.last_observed_x = fenrir_text
self.last_observed_fenrir = fenrir_text
return
if x_changed:
self.env["runtime"]["MemoryManager"].add_value_to_first_index(
"clipboardHistory", x_text
)
self.last_imported_from_x = x_text
self.last_observed_fenrir = x_text
self.last_observed_x = x_text
return
if fenrir_text:
self.last_observed_fenrir = fenrir_text
if x_text:
self.last_observed_x = x_text
def _get_fenrir_clipboard_text(self):
memory_manager = self.env["runtime"]["MemoryManager"]
if memory_manager.is_index_list_empty("clipboardHistory"):
return None
text = memory_manager.get_index_list_element("clipboardHistory")
if isinstance(text, str) and text:
return text
return None
def _get_x_clipboard_text(self):
try:
text = x_clipboard.read_text(self.display)
if isinstance(text, str) and text:
return text
return None
except Exception as error:
self.env["runtime"]["DebugManager"].write_debug_out(
"ClipboardSyncManager paste failed: " + str(error),
debug.DebugLevel.INFO,
)
return None
def _write_x_clipboard_text(self, text):
try:
return x_clipboard.write_text(text, self.display)
except Exception as error:
self._debug("ClipboardSyncManager copy failed: " + str(error))
return False
def _debug(self, message):
self.env["runtime"]["DebugManager"].write_debug_out(
message,
debug.DebugLevel.INFO,
)
@@ -22,4 +22,7 @@ command_info = {
# 'curr_command': '',
"lastCommandExecutionTime": time.time(),
"lastCommandRequestTime": time.time(),
"lastCommand": "",
"lastCommandSection": "",
"lastCommandRunTime": 0,
}
@@ -436,7 +436,10 @@ class CommandManager:
)
def execute_command(self, command, section="commands"):
if self.env["runtime"]["ScreenManager"].is_ignored_screen():
if (
self.env["runtime"]["ScreenManager"].is_ignored_screen()
and not self.env["runtime"]["HelpManager"].is_tutorial_mode()
):
return
if self.command_exists(command, section):
try:
@@ -474,6 +477,10 @@ class CommandManager:
def run_command(self, command, section="commands"):
if self.command_exists(command, section):
try:
command_time = time.time()
self.env["commandInfo"]["lastCommand"] = command
self.env["commandInfo"]["lastCommandSection"] = section
self.env["commandInfo"]["lastCommandRunTime"] = command_time
self.env["runtime"]["DebugManager"].write_debug_out(
"run_command command:" + section + "." + command,
debug.DebugLevel.INFO,
+49 -9
View File
@@ -3,24 +3,22 @@
import os
import pathlib
import fcntl
from datetime import datetime
from fenrirscreenreader.core import debug
class DebugManager:
DEFAULT_LOG_DIR = "/tmp"
DEFAULT_LOG_BASENAME = "fenrir"
DEFAULT_LOG_EXTENSION = ".log"
def __init__(self, file_name=""):
self._file = None
self._fileOpened = False
self._fileName = (
"/tmp/fenrir_"
+ str(os.getpid())
+ "_"
+ str(datetime.utcnow().strftime("%Y-%m-%d_%H-%M-%S"))
+ ".log"
)
if file_name != "":
self._fileName = file_name
self._fileName = file_name
self._useDefaultLogName = file_name == ""
def initialize(self, environment):
self.env = environment
@@ -39,6 +37,10 @@ class DebugManager:
self._fileOpened = False
if file_name != "":
self._fileName = file_name
self._useDefaultLogName = False
if self._useDefaultLogName:
self._open_default_debug_file()
return
if self._fileName != "":
directory = os.path.dirname(self._fileName)
if not os.path.exists(directory):
@@ -51,6 +53,43 @@ class DebugManager:
except Exception as e:
print(e)
def _open_default_debug_file(self):
pathlib.Path(self.DEFAULT_LOG_DIR).mkdir(parents=True, exist_ok=True)
log_number = 1
while True:
log_file = self._default_log_file_name(log_number)
try:
fd = os.open(
log_file,
os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW,
0o644,
)
file_obj = os.fdopen(fd, "a")
fcntl.flock(file_obj.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
file_obj.seek(0)
file_obj.truncate()
os.chmod(log_file, 0o644)
self._file = file_obj
self._fileName = log_file
self._fileOpened = True
return
except BlockingIOError:
try:
file_obj.close()
except Exception:
pass
log_number += 1
except OSError as e:
print(e)
return
def _default_log_file_name(self, log_number):
suffix = "" if log_number == 1 else str(log_number)
return os.path.join(
self.DEFAULT_LOG_DIR,
self.DEFAULT_LOG_BASENAME + suffix + self.DEFAULT_LOG_EXTENSION,
)
def write_debug_out(
self, text, level=debug.DebugLevel.DEACTIVE, on_any_level=False
):
@@ -120,3 +159,4 @@ class DebugManager:
def set_debug_file(self, file_name):
self.close_debug_file()
self._fileName = file_name
self._useDefaultLogName = file_name == ""
+195 -199
View File
@@ -1,10 +1,5 @@
#!/usr/bin/env python3
import importlib.util
import os
import subprocess
import time
from fenrirscreenreader.core import debug
@@ -13,51 +8,40 @@ class DynamicVoiceCommand:
def __init__(self, module, voice, env):
self.module = module
self.voice = voice
self.voice, separator, self.language = voice.partition("|")
if not separator:
self.language = ""
self.env = env
self.testMessage = "This is a voice test. The quick brown fox jumps over the lazy dog."
self.test_message = (
"This is a voice test. The quick brown fox jumps over the lazy dog."
)
self._test_generation = 0
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
self._test_generation += 1
def get_description(self):
return f"Select voice: {self.voice}"
def run(self):
self._test_generation += 1
generation = self._test_generation
try:
self.env["runtime"]["OutputManager"].present_text(
f"Testing voice {self.voice} from {self.module}. Please wait.",
interrupt=True,
)
# Brief pause before testing to avoid speech overlap
time.sleep(0.5)
# Test voice
testResult, errorMsg = self.test_voice()
if testResult:
self.env["runtime"]["OutputManager"].present_text(
"Voice test completed successfully. Navigate to Apply Tested Voice to use this voice.",
interrupt=False,
flush=False,
)
# Store for confirmation (use same variables as
# apply_tested_voice.py)
self.env["commandBuffer"]["lastTestedModule"] = self.module
self.env["commandBuffer"]["lastTestedVoice"] = self.voice
self.env["commandBuffer"]["pendingVoiceModule"] = self.module
self.env["commandBuffer"]["pendingVoiceVoice"] = self.voice
self.env["commandBuffer"]["voiceTestCompleted"] = True
else:
self.env["runtime"]["OutputManager"].present_text(
f"Voice test failed: {errorMsg}",
interrupt=False,
flush=False,
)
self.env["runtime"]["SpeechDiscoveryManager"].request_voice_test(
self.module,
self.voice,
self.test_message,
lambda succeeded, error: self._finish_voice_test(
generation, succeeded, error
),
)
except Exception as e:
self.env["runtime"]["OutputManager"].present_text(
@@ -66,35 +50,32 @@ class DynamicVoiceCommand:
flush=False,
)
def test_voice(self):
"""Test voice with spd-say"""
try:
cmd = [
"spd-say",
"-C",
"-w",
"-o",
self.module,
"-y",
self.voice,
self.testMessage,
]
result = subprocess.run(
cmd, timeout=8, capture_output=True, text=True
def _finish_voice_test(self, generation, succeeded, error):
if generation != self._test_generation:
return
if not succeeded:
self.env["runtime"]["OutputManager"].present_text(
f"Voice test failed: {error}",
interrupt=False,
flush=False,
)
if result.returncode == 0:
return True, "Voice test successful"
else:
error_msg = (
result.stderr.strip()
if result.stderr
else f"Command failed with return code {result.returncode}"
)
return False, error_msg
except subprocess.TimeoutExpired:
return False, "Voice test timed out"
except Exception as e:
return False, f"Error running voice test: {str(e)}"
return
self.env["commandBuffer"]["lastTestedModule"] = self.module
self.env["commandBuffer"]["lastTestedVoice"] = self.voice
self.env["commandBuffer"]["pendingVoiceModule"] = self.module
self.env["commandBuffer"]["pendingVoiceVoice"] = self.voice
if self.language:
self.env["commandBuffer"]["pendingVoiceLanguage"] = self.language
else:
self.env["commandBuffer"].pop("pendingVoiceLanguage", None)
self.env["commandBuffer"]["voiceTestCompleted"] = True
self.env["runtime"]["OutputManager"].present_text(
"Voice test completed successfully. "
"Navigate to Apply Tested Voice to use this voice.",
interrupt=False,
flush=False,
)
def set_callback(self, callback):
pass
@@ -125,6 +106,7 @@ class DynamicApplyVoiceCommand:
module = self.env["commandBuffer"]["pendingVoiceModule"]
voice = self.env["commandBuffer"]["pendingVoiceVoice"]
language = self.env["commandBuffer"].get("pendingVoiceLanguage")
self.env["runtime"]["OutputManager"].present_text(
f"Applying {voice} from {module}", interrupt=True
@@ -147,6 +129,7 @@ class DynamicApplyVoiceCommand:
old_driver = settings_manager.get_setting("speech", "driver")
old_module = settings_manager.get_setting("speech", "module")
old_voice = settings_manager.get_setting("speech", "voice")
old_language = settings_manager.get_setting("speech", "language")
try:
# Apply new settings to runtime only (use set_setting to update
@@ -156,51 +139,36 @@ class DynamicApplyVoiceCommand:
)
settings_manager.set_setting("speech", "module", module)
settings_manager.set_setting("speech", "voice", voice)
if language:
settings_manager.set_setting(
"speech", "language", language
)
# Apply settings to speech driver directly
if "SpeechDriver" in self.env["runtime"]:
SpeechDriver = self.env["runtime"]["SpeechDriver"]
speech_driver = self.env["runtime"]["SpeechDriver"]
# Get current module to see if we're changing modules
current_module = settings_manager.get_setting(
"speech", "module"
)
module_changing = current_module != module
# Set module and voice on driver instance first
SpeechDriver.set_module(module)
SpeechDriver.set_voice(voice)
if module_changing:
# Module change requires reinitializing the speech
# driver
self.env["runtime"]["OutputManager"].present_text(
f"Switching from {current_module} to {module} module",
interrupt=True,
)
SpeechDriver.shutdown()
SpeechDriver.initialize(self.env)
# Re-set after initialization
SpeechDriver.set_module(module)
SpeechDriver.set_voice(voice)
self.env["runtime"]["OutputManager"].present_text(
"Speech driver reinitialized", interrupt=True
)
# Set module and voice on the driver instance.
speech_driver.set_module(module)
if language:
speech_driver.set_language(language)
speech_driver.set_voice(voice)
# Debug: verify what was actually set
self.env["runtime"]["OutputManager"].present_text(
f"Speech driver now has module: {SpeechDriver.module}, voice: {SpeechDriver.voice}",
"Speech driver now has module: "
f"{speech_driver.module}, voice: "
f"{speech_driver.voice}",
interrupt=True,
)
# Force application by speaking a test message
self.env["runtime"]["OutputManager"].present_text(
"Voice applied successfully! You should hear this in the new voice.",
"Voice applied successfully! You should hear this in "
"the new voice.",
interrupt=True,
)
# Brief pause then more speech to test
time.sleep(1)
self.env["runtime"]["OutputManager"].present_text(
"Use save settings to make permanent", interrupt=True
)
@@ -213,16 +181,19 @@ class DynamicApplyVoiceCommand:
except Exception as e:
# Revert on failure
settings_manager.settings["speech"]["driver"] = old_driver
settings_manager.settings["speech"]["module"] = old_module
settings_manager.settings["speech"]["voice"] = old_voice
settings_manager.set_setting("speech", "driver", old_driver)
settings_manager.set_setting("speech", "module", old_module)
settings_manager.set_setting("speech", "voice", old_voice)
settings_manager.set_setting(
"speech", "language", old_language
)
# Try to reinitialize with old settings
if "SpeechDriver" in self.env["runtime"]:
try:
SpeechDriver = self.env["runtime"]["SpeechDriver"]
SpeechDriver.shutdown()
SpeechDriver.initialize(self.env)
speech_driver = self.env["runtime"]["SpeechDriver"]
speech_driver.shutdown()
speech_driver.initialize(self.env)
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
"dynamicVoiceMenu: Error reinitializing speech driver: "
@@ -245,56 +216,139 @@ class DynamicApplyVoiceCommand:
pass
def add_dynamic_voice_menus(VmenuManager):
"""Add dynamic voice menus to vmenu system"""
def add_dynamic_voice_menus(vmenu_manager):
"""Populate cached voice menus and start non-blocking discovery if needed."""
try:
env = VmenuManager.env
# Get speech modules
modules = get_speechd_modules()
if not modules:
return
# Create voice browser submenu
voice_browser_menu = {}
# Add apply voice command
apply_command = DynamicApplyVoiceCommand(env)
voice_browser_menu["Apply Tested Voice Action"] = apply_command
# Add modules as submenus
for module in modules[
:8
]: # Limit to 8 modules to keep menu manageable
module_menu = {}
# Get voices for this module
voices = get_module_voices(module)
if voices:
# Add voice commands
for voice in voices:
voice_command = DynamicVoiceCommand(module, voice, env)
module_menu[f"{voice} Action"] = voice_command
else:
module_menu["No voices available Action"] = (
create_info_command(f"No voices found for {module}", env)
env = vmenu_manager.env
discovery = env["runtime"]["SpeechDiscoveryManager"]
generation = getattr(vmenu_manager, "_voice_menu_generation", 0) + 1
vmenu_manager._voice_menu_generation = generation
modules = discovery.get_cached_modules()
if modules is None:
_install_loading_menu(vmenu_manager)
discovery.request_modules(
lambda found, error: _request_dynamic_voice_lists(
vmenu_manager, generation, found, error, False
)
voice_browser_menu[f"{module} Menu"] = module_menu
# Add to main menu dict
VmenuManager.menuDict["Voice Browser Menu"] = voice_browser_menu
except Exception as e:
# Use debug manager instead of print for error logging
)
return
_request_dynamic_voice_lists(
vmenu_manager, generation, modules, "", True
)
except Exception as error:
if "DebugManager" in env["runtime"]:
env["runtime"]["DebugManager"].write_debug_out(
f"Error creating dynamic voice menus: {e}",
f"Error creating dynamic voice menus: {error}",
debug.DebugLevel.ERROR,
)
else:
print(f"Error creating dynamic voice menus: {e}")
print(f"Error creating dynamic voice menus: {error}")
def _request_dynamic_voice_lists(
vmenu_manager, generation, modules, error, allow_active
):
if generation != getattr(vmenu_manager, "_voice_menu_generation", 0):
return
if error or not modules:
if vmenu_manager.get_active() and not allow_active:
vmenu_manager._voice_menu_refresh_pending = True
return
_install_discovery_error_menu(vmenu_manager)
return
discovery = vmenu_manager.env["runtime"]["SpeechDiscoveryManager"]
selected_modules = modules[:8]
voices_by_module = {}
missing_modules = []
for module in selected_modules:
voices = discovery.get_cached_voices(module)
if voices is None:
missing_modules.append(module)
else:
voices_by_module[module] = voices
if not missing_modules:
_install_dynamic_voice_menu(
vmenu_manager,
generation,
selected_modules,
voices_by_module,
allow_active,
)
return
_install_loading_menu(vmenu_manager)
remaining_modules = set(missing_modules)
def voices_ready(module, voices, _voice_error):
if generation != getattr(vmenu_manager, "_voice_menu_generation", 0):
return
voices_by_module[module] = voices
remaining_modules.discard(module)
if not remaining_modules:
_install_dynamic_voice_menu(
vmenu_manager,
generation,
selected_modules,
voices_by_module,
False,
)
for module in missing_modules:
discovery.request_voices(module, voices_ready)
def _install_dynamic_voice_menu(
vmenu_manager,
generation,
modules,
voices_by_module,
allow_active,
):
if generation != getattr(vmenu_manager, "_voice_menu_generation", 0):
return
if vmenu_manager.get_active() and not allow_active:
vmenu_manager._voice_menu_refresh_pending = True
return
env = vmenu_manager.env
voice_browser_menu = {
"Apply Tested Voice Action": DynamicApplyVoiceCommand(env)
}
for module in modules:
module_menu = {}
voices = voices_by_module.get(module, [])
for voice in voices:
voice_name = voice.split("|", 1)[0]
module_menu[f"{voice_name} Action"] = DynamicVoiceCommand(
module, voice, env
)
if not module_menu:
module_menu["No voices available Action"] = create_info_command(
f"No voices found for {module}", env
)
voice_browser_menu[f"{module} Menu"] = module_menu
vmenu_manager.menuDict["Voice Browser Menu"] = voice_browser_menu
vmenu_manager._voice_menu_refresh_pending = False
def _install_loading_menu(vmenu_manager):
env = vmenu_manager.env
vmenu_manager.menuDict["Voice Browser Menu"] = {
"Loading voices Action": create_info_command(
"Voice information is still loading", env
)
}
def _install_discovery_error_menu(vmenu_manager):
env = vmenu_manager.env
vmenu_manager.menuDict["Voice Browser Menu"] = {
"Voice discovery failed Action": create_info_command(
"Voice discovery failed", env
)
}
def create_info_command(message, env):
@@ -323,61 +377,3 @@ def create_info_command(message, env):
pass
return InfoCommand(message, env)
def get_speechd_modules():
"""Get available speech modules"""
try:
result = subprocess.run(
["spd-say", "-O"], capture_output=True, text=True, timeout=5
)
if result.returncode == 0:
lines = result.stdout.strip().split("\n")
return [line.strip() for line in lines[1:] if line.strip()]
except Exception:
pass
return []
def get_module_voices(module):
"""Get voices for a module"""
try:
result = subprocess.run(
["spd-say", "-o", module, "-L"],
capture_output=True,
text=True,
timeout=8,
)
if result.returncode == 0:
lines = result.stdout.strip().split("\n")
voices = []
for line in lines[1:]:
if not line.strip():
continue
if module.lower() == "espeak-ng":
voice = process_espeak_voice(line)
if voice:
voices.append(voice)
else:
voices.append(line.strip())
return voices
except Exception:
pass
return []
def process_espeak_voice(voiceLine):
"""Process espeak voice format"""
try:
parts = [p for p in voiceLine.split() if p]
if len(parts) < 2:
return None
lang_code = parts[-2].lower()
variant = parts[-1].lower()
return (
f"{lang_code}+{variant}"
if variant and variant != "none"
else lang_code
)
except Exception:
return None
+2
View File
@@ -19,6 +19,8 @@ class FenrirEventType(Enum):
heart_beat = 6
execute_command = 7
remote_incomming = 8
background_task_result = 9
clipboard_sync = 10
def __int__(self):
return self.value
@@ -63,6 +63,12 @@ class EventManager:
self.env["runtime"]["FenrirManager"].handle_execute_command(event)
elif event["Type"] == FenrirEventType.remote_incomming:
self.env["runtime"]["FenrirManager"].handle_remote_incomming(event)
elif event["Type"] == FenrirEventType.background_task_result:
self.env["runtime"]["FenrirManager"].handle_background_task_result(
event
)
elif event["Type"] == FenrirEventType.clipboard_sync:
self.env["runtime"]["FenrirManager"].handle_clipboard_sync(event)
def is_main_event_loop_running(self):
return self.running.value == 1
+94 -25
View File
@@ -46,6 +46,7 @@ class FenrirManager:
self.is_initialized = True
self.modifierInput = False
self.modifier_prefix_input = False
self.singleKeyCommand = False
self.command = ""
self.set_process_name()
@@ -81,19 +82,25 @@ class FenrirManager:
else:
return
if self.environment["runtime"]["InputManager"].no_key_pressed():
self.environment["runtime"]["InputManager"].clear_last_deep_input()
if self.environment["runtime"]["ScreenManager"].is_ignored_screen():
tutorial_mode = self.environment["runtime"][
"HelpManager"
].is_tutorial_mode()
if (
self.environment["runtime"]["ScreenManager"].is_ignored_screen()
and not tutorial_mode
):
self.environment["runtime"]["InputManager"].write_event_buffer()
else:
if self.environment["runtime"]["HelpManager"].is_tutorial_mode():
if tutorial_mode:
self.environment["runtime"][
"InputManager"
].clear_event_buffer()
self.environment["runtime"]["InputManager"].key_echo(
event["data"]
)
if self.environment["runtime"][
"ScreenManager"
].is_ignored_screen():
self.environment["runtime"]["InputManager"].key_echo(
event["data"]
)
if self.environment["runtime"]["VmenuManager"].get_active():
self.environment["runtime"][
@@ -121,13 +128,16 @@ class FenrirManager:
self.environment["runtime"][
"InputManager"
].clear_event_buffer()
else:
# Hold conventional modifier presses long enough to determine
# whether a Fenrir or Script modifier follows them.
elif not self.modifier_prefix_input:
self.environment["runtime"][
"InputManager"
].write_event_buffer()
if self.environment["runtime"]["InputManager"].no_key_pressed():
self.modifierInput = False
self.modifier_prefix_input = False
self.singleKeyCommand = False
self.environment["runtime"]["InputManager"].write_event_buffer()
self.environment["runtime"]["InputManager"].handle_device_grab()
@@ -151,10 +161,15 @@ class FenrirManager:
if self.environment["runtime"]["CommandManager"].command_exists(
current_command, "help"
):
self.environment["runtime"]["CommandManager"].execute_command(
self.environment["runtime"]["CommandManager"].run_command(
current_command, "help"
)
return
if current_command == "TOGGLE_TUTORIAL_MODE":
self.environment["runtime"]["CommandManager"].run_command(
current_command, "commands"
)
return
elif self.environment["runtime"]["VmenuManager"].get_active():
if self.environment["runtime"]["CommandManager"].command_exists(
current_command, "vmenu-navigation"
@@ -206,6 +221,11 @@ class FenrirManager:
event["data"]
)
def handle_background_task_result(self, event):
self.environment["runtime"]["BackgroundTaskManager"].handle_result(
event["data"]
)
def handle_screen_change(self, event):
self.environment["runtime"]["ScreenManager"].handle_screen_change(
event["data"]
@@ -229,13 +249,20 @@ class FenrirManager:
):
self.environment["runtime"]["InputManager"].clear_last_deep_input()
if not self._input_target_is_active():
self.environment["runtime"]["InputManager"].clear_last_deep_input()
return
if (
self.environment["runtime"][
"CursorManager"
].is_cursor_vertical_move()
or self.environment["runtime"][
"CursorManager"
].is_cursor_horizontal_move()
self._has_direct_user_input()
and (
self.environment["runtime"][
"CursorManager"
].is_cursor_vertical_move()
or self.environment["runtime"][
"CursorManager"
].is_cursor_horizontal_move()
)
):
self.environment["runtime"][
"CommandManager"
@@ -244,7 +271,22 @@ class FenrirManager:
self.environment["runtime"]["CommandManager"].execute_default_trigger(
"onScreenUpdate"
)
self.environment["runtime"]["InputManager"].clear_last_deep_input()
def _input_target_is_active(self):
input_driver = self.environment["runtime"].get("InputDriver")
if input_driver is None:
return True
return input_driver.is_active()
def _has_direct_user_input(self):
deepest_input = self.environment["runtime"][
"InputManager"
].get_last_deepest_input()
if not deepest_input:
return False
return not any(
key in deepest_input for key in ("KEY_FENRIR", "KEY_SCRIPT")
)
def handle_plug_input_device(self, event):
try:
@@ -268,10 +310,19 @@ class FenrirManager:
)
def handle_heart_beat(self, event):
if not self._input_target_is_active():
return
self.environment["runtime"]["CommandManager"].execute_default_trigger(
"onHeartBeat", force=True
)
def handle_clipboard_sync(self, event):
clipboard_sync_manager = self.environment["runtime"].get(
"ClipboardSyncManager"
)
if clipboard_sync_manager:
clipboard_sync_manager.handle_sync_event()
def detect_shortcut_command(self):
if self.environment["input"]["key_forward"] != 0:
return
@@ -281,14 +332,26 @@ class FenrirManager:
):
return
if self.environment["runtime"]["InputManager"].is_key_press():
self.modifierInput = self.environment["runtime"][
"InputManager"
].curr_key_is_modifier()
input_manager = self.environment["runtime"]["InputManager"]
if input_manager.is_key_press():
self.modifierInput = input_manager.curr_input_has_command_modifier()
self.modifier_prefix_input = (
not self.modifierInput
and input_manager.curr_input_is_modifier_prefix()
)
else:
if not self.environment["runtime"][
"InputManager"
].no_key_pressed():
if (
not self.modifierInput
and input_manager.curr_input_has_command_modifier()
):
self.modifierInput = True
self.modifier_prefix_input = False
elif (
self.modifier_prefix_input
and not input_manager.curr_input_is_modifier_prefix()
):
self.modifier_prefix_input = False
if not input_manager.no_key_pressed():
if self.singleKeyCommand:
self.singleKeyCommand = (
len(self.environment["input"]["curr_input"]) == 1
@@ -311,7 +374,13 @@ class FenrirManager:
self.singleKeyCommand = True
elif (
(
self.environment["runtime"]["VmenuManager"].get_active()
(
"HelpManager" in self.environment["runtime"]
and self.environment["runtime"][
"HelpManager"
].is_tutorial_mode()
)
or self.environment["runtime"]["VmenuManager"].get_active()
or self.environment["runtime"][
"DiffReviewManager"
].is_active()
@@ -17,12 +17,15 @@ general_data = {
"CursorManager",
"ApplicationManager",
"CommandManager",
"ClipboardSyncManager",
"ScreenManager",
"InputManager",
"OutputManager",
"SpeechHistoryManager",
"HelpManager",
"MemoryManager",
"SpeechDiscoveryManager",
"BackgroundTaskManager",
"EventManager",
"ProcessManager",
"VmenuManager",
+237 -104
View File
@@ -4,99 +4,181 @@
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
import ast
import copy
from fenrirscreenreader.core import debug
from fenrirscreenreader.core.i18n import _
class HelpManager:
ACTIVE_SECTION = "active"
UNBOUND_SECTION = "unbound"
PLUGINS_SECTION = "plugins"
HELP_SECTIONS = (
ACTIVE_SECTION,
UNBOUND_SECTION,
PLUGINS_SECTION,
)
def __init__(self):
self.helpDict = {}
self.tutorialListIndex = None
self.env = None
self.help_lists = {
self.ACTIVE_SECTION: [],
self.UNBOUND_SECTION: [],
self.PLUGINS_SECTION: [],
}
self.help_section = self.ACTIVE_SECTION
self.tutorial_list_index = None
self.bindings_backup = None
self.raw_bindings_backup = None
self.capture_degraded = False
def initialize(self, environment):
self.env = environment
def shutdown(self):
pass
if self.is_tutorial_mode():
self.set_tutorial_mode(False)
def toggle_tutorial_mode(self):
self.set_tutorial_mode(not self.env["general"]["tutorialMode"])
self.set_tutorial_mode(not self.is_tutorial_mode())
def set_tutorial_mode(self, newTutorialMode):
if self.env["runtime"]["VmenuManager"].get_active():
return
self.env["general"]["tutorialMode"] = newTutorialMode
if newTutorialMode:
def set_tutorial_mode(self, tutorial_mode):
if tutorial_mode == self.is_tutorial_mode():
return True
if tutorial_mode and self.env["runtime"]["VmenuManager"].get_active():
return False
if tutorial_mode:
self.bindings_backup = self.env["bindings"].copy()
self.raw_bindings_backup = copy.deepcopy(
self.env["rawBindings"]
)
self.create_help_dict()
self.env["bindings"][
str([1, ["KEY_ESC"]])
] = "TOGGLE_TUTORIAL_MODE"
self.env["bindings"][str([1, ["KEY_UP"]])] = "PREV_HELP"
self.env["bindings"][str([1, ["KEY_DOWN"]])] = "NEXT_HELP"
self.env["bindings"][str([1, ["KEY_SPACE"]])] = "CURR_HELP"
else:
try:
self.env["bindings"] = self.env["runtime"][
"SettingsManager"
].get_binding_backup()
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
"HelpManager set_tutorial_mode: Error restoring binding backup: "
+ str(e),
debug.DebugLevel.ERROR,
)
self.env["general"]["tutorialMode"] = True
self._install_help_bindings()
self._refresh_input_bindings()
self.capture_degraded = not self.env["runtime"][
"InputManager"
].set_help_capture(True)
return True
if not self.env["runtime"]["InputManager"].set_help_capture(False):
self.env["runtime"]["DebugManager"].write_debug_out(
"HelpManager could not release exclusive input capture",
debug.DebugLevel.ERROR,
)
return False
self._restore_bindings()
self.env["general"]["tutorialMode"] = False
self.capture_degraded = False
self.env["runtime"]["InputManager"].reset_input_state()
self._refresh_input_bindings()
return True
def _install_help_bindings(self):
help_bindings = {
str([1, ["KEY_ESC"]]): "TOGGLE_TUTORIAL_MODE",
str([1, ["KEY_UP"]]): "PREV_HELP",
str([1, ["KEY_DOWN"]]): "NEXT_HELP",
str([1, ["KEY_LEFT"]]): "PREV_HELP_SECTION",
str([1, ["KEY_RIGHT"]]): "NEXT_HELP_SECTION",
str([1, ["KEY_SPACE"]]): "CURR_HELP",
}
self.env["bindings"].update(help_bindings)
for shortcut in help_bindings:
self.env["rawBindings"][shortcut] = ast.literal_eval(shortcut)
def _restore_bindings(self):
if self.bindings_backup is not None:
self.env["bindings"] = self.bindings_backup
if self.raw_bindings_backup is not None:
self.env["rawBindings"] = self.raw_bindings_backup
self.bindings_backup = None
self.raw_bindings_backup = None
def _refresh_input_bindings(self):
try:
refresh_grabs = getattr(
self.env["runtime"]["InputDriver"], "refresh_grabs", None
)
if refresh_grabs:
refresh_grabs(force=True)
except Exception as error:
self.env["runtime"]["DebugManager"].write_debug_out(
"HelpManager could not refresh input bindings: "
+ str(error),
debug.DebugLevel.ERROR,
)
def is_tutorial_mode(self):
if self.env is None:
return False
return self.env["general"]["tutorialMode"]
def get_formatted_shortcut_for_command(self, command):
shortcut = []
raw_shortcut = []
try:
raw_shortcut = list(self.env["bindings"].keys())[
list(self.env["bindings"].values()).index(command)
]
raw_shortcut = self.env["rawBindings"][raw_shortcut]
# prefer numbers for multitap
if raw_shortcut[0] in range(2, 9):
formatted_key = str(raw_shortcut[0]) + " times "
shortcut.append(formatted_key)
# prefer metha keys
for k in [
"KEY_FENRIR",
"KEY_SCRIPT",
"KEY_CTRL",
"KEY_SHIFT",
"KEY_ALT",
"KEY_META",
]:
if k in raw_shortcut[1]:
formatted_key = k
formatted_key = formatted_key.lower()
formatted_key = formatted_key.replace("key_kp", " keypad ")
formatted_key = formatted_key.replace("key_", " ")
shortcut.append(formatted_key)
raw_shortcut[1].remove(k)
# handle other keys
for k in raw_shortcut[1]:
formatted_key = k
formatted_key = formatted_key.lower()
formatted_key = formatted_key.replace("key_kp", " keypad ")
formatted_key = formatted_key.replace("key_", " ")
shortcut.append(formatted_key)
except Exception as e:
return ""
shortcut = str(shortcut)
shortcut = shortcut.replace("[", "")
shortcut = shortcut.replace("]", "")
shortcut = shortcut.replace("'", "")
return shortcut
def is_capture_degraded(self):
return self.capture_degraded
def get_command_help_text(self, command, section="commands"):
command_name = command.lower()
command_name = command_name.split("__-__")[0]
command_name = command_name.replace("_", " ")
command_name = command_name.replace("_", " ")
def _get_shortcuts_by_command(self):
bindings = (
self.bindings_backup
if self.bindings_backup is not None
else self.env["bindings"]
)
raw_bindings = (
self.raw_bindings_backup
if self.raw_bindings_backup is not None
else self.env["rawBindings"]
)
shortcuts_by_command = {}
for shortcut_key, command in bindings.items():
raw_shortcut = raw_bindings.get(shortcut_key)
if raw_shortcut is None:
try:
raw_shortcut = ast.literal_eval(shortcut_key)
except (SyntaxError, ValueError):
continue
shortcuts_by_command.setdefault(command, []).append(
copy.deepcopy(raw_shortcut)
)
return shortcuts_by_command
def _format_shortcut(self, raw_shortcut):
shortcut_repeat, raw_keys = raw_shortcut
keys = list(raw_keys)
formatted_keys = []
repeat_prefix = ""
if shortcut_repeat in range(2, 9):
repeat_prefix = _("{} times ").format(shortcut_repeat)
for key_name in [
"KEY_FENRIR",
"KEY_SCRIPT",
"KEY_CTRL",
"KEY_SHIFT",
"KEY_ALT",
"KEY_META",
]:
if key_name in keys:
formatted_keys.append(self._format_key_name(key_name))
keys.remove(key_name)
formatted_keys.extend(self._format_key_name(key) for key in keys)
return repeat_prefix + ", ".join(formatted_keys)
def _format_key_name(self, key_name):
formatted_key = key_name.lower()
formatted_key = formatted_key.replace("key_kp", "keypad ")
formatted_key = formatted_key.replace("key_", "")
return _(formatted_key.strip())
def get_formatted_shortcut_for_command(self, command):
shortcuts = self._get_shortcuts_by_command().get(command, [])
return "; ".join(
self._format_shortcut(shortcut) for shortcut in shortcuts
)
def get_command_help_text(self, command, shortcuts=None):
command_name = command.lower().split("__-__")[0].replace("_", " ")
if command == "TOGGLE_TUTORIAL_MODE":
command_description = _("toggles the tutorial mode")
else:
@@ -104,45 +186,96 @@ class HelpManager:
"CommandManager"
].get_command_description(command, section="commands")
if command_description == "":
command_description = "no Description available"
command_shortcut = self.get_formatted_shortcut_for_command(command)
if command_shortcut == "":
command_shortcut = "unbound"
helptext = (
command_name
+ ", Shortcut "
+ command_shortcut
+ ", Description "
+ command_description
command_description = _("no description available")
if shortcuts is None:
shortcuts = self._get_shortcuts_by_command().get(command, [])
command_shortcuts = "; ".join(
self._format_shortcut(shortcut) for shortcut in shortcuts
)
if command_shortcuts == "":
command_shortcuts = _("unbound")
return _(
"{command_name}, Shortcuts {command_shortcuts}, Description "
"{command_description}"
).format(
command_name=command_name,
command_shortcuts=command_shortcuts,
command_description=command_description,
)
return helptext
def create_help_dict(self, section="commands"):
self.helpDict = {}
for command in sorted(self.env["commands"][section].keys()):
self.helpDict[len(self.helpDict)] = self.get_command_help_text(
command, section
)
if len(self.helpDict) > 0:
self.tutorialListIndex = 0
else:
self.tutorialListIndex = None
shortcuts_by_command = self._get_shortcuts_by_command()
self.help_lists = {
self.ACTIVE_SECTION: [],
self.UNBOUND_SECTION: [],
self.PLUGINS_SECTION: [],
}
for command in sorted(self.env["commands"][section]):
command_instance = self.env["commands"][section][command]
if not getattr(command_instance, "help_visible", True):
continue
shortcuts = shortcuts_by_command.get(command, [])
help_text = self.get_command_help_text(command, shortcuts)
if any("KEY_SCRIPT" in shortcut[1] for shortcut in shortcuts):
target_section = self.PLUGINS_SECTION
elif shortcuts:
target_section = self.ACTIVE_SECTION
else:
target_section = self.UNBOUND_SECTION
self.help_lists[target_section].append(help_text)
self.help_section = self.ACTIVE_SECTION
self.tutorial_list_index = None
def get_help_section_name(self):
if self.help_section == self.PLUGINS_SECTION:
return _("Plugins")
if self.help_section == self.UNBOUND_SECTION:
return _("Unbound actions")
return _("Active actions")
def select_help_section(self, section):
if section not in self.help_lists:
return self.get_help_section_name()
self.help_section = section
self.tutorial_list_index = None
return self.get_help_section_name()
def next_help_section(self):
section_index = self.HELP_SECTIONS.index(self.help_section)
section_index = (section_index + 1) % len(self.HELP_SECTIONS)
return self.select_help_section(self.HELP_SECTIONS[section_index])
def prev_help_section(self):
section_index = self.HELP_SECTIONS.index(self.help_section)
section_index = (section_index - 1) % len(self.HELP_SECTIONS)
return self.select_help_section(self.HELP_SECTIONS[section_index])
def get_help_for_current_index(self):
if self.tutorialListIndex is None:
return ""
return self.helpDict[self.tutorialListIndex]
entries = self.help_lists[self.help_section]
if self.tutorial_list_index is None or not entries:
return self.get_help_section_name()
return entries[self.tutorial_list_index]
def next_index(self):
if self.tutorialListIndex is None:
entries = self.help_lists[self.help_section]
if not entries:
self.tutorial_list_index = None
return
self.tutorialListIndex += 1
if self.tutorialListIndex >= len(self.helpDict):
self.tutorialListIndex = 0
if self.tutorial_list_index is None:
self.tutorial_list_index = 0
return
self.tutorial_list_index = (self.tutorial_list_index + 1) % len(
entries
)
def prev_index(self):
if self.tutorialListIndex is None:
entries = self.help_lists[self.help_section]
if not entries:
self.tutorial_list_index = None
return
self.tutorialListIndex -= 1
if self.tutorialListIndex < 0:
self.tutorialListIndex = len(self.helpDict) - 1
if self.tutorial_list_index is None:
self.tutorial_list_index = len(entries) - 1
return
self.tutorial_list_index = (self.tutorial_list_index - 1) % len(
entries
)
@@ -26,6 +26,10 @@ class InputDriver:
time.sleep(0.1)
return None
def is_active(self):
"""Return whether this input driver's target currently has focus."""
return True
def clear_event_buffer(self):
if not self._initialized:
return
@@ -54,6 +58,9 @@ class InputDriver:
return True
return True
def set_help_capture(self, enabled):
return True
def force_ungrab(self):
"""Emergency method to release grabbed devices in case of failure"""
if not self._initialized:
+38 -9
View File
@@ -18,6 +18,13 @@ currentdir = os.path.dirname(
)
fenrir_path = os.path.dirname(currentdir)
SHORTCUT_MODIFIER_KEYS = {
"KEY_ALT",
"KEY_CTRL",
"KEY_META",
"KEY_SHIFT",
}
class InputManager:
def __init__(self):
@@ -189,8 +196,6 @@ class InputManager:
self.env["input"]["curr_input"] = sorted(
self.env["input"]["curr_input"]
)
elif len(self.env["input"]["curr_input"]) == 0:
self.env["input"]["shortcut_repeat"] = 1
self.lastInputTime = time.time()
elif event_data["event_state"] == 1:
if not event_data["event_name"] in self.env["input"]["curr_input"]:
@@ -199,19 +204,18 @@ class InputManager:
self.env["input"]["curr_input"] = sorted(
self.env["input"]["curr_input"]
)
if len(self.lastDeepestInput) < len(
self.env["input"]["curr_input"]
):
self.set_last_deepest_input(
self.env["input"]["curr_input"].copy()
)
elif self.lastDeepestInput == self.env["input"]["curr_input"]:
if self.lastDeepestInput == self.env["input"]["curr_input"]:
if time.time() - self.lastInputTime <= self.env["runtime"][
"SettingsManager"
].get_setting_as_float("keyboard", "double_tap_timeout"):
self.env["input"]["shortcut_repeat"] += 1
else:
self.env["input"]["shortcut_repeat"] = 1
else:
self.env["input"]["shortcut_repeat"] = 1
self.set_last_deepest_input(
self.env["input"]["curr_input"].copy()
)
self.handle_led_states(event_data)
self.lastInputTime = time.time()
elif event_data["event_state"] == 2:
@@ -273,6 +277,18 @@ class InputManager:
return False
return True
def set_help_capture(self, enabled):
try:
return self.env["runtime"]["InputDriver"].set_help_capture(
enabled
)
except Exception as error:
self.env["runtime"]["DebugManager"].write_debug_out(
"InputManager could not change help capture: " + str(error),
debug.DebugLevel.ERROR,
)
return not enabled
def handle_plug_input_device(self, event_data):
for deviceEntry in event_data:
self.update_input_devices(deviceEntry["device"])
@@ -468,6 +484,16 @@ class InputManager:
self.env["input"]["curr_input"][0] == "KEY_SCRIPT"
)
def curr_input_has_command_modifier(self):
current_input = self.env["input"]["curr_input"]
return "KEY_FENRIR" in current_input or "KEY_SCRIPT" in current_input
def curr_input_is_modifier_prefix(self):
current_input = self.env["input"]["curr_input"]
return bool(current_input) and all(
key_name in SHORTCUT_MODIFIER_KEYS for key_name in current_input
)
def is_fenrir_key(self, event_name):
return event_name in self.env["input"]["fenrir_key"]
@@ -562,6 +588,9 @@ class InputManager:
self.env["bindings"][
str([1, ["KEY_F1", "KEY_FENRIR"]])
] = "TOGGLE_TUTORIAL_MODE"
self.env["rawBindings"][
str([1, ["KEY_F1", "KEY_FENRIR"]])
] = [1, ["KEY_F1", "KEY_FENRIR"]]
def is_valid_key(self, key):
return key in inputData.key_names
+8 -8
View File
@@ -133,16 +133,16 @@ class OutputManager:
"""
if not text:
return text
# Handle dots at the beginning of words (like .local, .bashrc, .config)
# Look for non-word character (or start of string), dot, then word characters
text = re.sub(r'(?<!\w)\.(\w+)', r'dot \1', text)
# Replace dots that appear between word characters with spoken form
# Use a loop to handle multiple consecutive dots like www.example.com or a.b.c.d
while re.search(r'\b\w+\.\w+\b', text):
text = re.sub(r'\b(\w+)\.(\w+)\b', r'\1 dot \2', text)
text = re.sub(r"(?<!\w)\.(\w+)", r"dot \1", text)
# Replace dots between word characters in one pass. The old loop
# rescanned the full speech string once per remaining dotted word,
# which made large directory listings block the event loop for seconds.
text = re.sub(r"(?<=\w)\.(?=\w)", " dot ", text)
return text
def speak_text(
+175 -313
View File
@@ -7,148 +7,16 @@
from fenrirscreenreader.core import debug
from fenrirscreenreader.core.i18n import _
from fenrirscreenreader.core.settingsData import settings_data
import subprocess
import time
class SpeechHelperMixin:
"""Helper methods for querying speech-dispatcher modules and voices.
Provides caching and query functionality for speech-dispatcher module
and voice enumeration, reusing proven logic from voice_browser.py.
"""
class QuickMenuManager:
def __init__(self):
self._modules_cache = None
self._voices_cache = {} # {module_name: [voice_list]}
self._cache_timestamp = 0
self._cache_timeout = 300 # 5 minutes
def get_speechd_modules(self):
"""Get available speech-dispatcher modules (cached).
Returns:
list: Available module names (e.g., ['espeak-ng', 'festival'])
"""
now = time.time()
# Return cached if valid
if (self._modules_cache and
(now - self._cache_timestamp) < self._cache_timeout):
return self._modules_cache
# Query spd-say
try:
result = subprocess.run(
["spd-say", "-O"],
capture_output=True,
text=True,
timeout=8
)
if result.returncode == 0:
lines = result.stdout.strip().split("\n")
self._modules_cache = [
line.strip() for line in lines[1:]
if line.strip() and line.strip().lower() != "dummy"
]
self._cache_timestamp = now
return self._modules_cache
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
(f"QuickMenuManager get_speechd_modules: "
f"Error querying modules: {e}"),
debug.DebugLevel.ERROR
)
return []
def get_module_voices(self, module):
"""Get voices for a specific module (cached per-module).
Args:
module (str): Module name (e.g., 'espeak-ng')
Returns:
list: Available voice names for this module
"""
# Return cached if available
if module in self._voices_cache:
return self._voices_cache[module]
# Query spd-say
try:
result = subprocess.run(
["spd-say", "-o", module, "-L"],
capture_output=True,
text=True,
timeout=8
)
if result.returncode == 0:
lines = result.stdout.strip().split("\n")
voices = []
for line in lines[1:]:
if not line.strip():
continue
if module.lower() == "espeak-ng":
voice = self._process_espeak_voice(line)
if voice:
voices.append(voice)
elif module.lower() == "voxin":
# For Voxin, store voice name with language
voice_data = self._process_voxin_voice(line)
if voice_data:
voices.append(voice_data)
else:
# For other modules, extract first field (voice name)
parts = line.strip().split()
if parts:
voices.append(parts[0])
self._voices_cache[module] = voices
return voices
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
(f"QuickMenuManager get_module_voices: "
f"Error querying voices for {module}: {e}"),
debug.DebugLevel.ERROR
)
return []
def _process_espeak_voice(self, voice_line):
"""Process espeak-ng voice format into usable voice name.
Args:
voice_line (str): Raw line from spd-say -L output
Returns:
str: Processed voice name (e.g., 'en-us' or 'en-us+f3')
"""
parts = [p for p in voice_line.split() if p]
if len(parts) < 2:
return None
lang_code = parts[-2].lower()
variant = parts[-1].lower()
return (f"{lang_code}+{variant}"
if variant and variant != "none" else lang_code)
def _process_voxin_voice(self, voice_line):
"""Process Voxin voice format with language information.
Args:
voice_line (str): Raw line from spd-say -o voxin -L output
Format: NAME LANGUAGE VARIANT
Returns:
str: Voice name with language encoded (e.g., 'daniel-embedded-high|en-GB')
"""
parts = [p for p in voice_line.split() if p]
if len(parts) < 2:
return None
voice_name = parts[0]
language = parts[1]
# Encode language with voice for later extraction
return f"{voice_name}|{language}"
self.position = 0
self.quickMenu = []
self.settings = settings_data
self._module_request_pending = False
self._voice_requests_pending = set()
def _select_default_voice(self, voices):
"""Select a sensible default voice from list, preferring user's
@@ -218,18 +86,7 @@ class SpeechHelperMixin:
return voices[0]
def invalidate_speech_cache(self):
"""Clear cached module and voice data."""
self._modules_cache = None
self._voices_cache = {}
self._cache_timestamp = 0
class QuickMenuManager(SpeechHelperMixin):
def __init__(self):
SpeechHelperMixin.__init__(self)
self.position = 0
self.quickMenu = []
self.settings = settings_data
self.env["runtime"]["SpeechDiscoveryManager"].invalidate_cache()
def initialize(self, environment):
self.env = environment
@@ -436,197 +293,202 @@ class QuickMenuManager(SpeechHelperMixin):
return True
def cycle_speech_module(self, direction):
"""Cycle to next/previous speech-dispatcher module.
if self._module_request_pending:
return False
discovery = self.env["runtime"]["SpeechDiscoveryManager"]
modules = discovery.get_cached_modules()
if modules is not None:
return self._continue_module_cycle(direction, modules, False)
Args:
direction (str): 'next' or 'prev'
Returns:
bool: True if successful, False otherwise
"""
try:
# Get available modules
modules = self.get_speechd_modules()
if not modules:
self.env["runtime"]["OutputManager"].present_text(
"No modules available", interrupt=True
)
return False
# Get current module
current_module = self.env["runtime"]["SettingsManager"].get_setting(
"speech", "module"
self._module_request_pending = True
self.env["runtime"]["OutputManager"].present_text(
"Loading speech modules", interrupt=True
)
discovery.request_modules(
lambda found, error: self._continue_module_cycle(
direction, found, True, error
)
)
return False
# Find current index
try:
current_index = (modules.index(current_module)
if current_module else 0)
except ValueError:
current_index = 0
# Cycle to next/previous
if direction == "next":
new_index = (current_index + 1) % len(modules)
else: # prev
new_index = (current_index - 1) % len(modules)
new_module = modules[new_index]
# Update setting (runtime only)
self.env["runtime"]["SettingsManager"].set_setting(
"speech", "module", new_module
)
# Select sensible default voice for new module
voices = self.get_module_voices(new_module)
if voices:
default_voice = self._select_default_voice(voices)
# Parse voice name and language for modules like Voxin
voice_name = default_voice
voice_lang = None
if "|" in default_voice:
voice_name, voice_lang = default_voice.split("|", 1)
self.env["runtime"]["SettingsManager"].set_setting(
"speech", "voice", voice_name
)
# Apply voice to speech driver immediately
if "SpeechDriver" in self.env["runtime"]:
try:
self.env["runtime"]["SpeechDriver"].set_module(
new_module
)
# Set language first if available
if voice_lang:
self.env["runtime"]["SpeechDriver"].set_language(
voice_lang
)
# Then set voice
self.env["runtime"]["SpeechDriver"].set_voice(
voice_name
)
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
(f"QuickMenuManager cycle_speech_module: "
f"Error applying voice: {e}"),
debug.DebugLevel.ERROR
)
# Announce new module
def _continue_module_cycle(
self, direction, modules, announce_result, error=""
):
if error:
self._module_request_pending = False
self._report_discovery_error("module", error)
return False
if not modules:
self._module_request_pending = False
self.env["runtime"]["OutputManager"].present_text(
new_module, interrupt=True
)
return True
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
f"QuickMenuManager cycle_speech_module: Error: {e}",
debug.DebugLevel.ERROR
"No modules available", interrupt=True
)
return False
def cycle_speech_voice(self, direction):
"""Cycle to next/previous voice for current module.
Args:
direction (str): 'next' or 'prev'
Returns:
bool: True if successful, False otherwise
"""
try:
# Get current module
current_module = self.env["runtime"]["SettingsManager"].get_setting(
"speech", "module"
)
try:
current_index = (
modules.index(current_module) if current_module else 0
)
except ValueError:
current_index = 0
if direction == "next":
new_index = (current_index + 1) % len(modules)
else:
new_index = (current_index - 1) % len(modules)
new_module = modules[new_index]
discovery = self.env["runtime"]["SpeechDiscoveryManager"]
voices = discovery.get_cached_voices(new_module)
if voices is not None:
self._module_request_pending = False
return self._apply_module(
new_module, voices, announce_result
)
self._module_request_pending = True
discovery.request_voices(
new_module,
lambda _module, found, voice_error: self._finish_module_cycle(
new_module, found, voice_error
),
)
return False
except Exception as error:
self._module_request_pending = False
self.env["runtime"]["DebugManager"].write_debug_out(
f"QuickMenuManager cycle_speech_module: Error: {error}",
debug.DebugLevel.ERROR,
)
return False
def _finish_module_cycle(self, module, voices, error):
self._module_request_pending = False
if error:
self.env["runtime"]["DebugManager"].write_debug_out(
f"QuickMenuManager voice discovery failed: {error}",
debug.DebugLevel.ERROR,
)
self._apply_module(module, voices, False)
self.env["runtime"]["OutputManager"].present_text(
f"{module}; voice discovery failed", interrupt=True
)
return
self._apply_module(module, voices, True)
def _apply_module(self, module, voices, announce_result):
settings_manager = self.env["runtime"]["SettingsManager"]
settings_manager.set_setting("speech", "module", module)
speech_driver = self.env["runtime"].get("SpeechDriver")
if speech_driver:
speech_driver.set_module(module)
if voices:
default_voice = self._select_default_voice(voices)
voice_name, voice_language = self._split_voice(default_voice)
settings_manager.set_setting("speech", "voice", voice_name)
if speech_driver:
if voice_language:
speech_driver.set_language(voice_language)
speech_driver.set_voice(voice_name)
if announce_result:
self.env["runtime"]["OutputManager"].present_text(
module, interrupt=True
)
return True
def cycle_speech_voice(self, direction):
try:
current_module = self.env["runtime"]["SettingsManager"].get_setting(
"speech", "module"
)
if not current_module:
self.env["runtime"]["OutputManager"].present_text(
"No module selected", interrupt=True
)
return False
# Get available voices for this module
voices = self.get_module_voices(current_module)
if not voices:
self.env["runtime"]["OutputManager"].present_text(
f"No voices for module {current_module}", interrupt=True
)
discovery = self.env["runtime"]["SpeechDiscoveryManager"]
voices = discovery.get_cached_voices(current_module)
if voices is not None:
return self._apply_voice(direction, voices, False)
if current_module in self._voice_requests_pending:
return False
# Get current voice
current_voice = self.env["runtime"]["SettingsManager"].get_setting(
"speech", "voice"
self._voice_requests_pending.add(current_module)
self.env["runtime"]["OutputManager"].present_text(
f"Loading voices for {current_module}", interrupt=True
)
# Find current index (handle Voxin voice|language format)
current_index = 0
if current_voice:
try:
# Try exact match first
current_index = voices.index(current_voice)
except ValueError:
# For Voxin, compare just the voice name part
for i, voice in enumerate(voices):
voice_name = voice.split("|")[0] if "|" in voice else voice
if voice_name == current_voice:
current_index = i
break
# Cycle to next/previous
if direction == "next":
new_index = (current_index + 1) % len(voices)
else: # prev
new_index = (current_index - 1) % len(voices)
new_voice = voices[new_index]
# Parse voice name and language for modules like Voxin
voice_name = new_voice
voice_lang = None
if "|" in new_voice:
# Format: "voicename|language" (e.g., "daniel-embedded-high|en-GB")
voice_name, voice_lang = new_voice.split("|", 1)
# Update setting (runtime only) - store the voice name only
self.env["runtime"]["SettingsManager"].set_setting(
"speech", "voice", voice_name
discovery.request_voices(
current_module,
lambda module, found, error: self._finish_voice_cycle(
direction, module, found, error
),
)
return False
except Exception as error:
self.env["runtime"]["DebugManager"].write_debug_out(
f"QuickMenuManager cycle_speech_voice: Error: {error}",
debug.DebugLevel.ERROR,
)
return False
# Apply voice to speech driver immediately
if "SpeechDriver" in self.env["runtime"]:
try:
# Set language first if available
if voice_lang:
self.env["runtime"]["SpeechDriver"].set_language(
voice_lang
)
# Then set voice
self.env["runtime"]["SpeechDriver"].set_voice(voice_name)
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
(f"QuickMenuManager cycle_speech_voice: "
f"Error applying voice: {e}"),
debug.DebugLevel.ERROR
)
def _finish_voice_cycle(self, direction, module, voices, error):
self._voice_requests_pending.discard(module)
if error:
self._report_discovery_error("voice", error)
return
self._apply_voice(direction, voices, True)
# Announce new voice (voice name only, not language)
def _apply_voice(self, direction, voices, announce_result):
if not voices:
self.env["runtime"]["OutputManager"].present_text(
"No voices available", interrupt=True
)
return False
settings_manager = self.env["runtime"]["SettingsManager"]
current_voice = settings_manager.get_setting("speech", "voice")
current_index = 0
for voice_index, voice in enumerate(voices):
voice_name, _language = self._split_voice(voice)
if voice == current_voice or voice_name == current_voice:
current_index = voice_index
break
if direction == "next":
new_index = (current_index + 1) % len(voices)
else:
new_index = (current_index - 1) % len(voices)
voice_name, voice_language = self._split_voice(voices[new_index])
settings_manager.set_setting("speech", "voice", voice_name)
speech_driver = self.env["runtime"].get("SpeechDriver")
if speech_driver:
if voice_language:
speech_driver.set_language(voice_language)
speech_driver.set_voice(voice_name)
if announce_result:
self.env["runtime"]["OutputManager"].present_text(
voice_name, interrupt=True
)
return True
return True
@staticmethod
def _split_voice(voice):
if "|" in voice:
return tuple(voice.split("|", 1))
return voice, None
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
f"QuickMenuManager cycle_speech_voice: Error: {e}",
debug.DebugLevel.ERROR
)
return False
def _report_discovery_error(self, kind, error):
self.env["runtime"]["DebugManager"].write_debug_out(
f"QuickMenuManager {kind} discovery failed: {error}",
debug.DebugLevel.ERROR,
)
self.env["runtime"]["OutputManager"].present_text(
f"{kind.capitalize()} discovery failed", interrupt=True
)
def get_current_entry(self):
if len(self.quickMenu) == 0:
@@ -12,6 +12,7 @@ import time
INSTANCE_TIMEOUT_SEC = 30.0
REGISTRY_DIR_MODE = 0o1777
def get_registry_dir():
@@ -26,8 +27,8 @@ def get_instance_file(pid=None):
def write_instance(instance_data):
registry_dir = get_registry_dir()
os.makedirs(registry_dir, mode=0o755, exist_ok=True)
os.chmod(registry_dir, 0o755)
os.makedirs(registry_dir, mode=REGISTRY_DIR_MODE, exist_ok=True)
os.chmod(registry_dir, REGISTRY_DIR_MODE)
prune_stale_instances()
instance_data["updated_at"] = time.time()
instance_path = get_instance_file(instance_data.get("pid"))
+1 -1
View File
@@ -422,7 +422,7 @@ class RemoteManager:
def export_clipboard(self):
clipboard_file_path = self.env["runtime"][
"SettingsManager"
].get_setting("general", "clipboard_export_path")
].get_setting("clipboard", "clipboard_export_path")
clipboard_file_path = clipboard_file_path.replace(
"$user", self.env["general"]["curr_user"]
)
@@ -14,6 +14,7 @@ runtime_data = {
"RemoteDriver": None,
"InputManager": None,
"CommandManager": None,
"ClipboardSyncManager": None,
"ScreenManager": None,
"OutputManager": None,
"SpeechHistoryManager": None,
@@ -21,6 +22,8 @@ runtime_data = {
"SettingsManager": None,
"FenrirManager": None,
"EventManager": None,
"BackgroundTaskManager": None,
"ProcessManager": None,
"SpeechDiscoveryManager": None,
"DiffReviewManager": None,
}
+18 -5
View File
@@ -12,8 +12,13 @@ settings_data = {
"driver": "genericDriver",
"theme": "default",
"volume": 1.0,
"generic_play_file_command": "play -q -v fenrirVolume fenrirSoundFile",
"generic_frequency_command": "play -q -v fenrirVolume -n -c1 synth fenrirDuration sine fenrirFrequence",
"generic_play_file_command": (
"play -q -v fenrir_volume fenrir_sound_file"
),
"generic_frequency_command": (
"play -q -v fenrir_volume -n -c1 synth "
"fenrir_duration sine fenrir_frequency"
),
"progress_monitoring": True,
},
"speech": {
@@ -38,7 +43,10 @@ settings_data = {
"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 fenrir_volume -s fenrir_rate -p fenrir_pitch "
'-v fenrir_voice "fenrir_text"'
),
"fenrir_min_volume": 0,
"fenrir_max_volume": 200,
"fenrir_min_pitch": 0,
@@ -62,8 +70,6 @@ settings_data = {
"respect_punctuation_pause": True,
"replace_undefined_punctuation_with_space": True,
"new_line_pause": True,
"number_of_clipboards": 10,
"clipboard_export_path": "/tmp/fenrirClipboard",
"emoticons": True,
"fenrir_keys": "KEY_KP0,KEY_META",
"script_keys": "KEY_COMPOSE",
@@ -81,6 +87,13 @@ settings_data = {
"diff_presentation": "both",
"diff_verbosity": "compact",
},
"clipboard": {
"number_of_clipboards": 10,
"clipboard_export_path": "/tmp/fenrirClipboard",
"sync_enabled": False,
"sync_display": "",
"sync_interval": 0.5,
},
"focus": {
"cursor": True,
"highlight": False,
@@ -6,11 +6,14 @@
import inspect
import os
from argparse import Namespace
from configparser import ConfigParser
from fenrirscreenreader.core import applicationManager
from fenrirscreenreader.core import attributeManager
from fenrirscreenreader.core import barrierManager
from fenrirscreenreader.core import backgroundTaskManager
from fenrirscreenreader.core import clipboardSyncManager
from fenrirscreenreader.core import commandManager
from fenrirscreenreader.core import cursorManager
from fenrirscreenreader.core import debug
@@ -30,6 +33,7 @@ from fenrirscreenreader.core import remoteManager
from fenrirscreenreader.core import sayAllManager
from fenrirscreenreader.core import screenManager
from fenrirscreenreader.core import speechHistoryManager
from fenrirscreenreader.core import speechDiscoveryManager
from fenrirscreenreader.core import tableManager
from fenrirscreenreader.core import textManager
from fenrirscreenreader.core import vmenuManager
@@ -67,6 +71,15 @@ class SettingsManager:
def shutdown(self):
pass
def format_cli_args(self, cliArgs):
if cliArgs is None:
return "{}"
if isinstance(cliArgs, Namespace):
args = vars(cliArgs)
else:
args = vars(cliArgs) if hasattr(cliArgs, "__dict__") else {}
return str({key: args[key] for key in sorted(args)})
def get_binding_backup(self):
return self.bindingsBackup.copy()
@@ -510,6 +523,7 @@ class SettingsManager:
"speechdDriver",
"genericDriver",
"dectalkDriver",
"dectalkUsbDriver",
"doubletalkDriver",
"litetalkDriver",
"tripletalkDriver",
@@ -644,6 +658,11 @@ class SettingsManager:
)
)
environment["runtime"]["DebugManager"].initialize(environment)
environment["runtime"]["DebugManager"].write_debug_out(
"Fenrir startup CLI arguments: " + self.format_cli_args(cliArgs),
debug.DebugLevel.INFO,
on_any_level=True,
)
if cliArgs.force_all_screens:
environment["runtime"]["force_all_screens"] = True
@@ -736,6 +755,11 @@ class SettingsManager:
] = processManager.ProcessManager()
environment["runtime"]["ProcessManager"].initialize(environment)
environment["runtime"][
"BackgroundTaskManager"
] = backgroundTaskManager.BackgroundTaskManager()
environment["runtime"]["BackgroundTaskManager"].initialize(environment)
environment["runtime"]["OutputManager"] = outputManager.OutputManager()
environment["runtime"]["OutputManager"].initialize(environment)
@@ -756,6 +780,13 @@ class SettingsManager:
] = commandManager.CommandManager()
environment["runtime"]["CommandManager"].initialize(environment)
environment["runtime"][
"ClipboardSyncManager"
] = clipboardSyncManager.ClipboardSyncManager()
environment["runtime"]["ClipboardSyncManager"].initialize(
environment
)
environment["runtime"]["HelpManager"] = helpManager.HelpManager()
environment["runtime"]["HelpManager"].initialize(environment)
@@ -783,6 +814,10 @@ class SettingsManager:
environment["runtime"]["BarrierManager"].initialize(environment)
environment["runtime"]["SayAllManager"] = sayAllManager.SayAllManager()
environment["runtime"]["SayAllManager"].initialize(environment)
environment["runtime"][
"SpeechDiscoveryManager"
] = speechDiscoveryManager.SpeechDiscoveryManager()
environment["runtime"]["SpeechDiscoveryManager"].initialize(environment)
environment["runtime"]["VmenuManager"] = vmenuManager.VmenuManager()
environment["runtime"]["VmenuManager"].initialize(environment)
environment["runtime"][
@@ -0,0 +1,241 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
import subprocess
import time
from fenrirscreenreader.core import debug
from fenrirscreenreader.utils.speechd_utils import (
get_synthesis_voice_name,
parse_synthesis_voice_line,
)
MODULE_QUERY_TIMEOUT = 5
VOICE_QUERY_TIMEOUT = 8
VOICE_TEST_TIMEOUT = 8
def query_speechd_modules(timeout=MODULE_QUERY_TIMEOUT):
"""Return available Speech Dispatcher output modules."""
result = subprocess.run(
["spd-say", "-O"],
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
if result.returncode != 0:
raise RuntimeError(result.stderr.strip() or "module discovery failed")
return [
line.strip()
for line in result.stdout.splitlines()[1:]
if line.strip() and line.strip().lower() != "dummy"
]
def query_speechd_voices(module, timeout=VOICE_QUERY_TIMEOUT):
"""Return the synthesis voices exposed by one output module."""
result = subprocess.run(
["spd-say", "-o", module, "-L"],
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
if result.returncode != 0:
raise RuntimeError(
result.stderr.strip() or f"voice discovery failed for {module}"
)
voices = []
for line in result.stdout.splitlines()[1:]:
if not line.strip():
continue
voice = get_synthesis_voice_name(module, line)
if module.lower() == "voxin":
voice_data = parse_synthesis_voice_line(line)
if voice_data is not None:
voice_name, language, _variant = voice_data
voice = f"{voice_name}|{language}" if language else None
if voice:
voices.append(voice)
return voices
def test_speechd_voice(module, voice, message, timeout=VOICE_TEST_TIMEOUT):
"""Play one bounded voice test and return when Speech Dispatcher finishes."""
result = subprocess.run(
["spd-say", "-C", "-w", "-o", module, "-y", voice, message],
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
if result.returncode != 0:
raise RuntimeError(result.stderr.strip() or "voice test failed")
return True
class SpeechDiscoveryManager:
"""Cache and coordinate asynchronous Speech Dispatcher discovery."""
def __init__(self):
self.env = None
self.cache_timeout = 300
self._modules = None
self._modules_timestamp = 0
self._voices = {}
self._voice_timestamps = {}
self._module_callbacks = []
self._voice_callbacks = {}
self._task_ids = set()
self._module_task_id = None
self._voice_task_ids = {}
def initialize(self, environment):
self.env = environment
def shutdown(self):
task_manager = self.env["runtime"].get("BackgroundTaskManager")
if task_manager:
for task_id in self._task_ids:
task_manager.cancel_task(task_id)
self._task_ids.clear()
self._module_callbacks = []
self._voice_callbacks = {}
def get_cached_modules(self):
if not self._cache_is_current(self._modules_timestamp):
return None
return list(self._modules) if self._modules is not None else None
def get_cached_voices(self, module):
if not self._cache_is_current(self._voice_timestamps.get(module, 0)):
return None
voices = self._voices.get(module)
return list(voices) if voices is not None else None
def request_modules(self, callback, refresh=False):
modules = None if refresh else self.get_cached_modules()
if modules is not None:
callback(modules, "")
return None
self._module_callbacks.append(callback)
if self._module_task_id is not None:
return self._module_task_id
task_id = self._submit_task(query_speechd_modules, self._finish_modules)
if task_id is None:
self._finish_modules(self._submission_failure())
return None
self._module_task_id = task_id
return task_id
def request_voices(self, module, callback, refresh=False):
voices = None if refresh else self.get_cached_voices(module)
if voices is not None:
callback(module, voices, "")
return None
callbacks = self._voice_callbacks.setdefault(module, [])
callbacks.append(callback)
if module in self._voice_task_ids:
return self._voice_task_ids[module]
task_id = self._submit_task(
query_speechd_voices,
lambda result: self._finish_voices(module, result),
module,
)
if task_id is None:
self._finish_voices(module, self._submission_failure())
return None
self._voice_task_ids[module] = task_id
return task_id
def request_voice_test(self, module, voice, message, callback):
task_id = self._submit_task(
test_speechd_voice,
lambda result: callback(
bool(result.get("succeeded")), result.get("error", "")
),
module,
voice,
message,
)
if task_id is None:
callback(False, "background task manager is unavailable")
return task_id
def invalidate_cache(self):
self._modules = None
self._modules_timestamp = 0
self._voices = {}
self._voice_timestamps = {}
def _cache_is_current(self, timestamp):
return timestamp > 0 and time.time() - timestamp < self.cache_timeout
def _submit_task(self, function, callback, *args):
task_manager = self.env["runtime"]["BackgroundTaskManager"]
def finish(result):
self._task_ids.discard(result.get("task_id"))
callback(result)
task_id = task_manager.submit_task(function, finish, *args)
if task_id is None:
self.env["runtime"]["DebugManager"].write_debug_out(
"SpeechDiscoveryManager could not submit background task",
debug.DebugLevel.ERROR,
)
return None
self._task_ids.add(task_id)
return task_id
@staticmethod
def _submission_failure():
return {
"task_id": None,
"succeeded": False,
"value": None,
"error": "background task manager is unavailable",
}
def _finish_modules(self, result):
self._module_task_id = None
callbacks = self._module_callbacks
self._module_callbacks = []
error = result.get("error", "")
modules = []
if result.get("succeeded"):
modules = list(result.get("value") or [])
self._modules = modules
self._modules_timestamp = time.time()
self._run_callbacks(callbacks, list(modules), error)
def _finish_voices(self, module, result):
self._voice_task_ids.pop(module, None)
callbacks = self._voice_callbacks.pop(module, [])
error = result.get("error", "")
voices = []
if result.get("succeeded"):
voices = list(result.get("value") or [])
self._voices[module] = voices
self._voice_timestamps[module] = time.time()
self._run_callbacks(callbacks, module, list(voices), error)
def _run_callbacks(self, callbacks, *args):
for callback in callbacks:
try:
callback(*args)
except Exception as error:
self.env["runtime"]["DebugManager"].write_debug_out(
"SpeechDiscoveryManager callback failed: " + str(error),
debug.DebugLevel.ERROR,
)
@@ -132,13 +132,6 @@ class TabCompletionManager:
if candidate_text:
return self._clean_text(candidate_text)
delta_text = self.env["screen"]["new_delta"]
if (
delta_text
and not self.env["screen"].get("new_delta_is_typing", False)
):
return self._clean_text(delta_text)
return ""
def _get_cursor_line_inserted_text(
@@ -184,26 +177,19 @@ class TabCompletionManager:
return "".join(inserted_parts)
def _get_candidate_text(self, old_lines, new_lines, cursor_y):
if len(old_lines) != len(new_lines):
return self._get_inserted_lines(old_lines, new_lines, cursor_y)
changed_lines = []
old_cursor_line = (
old_lines[cursor_y].strip() if cursor_y < len(old_lines) else ""
)
for index, old_line in enumerate(old_lines):
if index == cursor_y:
continue
if index < len(new_lines) and old_line != new_lines[index]:
if new_lines[index].strip() == old_cursor_line:
continue
changed_lines.append(new_lines[index])
return "\n".join(
line.rstrip() for line in changed_lines if line.strip()
return self._get_inserted_lines(
old_lines,
new_lines,
self.env["screen"]["new_cursor"]["y"],
old_cursor_line,
)
def _get_inserted_lines(self, old_lines, new_lines, cursor_y):
def _get_inserted_lines(
self, old_lines, new_lines, new_cursor_y, old_cursor_line
):
matcher = difflib.SequenceMatcher(
None, old_lines, new_lines, autojunk=False
)
@@ -217,10 +203,15 @@ class TabCompletionManager:
) in matcher.get_opcodes():
if tag not in ["insert", "replace"]:
continue
if new_end <= cursor_y:
if new_start > new_cursor_y:
continue
if tag == "replace" and any(
line.strip() for line in old_lines[old_start:old_end]
):
continue
for line in new_lines[new_start:new_end]:
if line.strip():
stripped_line = line.strip()
if stripped_line and stripped_line != old_cursor_line:
inserted_lines.append(line.rstrip())
return "\n".join(inserted_lines)
+2 -2
View File
@@ -4,5 +4,5 @@
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
version = "2026.05.24"
code_name = "master"
version = "2026.08.21"
code_name = "testing"
@@ -61,20 +61,31 @@ class driver(inputDriver):
"Escape": "KEY_ESC",
"space": "KEY_SPACE",
"minus": "KEY_MINUS",
"-": "KEY_MINUS",
"underscore": "KEY_MINUS",
"_": "KEY_MINUS",
"equal": "KEY_EQUAL",
"=": "KEY_EQUAL",
"plus": "KEY_EQUAL",
"+": "KEY_EQUAL",
"bracketleft": "KEY_LEFTBRACE",
"[": "KEY_LEFTBRACE",
"bracketright": "KEY_RIGHTBRACE",
"]": "KEY_RIGHTBRACE",
"backslash": "KEY_BACKSLASH",
"\\": "KEY_BACKSLASH",
"semicolon": "KEY_SEMICOLON",
";": "KEY_SEMICOLON",
"apostrophe": "KEY_APOSTROPHE",
"'": "KEY_APOSTROPHE",
"grave": "KEY_GRAVE",
"`": "KEY_GRAVE",
"comma": "KEY_COMMA",
",": "KEY_COMMA",
"period": "KEY_DOT",
".": "KEY_DOT",
"slash": "KEY_SLASH",
"/": "KEY_SLASH",
"Shift_L": "KEY_LEFTSHIFT",
"Shift_R": "KEY_RIGHTSHIFT",
"Control_L": "KEY_LEFTCTRL",
@@ -148,6 +159,13 @@ class driver(inputDriver):
modifier_key_names = set(modifier_masks.keys())
canonical_modifier_masks = (
("KEY_SHIFT", X.ShiftMask if X else 1),
("KEY_CTRL", X.ControlMask if X else 4),
("KEY_ALT", X.Mod1Mask if X else 8),
("KEY_META", X.Mod4Mask if X else 64),
)
def __init__(self):
inputDriver.__init__(self)
self.display = None
@@ -162,6 +180,11 @@ class driver(inputDriver):
self.fenrir_keys = set()
self.failed_grabs = 0
self.modifier_state = 0
self.modifier_interrupt_state = 0
self.command_key_active = False
self.chord_modifiers = set()
self.chord_keys = set()
self.help_keyboard_grabbed = False
def initialize(self, environment):
self.env = environment
@@ -194,6 +217,7 @@ class driver(inputDriver):
)
self.num_lock_mask = self.find_num_lock_mask()
self.refresh_modifier_state()
self.modifier_interrupt_state = self.modifier_state
self.refresh_interesting_keys()
self.refresh_grabs(force=True)
self.env["runtime"]["ProcessManager"].add_custom_event_thread(
@@ -202,6 +226,7 @@ class driver(inputDriver):
self._initialized = True
def shutdown(self):
self.set_help_capture(False)
self.ungrab_all_devices()
try:
if self.display:
@@ -242,6 +267,9 @@ class driver(inputDriver):
return None
return int(prop.value[0])
def is_active(self):
return self.active
def format_window_id(self, window_id):
if window_id is None:
return "None"
@@ -274,6 +302,7 @@ class driver(inputDriver):
while active.value:
try:
self.refresh_grabs()
self.poll_modifier_interrupt_keys()
if not self.display.pending_events():
time.sleep(0.01)
continue
@@ -294,6 +323,10 @@ class driver(inputDriver):
def handle_x_event(self, event, event_queue):
event_type = getattr(event, "type", None)
if event_type in [X.FocusIn, X.FocusOut] and getattr(
event, "mode", X.NotifyNormal
) in [X.NotifyGrab, X.NotifyUngrab]:
return
if event_type == X.FocusIn:
self.active = True
self.clear_event_buffer()
@@ -301,6 +334,9 @@ class driver(inputDriver):
if event_type == X.FocusOut:
self.active = False
self.reset_input_state()
output_manager = self.env["runtime"].get("OutputManager")
if output_manager:
output_manager.interrupt_output_async()
return
if event_type not in [X.KeyPress, X.KeyRelease]:
return
@@ -313,6 +349,12 @@ class driver(inputDriver):
if not self.should_emit_key(key_name):
return
self.update_modifier_state_from_event(input_event)
is_command_key = self.is_command_key(key_name)
if is_command_key and input_event["event_state"] == 1:
self.start_command_chord(input_event, event_queue)
elif self.command_key_active and not is_command_key:
self.track_chord_key(input_event)
self.track_chord_modifier(input_event)
self.write_debug(
"x11Driver key event "
+ key_name
@@ -329,6 +371,86 @@ class driver(inputDriver):
"data": input_event,
}
)
if is_command_key and input_event["event_state"] == 0:
self.finish_command_chord(input_event, event_queue)
def is_command_key(self, key_name):
if key_name in self.fenrir_keys or key_name in self.env["input"].get(
"script_key", []
):
return True
converted_name = self.env["runtime"][
"InputManager"
].convert_event_name(key_name)
return converted_name in {"KEY_FENRIR", "KEY_SCRIPT"}
def start_command_chord(self, input_event, event_queue):
self.command_key_active = True
self.chord_keys.clear()
raw_state = input_event.get("event_raw_state", 0)
current_input = self.env["input"]["curr_input"]
for key_name, modifier_mask in self.canonical_modifier_masks:
if not raw_state & modifier_mask:
continue
self.chord_modifiers.add(key_name)
if key_name not in current_input:
self.queue_synthetic_key(
key_name, 1, input_event, event_queue
)
def track_chord_modifier(self, input_event):
key_name = self.canonical_modifier_name(input_event["event_name"])
if key_name is None:
return
if input_event["event_state"] == 1:
self.chord_modifiers.add(key_name)
elif input_event["event_state"] == 0:
self.chord_modifiers.discard(key_name)
def track_chord_key(self, input_event):
key_name = input_event["event_name"]
if key_name in self.modifier_key_names:
return
if input_event["event_state"] == 1:
self.chord_keys.add(key_name)
elif input_event["event_state"] == 0:
self.chord_keys.discard(key_name)
def canonical_modifier_name(self, key_name):
modifier_mask = self.modifier_masks.get(key_name, 0)
for canonical_name, canonical_mask in self.canonical_modifier_masks:
if modifier_mask == canonical_mask:
return canonical_name
return None
def finish_command_chord(self, input_event, event_queue):
for key_name in sorted(self.chord_keys):
self.queue_synthetic_key(key_name, 0, input_event, event_queue)
self.chord_keys.clear()
for key_name in sorted(self.chord_modifiers):
self.queue_synthetic_key(key_name, 0, input_event, event_queue)
self.chord_modifiers.clear()
self.command_key_active = False
def queue_synthetic_key(
self, key_name, event_state, source_event, event_queue
):
synthetic_event = {
"event_name": key_name,
"event_value": 0,
"event_sec": source_event["event_sec"],
"event_usec": source_event["event_usec"],
"event_state": event_state,
"event_type": 0,
"event_raw_state": source_event.get("event_raw_state", 0),
"event_x_time": source_event.get("event_x_time", X.CurrentTime),
}
event_queue.put(
{
"Type": FenrirEventType.keyboard_input,
"data": synthetic_event,
}
)
def event_is_in_target_tree(self, event):
event_window = getattr(event, "event", None)
@@ -371,6 +493,56 @@ class driver(inputDriver):
"event_x_time": getattr(event, "time", X.CurrentTime),
}
def poll_modifier_interrupt_keys(self):
if not self.active or not self.should_poll_modifier_interrupt_keys():
return
try:
pointer = self.root.query_pointer()
current_state = getattr(pointer, "mask", 0)
except Exception:
return
previous_state = self.modifier_interrupt_state
self.modifier_interrupt_state = current_state
self.modifier_state = current_state
for key_name, modifier_mask in self.interrupt_modifier_masks():
if current_state & modifier_mask and not previous_state & modifier_mask:
self.interrupt_output_on_modifier_key(key_name)
def should_poll_modifier_interrupt_keys(self):
try:
settings_manager = self.env["runtime"]["SettingsManager"]
except Exception:
return False
if not settings_manager.get_setting_as_bool(
"keyboard", "interrupt_on_key_press"
):
return False
return (
settings_manager.get_setting(
"keyboard", "interrupt_on_key_press_filter"
).strip()
== ""
)
def interrupt_modifier_masks(self):
return [
("KEY_CTRL", X.ControlMask),
("KEY_SHIFT", X.ShiftMask),
("KEY_ALT", X.Mod1Mask),
]
def interrupt_output_on_modifier_key(self, key_name):
try:
self.env["runtime"]["OutputManager"].interrupt_output_async()
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
"x11Driver modifier interrupt failed for "
+ key_name
+ ": "
+ str(e),
debug.DebugLevel.ERROR,
)
def refresh_modifier_state(self):
try:
pointer = self.root.query_pointer()
@@ -645,6 +817,9 @@ class driver(inputDriver):
return []
def reset_input_state(self):
self.command_key_active = False
self.chord_modifiers.clear()
self.chord_keys.clear()
try:
self.env["runtime"]["InputManager"].reset_input_state()
except Exception:
@@ -705,6 +880,54 @@ class driver(inputDriver):
pass
return True
def set_help_capture(self, enabled):
if not self._initialized or not self.display or not self.window:
return not enabled
if enabled:
if self.help_keyboard_grabbed:
return True
try:
grab_status = self.window.grab_keyboard(
False,
X.GrabModeAsync,
X.GrabModeAsync,
X.CurrentTime,
)
self.display.flush()
self.help_keyboard_grabbed = grab_status == X.GrabSuccess
if not self.help_keyboard_grabbed:
self.write_debug(
"x11Driver help keyboard grab failed with status "
+ str(grab_status),
debug.DebugLevel.WARNING,
)
return self.help_keyboard_grabbed
except Exception as error:
self.help_keyboard_grabbed = False
self.write_debug(
"x11Driver help keyboard grab failed: " + str(error),
debug.DebugLevel.ERROR,
)
return False
if self.help_keyboard_grabbed:
for attempt in range(1, 4):
try:
self.display.ungrab_keyboard(X.CurrentTime)
self.display.sync()
break
except Exception as error:
self.write_debug(
"x11Driver help keyboard ungrab attempt "
+ str(attempt)
+ " failed: "
+ str(error),
debug.DebugLevel.ERROR,
)
else:
return False
self.help_keyboard_grabbed = False
return True
def remove_all_devices(self):
self.ungrab_all_devices()
@@ -114,6 +114,17 @@ class driver(remoteDriver):
}
remoteInstanceRegistry.write_instance(instance_data)
def _try_register_instance(self):
try:
self._register_instance()
return True
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
"unixDriver could not register instance: " + str(e),
debug.DebugLevel.ERROR,
)
return False
def _socket_file_matches_socket(self, fenrir_sock, socket_file):
try:
if os.stat(socket_file).st_uid != os.getuid():
@@ -150,6 +161,23 @@ class driver(remoteDriver):
except OSError:
return 0
def _get_peer_uid(self, client_sock):
so_peercred = getattr(socket, "SO_PEERCRED", 17)
try:
creds = client_sock.getsockopt(
socket.SOL_SOCKET, so_peercred, struct.calcsize("3i")
)
_pid, uid, _gid = struct.unpack("3i", creds)
return uid
except OSError:
return -1
def _get_socket_owner_uid(self, socket_file):
try:
return os.stat(socket_file).st_uid
except OSError:
return -1
def _get_parent_pid(self, pid):
try:
with open(f"/proc/{pid}/stat", "r", encoding="utf-8") as proc_file:
@@ -195,13 +223,20 @@ class driver(remoteDriver):
socket_files.append(socket_file)
return socket_files
def _find_available_private_socket(self, preferred_socket=""):
def _find_available_private_socket(
self, preferred_socket="", owner_uid=None
):
socket_files = self._get_registered_private_sockets()
if preferred_socket and preferred_socket in socket_files:
socket_files.remove(preferred_socket)
socket_files.insert(0, preferred_socket)
for socket_file in socket_files:
if (
owner_uid is not None
and self._get_socket_owner_uid(socket_file) != owner_uid
):
continue
if self._is_own_socket_file(socket_file):
return socket_file
if self._is_socket_active(socket_file):
@@ -242,14 +277,27 @@ class driver(remoteDriver):
def _route_main_socket_command(self, data, client_sock, socket_file):
if socket_file != MAIN_SOCKET_FILE:
return False
owner_uid = None
if not self._has_own_private_socket():
return False
peer_uid = self._get_peer_uid(client_sock)
main_socket_uid = self._get_socket_owner_uid(MAIN_SOCKET_FILE)
if peer_uid < 0 or peer_uid == main_socket_uid:
return False
owner_uid = peer_uid
peer_pid = self._get_peer_pid(client_sock)
ancestor_socket = ""
if peer_pid > 1:
ancestor_socket = self._find_ancestor_private_socket(peer_pid)
target_socket = self._find_available_private_socket(ancestor_socket)
if owner_uid is None:
target_socket = self._find_available_private_socket(
ancestor_socket
)
else:
target_socket = self._find_available_private_socket(
ancestor_socket, owner_uid=owner_uid
)
if not target_socket:
return False
@@ -318,48 +366,54 @@ class driver(remoteDriver):
def watch_dog(self, active, event_queue):
# echo "command say this is a test" | socat -
# UNIX-CLIENT:/tmp/fenrirscreenreader-daemon.sock
for socket_file, optional in self._get_socket_candidates():
fenrir_sock = self._bind_socket(socket_file, optional)
if fenrir_sock is None:
continue
self.fenrirSocks.append(fenrir_sock)
self.bound_sockets.append((fenrir_sock, socket_file))
try:
for socket_file, optional in self._get_socket_candidates():
fenrir_sock = self._bind_socket(socket_file, optional)
if fenrir_sock is None:
continue
self.fenrirSocks.append(fenrir_sock)
self.bound_sockets.append((fenrir_sock, socket_file))
if not self.fenrirSocks:
return
if not self.fenrirSocks:
return
self._register_instance()
last_register = time.time()
while active.value:
if time.time() - last_register > 10.0:
self._register_instance()
last_register = time.time()
self._try_register_instance()
last_register = time.time()
while active.value:
if time.time() - last_register > 10.0:
self._try_register_instance()
last_register = time.time()
# Check if the client is still connected and if data is available:
try:
r, _, _ = select.select(self.fenrirSocks, [], [], 0.8)
except select.error:
break
if r == []:
continue
for fenrir_sock in r:
client_sock, client_addr = fenrir_sock.accept()
socket_file = self._socket_file_for_socket(fenrir_sock)
# Ensure client socket is always closed to prevent resource
# leaks
# Check if the client is still connected and data is available.
try:
self._handle_client(client_sock, event_queue, socket_file)
finally:
# Always close client socket, even if data processing fails
r, _, _ = select.select(self.fenrirSocks, [], [], 0.8)
except select.error:
break
if r == []:
continue
for fenrir_sock in r:
client_sock, _client_addr = fenrir_sock.accept()
socket_file = self._socket_file_for_socket(fenrir_sock)
try:
client_sock.close()
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
"unixDriver watch_dog: Error closing client socket: "
+ str(e),
debug.DebugLevel.ERROR,
self._handle_client(
client_sock, event_queue, socket_file
)
self._cleanup()
finally:
try:
client_sock.close()
except Exception as e:
self.env["runtime"][
"DebugManager"
].write_debug_out(
"unixDriver watch_dog: Error closing client "
"socket: "
+ str(e),
debug.DebugLevel.ERROR,
)
finally:
# ProcessManager restarts a failed watchdog. Always release the
# old listeners first so each restart cannot leak another socket.
self._cleanup()
def shutdown(self):
self._cleanup()
@@ -49,6 +49,23 @@ class PTYConstants:
class FenrirScreen(pyte.Screen):
@property
def display(self):
def render(line):
skip_stub = False
for x in range(self.columns):
if skip_stub:
skip_stub = False
continue
char = line[x].data
if not char:
yield " "
continue
skip_stub = pyte.screens.wcwidth(char[0]) == 2
yield char
return ["".join(render(self.buffer[y])) for y in range(self.lines)]
def set_margins(self, *args, **kwargs):
kwargs.pop("private", None)
super(FenrirScreen, self).set_margins(*args, **kwargs)
@@ -304,6 +321,11 @@ class driver(screenDriver):
"keyboard", "interrupt_on_key_press_filter"
).strip():
return
self.env["runtime"]["DebugManager"].write_debug_out(
"ptyDriver interrupt_output_on_stdin_input: "
+ repr(msg_bytes),
debug.DebugLevel.INFO,
)
self.start_stdin_interrupt_thread()
def start_stdin_interrupt_thread(self):
@@ -335,10 +357,43 @@ class driver(screenDriver):
return
if self.handle_vmenu_stdin_input(msg_bytes, event_queue):
return
if self.suppress_recent_review_stdin_tail(msg_bytes):
return
self.record_stdin_keypress(msg_bytes)
self.interrupt_output_on_stdin_input(msg_bytes)
self.inject_text_to_screen(msg_bytes)
def suppress_recent_review_stdin_tail(self, msg_bytes):
if not self.is_keyboard_control_sequence(msg_bytes):
return False
try:
command_info = self.env["commandInfo"]
last_command = command_info.get("lastCommand", "")
last_section = command_info.get("lastCommandSection", "")
last_run_time = command_info.get("lastCommandRunTime", 0)
except Exception:
return False
if last_section != "commands" or not last_command.startswith("REVIEW_"):
return False
if time.time() - last_run_time > 0.8:
return False
self.env["runtime"]["DebugManager"].write_debug_out(
"ptyDriver suppressing recent review stdin tail: "
+ repr(msg_bytes),
debug.DebugLevel.INFO,
)
return True
def is_keyboard_control_sequence(self, msg_bytes):
if not msg_bytes:
return False
if msg_bytes.startswith(b"\x1b"):
return True
if len(msg_bytes) == 1:
value = msg_bytes[0]
return value < 32 and msg_bytes not in [b"\t", b"\n", b"\r"]
return False
def handle_vmenu_stdin_input(self, msg_bytes, event_queue):
if not self.is_vmenu_active():
return False
@@ -407,11 +462,10 @@ class driver(screenDriver):
)
def record_stdin_keypress(self, msg_bytes):
if msg_bytes != b"\t":
return
key_name = self.stdin_key_name(msg_bytes)
try:
self.env["runtime"]["InputManager"].record_unmanaged_keypress(
"KEY_TAB"
key_name
)
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
@@ -419,6 +473,38 @@ class driver(screenDriver):
debug.DebugLevel.ERROR,
)
def stdin_key_name(self, msg_bytes):
key_map = {
b"\t": "KEY_TAB",
b"\x08": "KEY_BACKSPACE",
b"\x7f": "KEY_BACKSPACE",
b"\x1b[A": "KEY_UP",
b"\x1bOA": "KEY_UP",
b"\x1b[B": "KEY_DOWN",
b"\x1bOB": "KEY_DOWN",
b"\x1b[C": "KEY_RIGHT",
b"\x1bOC": "KEY_RIGHT",
b"\x1b[D": "KEY_LEFT",
b"\x1bOD": "KEY_LEFT",
b"\x1b[H": "KEY_HOME",
b"\x1bOH": "KEY_HOME",
b"\x1b[F": "KEY_END",
b"\x1bOF": "KEY_END",
b"\x1b[5~": "KEY_PAGEUP",
b"\x1b[6~": "KEY_PAGEDOWN",
b"\x1b[3~": "KEY_DELETE",
b"\r": "KEY_ENTER",
b"\n": "KEY_ENTER",
b" ": "KEY_SPACE",
}
if msg_bytes in key_map:
return key_map[msg_bytes]
if len(msg_bytes) == 1:
char = chr(msg_bytes[0])
if char.isascii() and char.isalnum():
return "KEY_" + char.upper()
return "KEY_TERMINAL_INPUT"
def synthesize_backspace_shortcut(self, msg_bytes, event_queue):
if not self.is_backspace_shortcut_sequence(msg_bytes):
return False
@@ -10,6 +10,7 @@
# blink = 5 if attr & 1 else 0
# bold = 1 if attr & 16 else 0
import errno
import fcntl
import glob
import os
@@ -53,6 +54,9 @@ class driver(screenDriver):
fgColorValues: Foreground color value mappings
hichar: High character mask for Unicode support
"""
read_retry_attempts = 10
read_retry_delay = 0.05
def __init__(self):
screenDriver.__init__(self)
self.ListSessions = None
@@ -251,23 +255,40 @@ class driver(screenDriver):
bytes: File content as bytes
"""
d = b""
file.seek(0)
try:
d = file.read()
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
"vcsaDriver get_screen_text: Error reading file: " + str(e),
debug.DebugLevel.ERROR,
)
for attempt in range(self.read_retry_attempts + 1):
file.seek(0)
while True:
# Read from file
try:
d += file.readline(1)
if not d:
break
except Exception as e:
try:
return file.read()
except OSError as e:
if (
e.errno == errno.EINVAL
and attempt < self.read_retry_attempts
):
time.sleep(self.read_retry_delay)
continue
self.env["runtime"]["DebugManager"].write_debug_out(
"vcsaDriver get_screen_text: Error reading file: "
+ str(e),
debug.DebugLevel.ERROR,
)
break
except Exception as e:
self.env["runtime"]["DebugManager"].write_debug_out(
"vcsaDriver get_screen_text: Error reading file: "
+ str(e),
debug.DebugLevel.ERROR,
)
break
file.seek(0)
while True:
try:
chunk = file.readline(1)
if not chunk:
break
d += chunk
except Exception:
break
return d
def update_watchdog(self, active, event_queue):
@@ -26,15 +26,15 @@ class driver(sound_driver):
Attributes:
proc: Currently running subprocess for sound playback
soundFileCommand (str): Command template for playing sound files
frequenceCommand (str): Command template for generating frequencies
sound_file_command (str): Command template for playing sound files
frequency_command (str): Command template for generating frequencies
"""
def __init__(self):
sound_driver.__init__(self)
self.proc = None
self.soundType = ""
self.soundFileCommand = ""
self.frequenceCommand = ""
self.sound_type = ""
self.sound_file_command = ""
self.frequency_command = ""
def initialize(self, environment):
"""Initialize the generic sound driver.
@@ -46,17 +46,20 @@ class driver(sound_driver):
environment: Fenrir environment dictionary with settings
"""
self.env = environment
self.soundFileCommand = self.env["runtime"][
self.sound_file_command = self.env["runtime"][
"SettingsManager"
].get_setting("sound", "generic_play_file_command")
self.frequenceCommand = self.env["runtime"][
self.frequency_command = self.env["runtime"][
"SettingsManager"
].get_setting("sound", "generic_frequency_command")
if self.soundFileCommand == "":
self.soundFileCommand = "play -q -v fenrirVolume fenrirSoundFile"
if self.frequenceCommand == "":
self.frequenceCommand = (
"play -q -v fenrirVolume -n -c1 synth fenrirDuration sine fenrirFrequence"
if self.sound_file_command == "":
self.sound_file_command = (
"play -q -v fenrir_volume fenrir_sound_file"
)
if self.frequency_command == "":
self.frequency_command = (
"play -q -v fenrir_volume -n -c1 synth "
"fenrir_duration sine fenrir_frequency"
)
self._initialized = True
@@ -75,22 +78,22 @@ class driver(sound_driver):
return
if interrupt:
self.cancel()
popen_frequence_command = shlex.split(self.frequenceCommand)
for idx, word in enumerate(popen_frequence_command):
popen_frequency_command = shlex.split(self.frequency_command)
for idx, word in enumerate(popen_frequency_command):
word = word.replace(
"fenrirVolume", str(self.volume * adjust_volume)
"fenrir_volume", str(self.volume * adjust_volume)
)
word = word.replace("fenrirDuration", str(duration))
word = word.replace("fenrirFrequence", str(frequence))
popen_frequence_command[idx] = word
word = word.replace("fenrir_duration", str(duration))
word = word.replace("fenrir_frequency", str(frequence))
popen_frequency_command[idx] = word
self.proc = subprocess.Popen(
popen_frequence_command,
popen_frequency_command,
stdin=None,
stdout=None,
stderr=None,
shell=False,
)
self.soundType = "frequence"
self.sound_type = "frequence"
def play_sound_file(self, file_path, interrupt=True):
"""Play a sound file.
@@ -108,13 +111,13 @@ class driver(sound_driver):
if not os.path.isfile(file_path) or ".." in file_path:
return
popen_sound_file_command = shlex.split(self.soundFileCommand)
popen_sound_file_command = shlex.split(self.sound_file_command)
for idx, word in enumerate(popen_sound_file_command):
word = word.replace("fenrirVolume", str(self.volume))
word = word.replace("fenrirSoundFile", shlex.quote(str(file_path)))
word = word.replace("fenrir_volume", str(self.volume))
word = word.replace("fenrir_sound_file", str(file_path))
popen_sound_file_command[idx] = word
self.proc = subprocess.Popen(popen_sound_file_command, shell=False)
self.soundType = "file"
self.sound_type = "file"
def cancel(self):
"""Cancel currently playing sound.
@@ -123,9 +126,9 @@ class driver(sound_driver):
"""
if not self._initialized:
return
if self.soundType == "":
if self.sound_type == "":
return
if self.soundType == "file":
if self.sound_type == "file":
self.proc.kill()
try:
# Wait for process to finish to prevent zombies
@@ -134,7 +137,7 @@ class driver(sound_driver):
pass # Process already terminated