Compare commits
36
Commits
2026.05.25
...
testing
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e6e806947 | ||
|
|
45019091ab | ||
|
|
68462d00cb | ||
|
|
93a2745065 | ||
|
|
4ed431640e | ||
|
|
e7f69b2159 | ||
|
|
a2e4a8a086 | ||
|
|
cca688328b | ||
|
|
ee441c319a | ||
|
|
7593c8d0b3 | ||
|
|
122a6a38b3 | ||
|
|
76b8f2f2cf | ||
|
|
d81565fbf5 | ||
|
|
186512f79a | ||
|
|
17b6e6f909 | ||
|
|
fac3065c6a | ||
|
|
e93aba109a | ||
|
|
11a4244cc3 | ||
|
|
9ac2e9e844 | ||
|
|
c863245c24 | ||
|
|
45bd33f756 | ||
|
|
6d9260e79b | ||
|
|
385356e745 | ||
|
|
28e4f8ab0c | ||
|
|
4d94b56ee9 | ||
|
|
eb93a8aa62 | ||
|
|
ad863ad706 | ||
|
|
447e2cc896 | ||
|
|
2b7d205f06 | ||
|
|
fd5fe5b328 | ||
|
|
4ed3f4d6ab | ||
|
|
2cb83632f9 | ||
|
|
0c4fe50606 | ||
|
|
15f2435749 | ||
|
|
3897b63068 | ||
|
|
f1a8e6af21 |
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
+8
-10
@@ -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,
|
||||
|
||||
@@ -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 == ""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
| ||||