Initial commit
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Function to clean up on exit
|
||||
cleanup() {
|
||||
sudo systemctl stop fenrirscreenreader.service 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Set up trap for cleanup
|
||||
trap cleanup EXIT
|
||||
|
||||
if [[ -r "/home/stormux/.local/games/Upheaval/Upheaval_Command_Line" ]]; then
|
||||
pushd "/home/stormux/.local/games/Upheaval/" > /dev/null
|
||||
|
||||
# Comprehensive terminal reset
|
||||
reset
|
||||
clear
|
||||
stty sane
|
||||
stty echo
|
||||
stty icanon
|
||||
stty -raw
|
||||
stty -cbreak
|
||||
|
||||
# Start screen reader
|
||||
sudo systemctl start fenrirscreenreader.service
|
||||
|
||||
# Give screen reader time to initialize
|
||||
sleep 1
|
||||
|
||||
# Run the game with proper terminal handling
|
||||
# Use exec to replace the shell process entirely
|
||||
exec stdbuf -i0 -o0 -e0 FEXLoader ./Upheaval_Command_Line 2>&1 | grep -v '^vc4: driver missing$'
|
||||
popd > /dev/null
|
||||
fi
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
trap 'sudo systemctl stop fenrirscreenreader.service' EXIT
|
||||
|
||||
export BOX64_LOG=0
|
||||
if [[ -r "/home/stormux/.local/games/Warsim/Warsim.exe" ]]; then
|
||||
pushd "/home/stormux/.local/games/Warsim/"
|
||||
clear
|
||||
sudo systemctl start fenrirscreenreader.service
|
||||
wine Warsim.exe 2> /dev/null | grep -v '^box64'
|
||||
popd
|
||||
else
|
||||
GAME='Warsim' startx
|
||||
fi
|
||||
Executable
+113
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
|
||||
# Stop the screen reader if this closes for any reason
|
||||
trap 'sudo -n /usr/bin/systemctl stop fenrirscreenreader.service' SIGINT SIGTERM SIGHUP EXIT
|
||||
|
||||
# Start Fenrir for interaction with the terminal
|
||||
sudo -n /usr/bin/systemctl start fenrirscreenreader.service
|
||||
|
||||
# Clear the screen before loading
|
||||
clear
|
||||
|
||||
case "$GAME" in
|
||||
"Alter Aeon")
|
||||
pushd ~/.local/games/tintin-alteraeon
|
||||
git pull -q
|
||||
tt++ aa.tin
|
||||
popd
|
||||
;;
|
||||
"BPG")
|
||||
pushd ~/.local/games/bpg
|
||||
git pull -q
|
||||
./bpg.sh
|
||||
popd
|
||||
;;
|
||||
"Empire MUD")
|
||||
pushd ~/.local/games/tintin-empiremud
|
||||
git pull -q
|
||||
tt++ em.tin
|
||||
popd
|
||||
;;
|
||||
"End of Time")
|
||||
pushd ~/.local/games/tintin-endoftime
|
||||
git pull -q
|
||||
tt++ eot.tin
|
||||
popd
|
||||
;;
|
||||
"Kallisti MUD")
|
||||
pushd ~/.local/games/tintin-kallisti-pack
|
||||
git pull -q
|
||||
tt++ kallisti.tin
|
||||
popd
|
||||
;;
|
||||
"RS Games")
|
||||
pushd ~/.local/games/tintin-rsgames
|
||||
git pull -q
|
||||
tt++ rs.tin
|
||||
popd
|
||||
;;
|
||||
"Slay the Text")
|
||||
pushd ~/.local/games/slaythetext
|
||||
git pull -q
|
||||
python3 main.py
|
||||
popd
|
||||
;;
|
||||
"Stationfall")
|
||||
frotz ~/.local/frotz/Stationfall/stationfall-r107-s870430.z3
|
||||
;;
|
||||
"Planetfall")
|
||||
frotz ~/.local/frotz/Planetfall/planetfall-r39-s880501.z3
|
||||
;;
|
||||
"The Hitchhiker's Guide to the Galaxy")
|
||||
frotz ~/.local/frotz/"The Hitchhiker's Guide to the Galaxy/HITCHHIK.DAT"
|
||||
;;
|
||||
"Zork 1") ;&
|
||||
"Zork 2") ;&
|
||||
"Zork 3")
|
||||
frotz ~/.local/frotz/${GAME/ /}/DATA/ZORK${GAME##* }.DAT
|
||||
;;
|
||||
"IRC")
|
||||
if ! ping -c1 stormux.org &> /dev/null ; then
|
||||
spd-say -Cw "No internet connection detected. Please go to the system menu and select configure internet."
|
||||
exit 0
|
||||
fi
|
||||
configFile="$HOME/.irssi/config"
|
||||
validNickRegex='^[[:alnum:]_-]*$'
|
||||
# Check if nick and user_name are still set to "stormux"
|
||||
if grep -qE '^\s*nick = "stormux";' "$configFile" && grep -qE '^\s*user_name = "stormux";' "$configFile"; then
|
||||
echo "First time connection setup:"
|
||||
echo
|
||||
echo "Let's get you set up with your own nick."
|
||||
echo "This is the name other people will see when you join, chat, leave, etc."
|
||||
echo
|
||||
while : ; do
|
||||
echo "Enter your desired IRC nick:"
|
||||
read -re newNick
|
||||
if [[ $newNick =~ $validNickRegex ]]; then
|
||||
# Update nick and user_name in config
|
||||
sed -i "s/^\(\s*nick = \)\"stormux\";/\1\"$newNick\";/" "$configFile"
|
||||
sed -i "s/^\(\s*user_name = \)\"stormux\";/\1\"$newNick\";/" "$configFile"
|
||||
echo "Configuration updated. Launching irssi..."
|
||||
break
|
||||
else
|
||||
echo "Invalid IRC nickname. Must start with a letter and contain only letters, numbers, and these symbols: - [ ] \\ \` ^ { } _"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
# Launch irssi
|
||||
/usr/bin/irssi
|
||||
;;
|
||||
"Network Configuration")
|
||||
count=0
|
||||
while [[ $count -lt 10 ]]; do
|
||||
sleep 1
|
||||
if echo "setting set focus#highlight=True" | socat - UNIX-CLIENT:/tmp/fenrirscreenreader-deamon.sock 2> /dev/null ; then
|
||||
break
|
||||
fi
|
||||
done
|
||||
nmtui-connect
|
||||
;;
|
||||
*".md") /usr/bin/markdown -toc "$GAME" | /usr/bin/w3m -T text/html ;;
|
||||
"/usr/bin/"*) $GAME ;;
|
||||
esac
|
||||
@@ -0,0 +1,6 @@
|
||||
# Reload changes with control+x followed by control+r
|
||||
set echo-control-characters off
|
||||
|
||||
# History searching with up and down arrows.
|
||||
"\e[A": history-search-backward
|
||||
"\e[B": history-search-forward
|
||||
Executable
+351
@@ -0,0 +1,351 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Function to run a game with FEXLoader
|
||||
fex_load() {
|
||||
local gameDir="$1"
|
||||
local exeName="$2"
|
||||
export FEX_NO_SANDBOX=1
|
||||
export MESA_GL_VERSION_OVERRIDE=3.2
|
||||
export SPD_SOCKET=/run/user/1000/speech-dispatcher/speechd.sock
|
||||
pushd "$HOME/.local/games/$gameDir"
|
||||
FEX_NO_SANDBOX=1 MESA_GL_VERSION_OVERRIDE=3.2 BOX64_DYNAREC_STRONGMEM=1 DISPLAY=:0 FEXLoader ./$exeName
|
||||
popd
|
||||
}
|
||||
|
||||
# Function to run a Windows game with wine via FEXBash
|
||||
run_wine() {
|
||||
local gameDir="$1"
|
||||
local exeName="$2"
|
||||
local needNvda="${3:-}"
|
||||
pushd "$HOME/.local/games/$gameDir"
|
||||
if [[ ${#needNvda} > 1 ]]; then
|
||||
systemctl --user start ${needNvda}.service
|
||||
FEXBash -c "wine \"$exeName\""
|
||||
systemctl --user stop ${needNvda}.service
|
||||
else
|
||||
FEXBash -c "wine $exeName"
|
||||
fi
|
||||
popd
|
||||
}
|
||||
|
||||
# Function to handle downloadable games with installation
|
||||
setup_and_run_downloadable() {
|
||||
local zipName="$1"
|
||||
local gameDir="$2"
|
||||
local executable="$3"
|
||||
local fallbackUrl="$4"
|
||||
local needNvda="$5"
|
||||
local zipPath=~/Downloads/"$zipName"
|
||||
local extractDir="$HOME/.local/games/$gameDir"
|
||||
if [[ -e "$zipPath" ]]; then
|
||||
# Recreate extractDir
|
||||
rm -rf "$extractDir"
|
||||
mkdir -p "$extractDir"
|
||||
# Get the first path component of each file in the zip
|
||||
mapfile -t topDirs < <(unzip -l "$zipPath" | awk 'NR>3 {print $4}' | grep '/$' | cut -d/ -f1 | sort -u | grep -v '^$')
|
||||
if [[ ${#topDirs[@]} -eq 1 ]]; then
|
||||
# Zip has a single top-level dir Ãse it as final dir
|
||||
unzip -q "$zipPath" -d ~/.local/games
|
||||
else
|
||||
# Unzips file into the given directory without creating a subdirectory
|
||||
unzip -q "$zipPath" -d "$extractDir"
|
||||
fi
|
||||
# Copy NVDA DLLs
|
||||
for i in 32 64; do
|
||||
find "$extractDir" -type f -name "nvdaControllerClient${i}.dll" -exec cp -v "$HOME/.local/games/nvda/nvdaControllerClient${i}.dll" '{}' \;
|
||||
done
|
||||
rm -f "$zipPath"
|
||||
fi
|
||||
if [[ -e "$extractDir/$executable" ]]; then
|
||||
run_wine "${extractDir##*/}" "./$executable" "$needNvda"
|
||||
else
|
||||
run_web "$fallbackUrl"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to handle all web-based games
|
||||
run_web() {
|
||||
orca &
|
||||
exec brave "$1"
|
||||
}
|
||||
|
||||
# Function to handle unpacked web games
|
||||
# Function to handle unpacked web games
|
||||
setup_and_run_web_game() {
|
||||
local zipName="$1"
|
||||
local gameDir="$2"
|
||||
local htmlPath="$3"
|
||||
local fallbackUrl="$4"
|
||||
local zipPath=~/Downloads/"$zipName"
|
||||
local extractDir="$HOME/.local/games/$gameDir"
|
||||
if [[ -e "$zipPath" ]]; then
|
||||
# Recreate extractDir
|
||||
rm -rf "$extractDir"
|
||||
mkdir -p "$extractDir"
|
||||
# Get the first path component of each file in the zip
|
||||
mapfile -t topDirs < <(unzip -l "$zipPath" | awk 'NR>3 {print $4}' | grep '/$' | cut -d/ -f1 | sort -u | grep -v '^$')
|
||||
if [[ ${#topDirs[@]} -eq 1 ]]; then
|
||||
# Zip has a single top-level dir - extract to ~/.local/games
|
||||
unzip -q "$zipPath" -d "$HOME/.local/games"
|
||||
else
|
||||
# Unzips file into the given directory
|
||||
unzip -q "$zipPath" -d "$extractDir"
|
||||
fi
|
||||
rm -f "$zipPath"
|
||||
fi
|
||||
if [[ -e "$extractDir/$htmlPath" ]]; then
|
||||
run_web "$extractDir/$htmlPath"
|
||||
else
|
||||
run_web "$fallbackUrl"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to run native applications
|
||||
run_native() {
|
||||
local gameDir="$1"
|
||||
local exeName="$2"
|
||||
pushd "/home/stormux/.local/games/$gameDir"
|
||||
case "${exeName##*.}" in
|
||||
"py")
|
||||
python3 "$exeName"
|
||||
;;
|
||||
"py")
|
||||
./"$exeName"
|
||||
;;
|
||||
esac
|
||||
popd
|
||||
}
|
||||
|
||||
# Initial setup
|
||||
if [[ -e /etc/X11/xorg.conf.d/10-headless.conf ]]; then
|
||||
export LIBGL_ALWAYS_SOFTWARE=1
|
||||
fi
|
||||
|
||||
[ -f /etc/xprofile ] && . /etc/xprofile
|
||||
[ -f ~/.xprofile ] && . ~/.xprofile
|
||||
export BOX64_PATH="$HOME/.fex-emu/RootFS/ArchLinux/usr/bin"
|
||||
export BOX64_NOBANNER=1
|
||||
export MESA_GL_VERSION_OVERRIDE=3.2
|
||||
export BOX64_DYNAREC_STRONGMEM=1
|
||||
export DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus
|
||||
export DBUS_SESSION_BUS_PID
|
||||
exec dwm &
|
||||
xbindkeys
|
||||
|
||||
|
||||
# Main game launcher
|
||||
case "$GAME" in
|
||||
"Apple 2e")
|
||||
exec mame apple2ee -sl1 echoii -flop1 ~/.local/games/apple2e/Echo\ II\ -\ Textalker\ DOS\ 3.3.dsk -samplerate 48000 -sound pulse
|
||||
;;
|
||||
"Audio Disc")
|
||||
run_wine "audiodisc" "disco.exe"
|
||||
;;
|
||||
"BallBouncer")
|
||||
fex_load BallBouncer BallBouncer
|
||||
;;
|
||||
"Battle of the Hunter")
|
||||
run_wine "Battle of the Hunter" "bth.exe" "nvda2speechd"
|
||||
;;
|
||||
"blueman-manager")
|
||||
orca &
|
||||
blueman-manager
|
||||
;;
|
||||
"Bokurano Daibouken")
|
||||
/home/stormux/.local/files/clipboard_translator.sh play.exe bokurano-daibouken &
|
||||
run_wine "bk" "play.exe"
|
||||
killall speech-dispatcher
|
||||
;;
|
||||
"Bokurano Daibouken 2")
|
||||
/home/stormux/.local/files/clipboard_translator.sh play.exe bokurano-daibouken2 &
|
||||
run_wine "bk2" "play.exe"
|
||||
killall speech-dispatcher
|
||||
;;
|
||||
"Bokurano Daibouken 3")
|
||||
if [[ -d /home/stormux/.local/games/bk3/dict ]] || [[ -e ~/Downloads/dict.dat ]]; then
|
||||
[[ -e ~/Downloads/dict.dat ]] && mv ~/Downloads/dict.dat /home/stormux/.local/games/bk3/
|
||||
run_wine "bk3" "play.exe" "nvda2speechd"
|
||||
else
|
||||
/home/stormux/.local/files/clipboard_translator.sh play.exe bokurano-daibouken3 &
|
||||
run_wine "bk3" "play.exe"
|
||||
fi
|
||||
killall speech-dispatcher
|
||||
;;
|
||||
"Bop It")
|
||||
~/.local/files/speak_window_title.sh bop.exe &
|
||||
run_wine "BopIt" "bop.exe"
|
||||
;;
|
||||
"Brave")
|
||||
run_web "" # Runs brave without a specific URL
|
||||
;;
|
||||
"Challenge of the Horse")
|
||||
run_wine "Challenge of the Horse" "game.exe" "nvda2speechd"
|
||||
;;
|
||||
"Clashes of the Sky")
|
||||
run_wine "clashes_of_the_sky" "clash.exe" "nvda2speechd"
|
||||
;;
|
||||
"Constant Motion")
|
||||
run_wine "ConstantMotion" "cm.exe" "nvda2speechd"
|
||||
;;
|
||||
"Crazy Party")
|
||||
run_wine "Crazy-Party-beta82" "Crazy\ Party.exe" "nvda2speechd-important"
|
||||
;;
|
||||
"Doom")
|
||||
pushd /home/stormux/.local/games/doom/toby-doom-launcher
|
||||
git pull -q
|
||||
orca &
|
||||
exec "./Toby Doom Launcher.py"
|
||||
popd
|
||||
;;
|
||||
"Dosbox")
|
||||
exec dosbox-x -fastlaunch -conf /opt/talking-dosbox/dosbox-x.conf
|
||||
;;
|
||||
"Fantasy Story 2")
|
||||
pushd "/home/stormux/.local/games/Fantasy Story 2/FS2_3.0_Linux"
|
||||
FEXBash './fs2.x86_64'
|
||||
popd
|
||||
;;
|
||||
"Golf")
|
||||
run_wine "golf" "golf.exe"
|
||||
;;
|
||||
"Haunted House")
|
||||
pushd /home/stormux/.local/games/haunted-house
|
||||
./runner haunted-house
|
||||
popd
|
||||
;;
|
||||
"Haunted Party")
|
||||
setup_and_run_downloadable "hp.zip" "hp" "hp.exe" "https://tunmi13.itch.io/haunted-party" "nvda2speechd"
|
||||
;;
|
||||
"Kaskade")
|
||||
setup_and_run_downloadable "battlefield-2d-win.zip" "bf" "bf.exe" "https://tunmi13.itch.io/kaskade" "nvda2speechd"
|
||||
;;
|
||||
"Mach1")
|
||||
run_wine "mach1" "mach1.exe"
|
||||
;;
|
||||
"Manamon 2")
|
||||
run_wine "Manamon 2" "rpg.exe" "nvda2speechd"
|
||||
;;
|
||||
"Mine Racer")
|
||||
pushd /home/stormux/.local/games/Mineracer
|
||||
./mineracer
|
||||
popd
|
||||
;;
|
||||
"Oh Shit")
|
||||
run_wine "oh_shit" "OhShit.exe" "nvda2speechd"
|
||||
;;
|
||||
"Pong")
|
||||
run_wine "pong" "pong.exe"
|
||||
;;
|
||||
"Q9 Action Game")
|
||||
run_wine "Q9 Action Game" "q9.exe"
|
||||
;;
|
||||
"Retro Arch")
|
||||
/usr/bin/retroarch --accessibility
|
||||
killall speech-dispatcher
|
||||
;;
|
||||
"River Raiders")
|
||||
run_wine "River Raiders" "raid.exe"
|
||||
;;
|
||||
"RS Games")
|
||||
run_wine "RS Games Client" "rsg.exe" "nvda2speechd-important"
|
||||
;;
|
||||
"Scramble")
|
||||
run_wine "scramble_win32" "scramble.exe" "nvda2speechd"
|
||||
;;
|
||||
"Screaming Strike 2")
|
||||
run_wine "Screaming Strike 2" "play.exe" "nvda2speechd"
|
||||
;;
|
||||
"Scrolling Battles")
|
||||
run_wine "Scrolling Battles" "sb.exe" "nvda2speechd"
|
||||
;;
|
||||
"Shadow Line")
|
||||
run_wine "ShadowRine_FullVoice" "play_sr.exe" "nvda2speechd"
|
||||
;;
|
||||
"Shooter")
|
||||
run_wine "shooter" "Shooter.exe" "nvda2speechd"
|
||||
;;
|
||||
"Side Party")
|
||||
setup_and_run_downloadable "sideparty-win.zip" "SideParty" "SideParty.exe" "https://masonasons.itch.io/sideparty" "nvda2speechd"
|
||||
;;
|
||||
"Skateboarder Pro")
|
||||
run_wine "sb_pro_rw" "sb_pro.exe" "nvda2speechd"
|
||||
;;
|
||||
"Sketchbook (Your World)")
|
||||
run_wine "SBYW" "SBYW.exe" "nvda2speechd-important"
|
||||
;;
|
||||
"SoundRTS")
|
||||
run_native "SoundRTS" "soundrts.py"
|
||||
;;
|
||||
"Super Egg Hunt")
|
||||
run_wine "super egg hunt" "superegghunt.exe"
|
||||
;;
|
||||
"Super Liam")
|
||||
run_wine "Super Liam" "sl.exe"
|
||||
;;
|
||||
"The Blind Swordsman")
|
||||
FEXBash -c 'wine ~/.local/games/TheBlindSwordsman.exe'
|
||||
;;
|
||||
"The Tornado Chicken")
|
||||
fex_load "The Tornado Chicken" "Ttc"
|
||||
;;
|
||||
"Top Speed 3")
|
||||
run_wine "Top Speed 3" "TopSpeed.exe"
|
||||
;;
|
||||
"Toy Mania")
|
||||
if [[ -e ~/Downloads/ToyMania_windows_portable_password_is_GrateCollector.7z ]]; then
|
||||
rm -rf ~/.local/games/ToyMania
|
||||
mkdir -p ~/.local/games/ToyMania
|
||||
7z x ~/Downloads/ToyMania_windows_portable_password_is_GrateCollector.7z -o/home/stormux/.local/games/ToyMania -pGrateCollector
|
||||
rm -f ~/Downloads/ToyMania_windows_portable_password_is_GrateCollector.7z
|
||||
fi
|
||||
if [[ -e ~/.local/games/ToyMania/tm.exe ]]; then
|
||||
cp ~/.local/games/nvda/nvdaControllerClient64.dll ~/.local/games/ToyMania/lib/nvdaControllerClient64.dll
|
||||
systemctl --user start nvda2speechd-important.service
|
||||
pushd ~/.local/games/ToyMania
|
||||
exec FEXBash -c 'wine ./tm.exe'
|
||||
popd
|
||||
systemctl --user stop nvda2speechd-important.service
|
||||
else
|
||||
orca &
|
||||
exec brave "https://tsatria03.itch.io/toymania"
|
||||
fi
|
||||
;;
|
||||
"Upheaval")
|
||||
setup_and_run_downloadable "upheaval-linux-console.zip" "Upheaval" "Upheaval_Command_Line" "https://leonegaming.itch.io/upheaval"
|
||||
;;
|
||||
"Villains From Beyond")
|
||||
run_wine "villains from beyond" "game.exe"
|
||||
;;
|
||||
"Warsim")
|
||||
setup_and_run_downloadable "Warsim Full Game.zip" "Warsim" "Warsim.exe" "https://huw2k8.itch.io/warsim"
|
||||
;;
|
||||
"Wheels of Prio")
|
||||
fex_load "Wheels of Prio" "Wp"
|
||||
;;
|
||||
"Wicked Quest")
|
||||
run_native "wicked-quest" "wicked_quest.py"
|
||||
;;
|
||||
"Zombowl")
|
||||
run_native "zombowl" "zombowl.py"
|
||||
;;
|
||||
"https://shiftbacktick.itch.io/periphery-synthetic-ep")
|
||||
setup_and_run_web_game "periphery-synthetic-ep-html5.zip" "periphery-synthetic-ep" "index.html" "$GAME"
|
||||
;;
|
||||
"https://"*)
|
||||
run_web "$GAME"
|
||||
;;
|
||||
*".dsk")
|
||||
xbindkeys
|
||||
exec mame apple2ee -sl1 echoii -flop1 ~/.local/games/apple2e/Echo\ II\ -\ Textalker\ DOS\ 3.3.dsk -flop2 "$GAME" -samplerate 48000 -sound pulse
|
||||
;;
|
||||
*"/Roms/"*)
|
||||
exec mednafen -sounddriver sdl -sound 1 "$GAME"
|
||||
;;
|
||||
*)
|
||||
echo "No valid game specified in \$GAME"
|
||||
sleep 5
|
||||
;;
|
||||
esac
|
||||
|
||||
# Make sure X goes down after game ends
|
||||
pkill -15 Xorg
|
||||
@@ -0,0 +1,7 @@
|
||||
# Accessibility variables
|
||||
export ACCESSIBILITY_ENABLED=1
|
||||
export GTK_MODULES=gail:atk-bridge
|
||||
export GNOME_ACCESSIBILITY=1
|
||||
export QT_ACCESSIBILITY=1
|
||||
export QT_LINUX_ACCESSIBILITY_ALWAYS_ON=1
|
||||
export SAL_USE_VCLPLUGIN=gtk3
|
||||
@@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=Fenrir screenreader
|
||||
Wants=systemd-udev-settle.service
|
||||
After=systemd-udev-settle.service getty.target
|
||||
[Service]
|
||||
Type=forking
|
||||
PIDFile=/var/run/fenrir.pid
|
||||
ExecStart=/usr/bin/fenrir -i 1
|
||||
ExecReload=/usr/bin/kill -HUP $MAINPID
|
||||
Restart=always
|
||||
#Group=fenrirscreenreader
|
||||
#User=fenrirscreenreader
|
||||
|
||||
[Install]
|
||||
WantedBy=getty.target
|
||||
@@ -0,0 +1,13 @@
|
||||
[Unit]
|
||||
Description=Play shutdown sound
|
||||
DefaultDependencies=no
|
||||
Before=shutdown.target reboot.target halt.target
|
||||
After=multi-user.target pipewire.service pipewire.socket sound.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/bin/play /usr/share/sounds/stormux/stop.opus
|
||||
#ExecStartPost=/bin/sleep 3.5
|
||||
|
||||
[Install]
|
||||
WantedBy=halt.target reboot.target shutdown.target
|
||||
Executable
+390
@@ -0,0 +1,390 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Self-voiced Terminal Menu for Apple IIe Disk Launcher
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import curses
|
||||
import speechd # Python bindings for Speech Dispatcher
|
||||
import configparser
|
||||
|
||||
class VoicedDiskMenu:
|
||||
def __init__(self, title="Apple 2e Disk Menu"):
|
||||
self.title = title
|
||||
self.menu_items = [] # List to store (name, path) tuples
|
||||
self.current_index = 0 # Index of current selection
|
||||
self.stdscr = None
|
||||
self.curses_initialized = False # Flag to track if curses has been initialized
|
||||
|
||||
# Config settings
|
||||
self.config_dir = os.path.expanduser("~/.config/stormux")
|
||||
self.config_file = os.path.join(self.config_dir, "apple2e_menu.conf")
|
||||
self.config = configparser.ConfigParser()
|
||||
|
||||
# Default settings
|
||||
self.speech_rate = 0 # Normal speech rate (0 is default in speechd)
|
||||
|
||||
# Load settings
|
||||
self.load_settings()
|
||||
|
||||
# Initialize speech client
|
||||
self.speech_client = None
|
||||
self.init_speech()
|
||||
|
||||
def init_speech(self):
|
||||
"""Initialize the speech client"""
|
||||
try:
|
||||
self.speech_client = speechd.SSIPClient("apple_menu")
|
||||
self.speech_client.set_priority(speechd.Priority.IMPORTANT)
|
||||
self.speech_client.set_punctuation(speechd.PunctuationMode.SOME)
|
||||
|
||||
# Apply speech rate from settings
|
||||
self.speech_client.set_rate(self.speech_rate)
|
||||
except Exception as e:
|
||||
print(f"Could not initialize speech: {e}")
|
||||
# Fallback to None - the speak method will handle this
|
||||
|
||||
def load_settings(self):
|
||||
"""Load settings from config file"""
|
||||
# Create default settings if they don't exist
|
||||
if not os.path.exists(self.config_file):
|
||||
self.save_settings()
|
||||
return
|
||||
|
||||
try:
|
||||
self.config.read(self.config_file)
|
||||
|
||||
# Load speech settings
|
||||
if 'Speech' in self.config:
|
||||
self.speech_rate = self.config.getint('Speech', 'rate', fallback=0)
|
||||
except Exception as e:
|
||||
print(f"Error loading settings: {e}")
|
||||
# If loading fails, we'll use default values
|
||||
|
||||
def save_settings(self):
|
||||
"""Save settings to config file"""
|
||||
# Ensure config directory exists
|
||||
os.makedirs(self.config_dir, exist_ok=True)
|
||||
|
||||
# Update config object
|
||||
if 'Speech' not in self.config:
|
||||
self.config['Speech'] = {}
|
||||
|
||||
self.config['Speech']['rate'] = str(self.speech_rate)
|
||||
|
||||
# Write to file
|
||||
try:
|
||||
with open(self.config_file, 'w') as f:
|
||||
self.config.write(f)
|
||||
except Exception as e:
|
||||
print(f"Error saving settings: {e}")
|
||||
|
||||
def increase_speech_rate(self):
|
||||
"""Increase speech rate"""
|
||||
self.speech_rate = min(100, self.speech_rate + 10) # Max is 100
|
||||
if self.speech_client:
|
||||
try:
|
||||
self.speech_client.set_rate(self.speech_rate)
|
||||
self.speak(f"Speech rate: {self.speech_rate}")
|
||||
except Exception as e:
|
||||
print(f"Error adjusting speech rate: {e}")
|
||||
|
||||
# Save the new setting
|
||||
self.save_settings()
|
||||
|
||||
def decrease_speech_rate(self):
|
||||
"""Decrease speech rate"""
|
||||
self.speech_rate = max(-100, self.speech_rate - 10) # Min is -100
|
||||
if self.speech_client:
|
||||
try:
|
||||
self.speech_client.set_rate(self.speech_rate)
|
||||
self.speak(f"Speech rate: {self.speech_rate}")
|
||||
except Exception as e:
|
||||
print(f"Error adjusting speech rate: {e}")
|
||||
|
||||
# Save the new setting
|
||||
self.save_settings()
|
||||
|
||||
def speak(self, text, interrupt=True):
|
||||
"""Speak the given text with option to interrupt existing speech"""
|
||||
if self.speech_client is None:
|
||||
return
|
||||
|
||||
try:
|
||||
if interrupt:
|
||||
self.stop_speech()
|
||||
|
||||
self.speech_client.speak(text)
|
||||
except Exception as e:
|
||||
# If speech fails, try to reinitialize and try once more
|
||||
try:
|
||||
self.init_speech()
|
||||
if self.speech_client:
|
||||
self.speech_client.speak(text)
|
||||
except:
|
||||
# If reinitializing fails, just give up silently
|
||||
pass
|
||||
|
||||
def stop_speech(self):
|
||||
"""Stop any ongoing speech"""
|
||||
if self.speech_client is None:
|
||||
return
|
||||
|
||||
try:
|
||||
self.speech_client.cancel()
|
||||
except Exception as e:
|
||||
# If cancel fails, try to reinitialize
|
||||
self.init_speech()
|
||||
|
||||
def announce_current_item(self, interrupt=True):
|
||||
"""Announce the currently selected menu item"""
|
||||
if self.menu_items and 0 <= self.current_index < len(self.menu_items):
|
||||
name = self.menu_items[self.current_index][0]
|
||||
self.speak(name, interrupt=interrupt)
|
||||
|
||||
def execute_current_item(self):
|
||||
"""Execute the currently selected menu item"""
|
||||
if self.menu_items and 0 <= self.current_index < len(self.menu_items):
|
||||
_, path = self.menu_items[self.current_index]
|
||||
|
||||
# Clean up resources before executing the command
|
||||
self.cleanup(full_cleanup=True)
|
||||
|
||||
# Execute the command to launch the disk file
|
||||
os.system(f'export GAME="{path}" && startx')
|
||||
sys.exit(0) # Exit after launching
|
||||
|
||||
def speak_help(self):
|
||||
"""Speak help information"""
|
||||
helpText = """
|
||||
Navigation controls:
|
||||
Up arrow: Previous disk.
|
||||
Down arrow: Next disk.
|
||||
Enter: Launch selected disk.
|
||||
H key: Hear these instructions again.
|
||||
Left bracket: Decrease speech rate.
|
||||
Right bracket: Increase speech rate.
|
||||
Escape or Q: Exit the menu.
|
||||
Any key will interrupt speech.
|
||||
"""
|
||||
self.speak(helpText)
|
||||
|
||||
def draw_menu(self):
|
||||
"""Draw the menu on the screen"""
|
||||
self.stdscr.clear()
|
||||
h, w = self.stdscr.getmaxyx()
|
||||
|
||||
# Draw title
|
||||
title = f" {self.title} "
|
||||
x = max(0, w // 2 - len(title) // 2)
|
||||
self.stdscr.addstr(1, x, title, curses.A_BOLD)
|
||||
|
||||
# Draw help line
|
||||
helpText = "Up/Down: Navigate | Enter: Select | H: Help | [ ] Rate | Q/Esc: Quit"
|
||||
x = max(0, w // 2 - len(helpText) // 2)
|
||||
self.stdscr.addstr(3, x, helpText)
|
||||
|
||||
# Check if we have items
|
||||
if not self.menu_items:
|
||||
message = "No disk files found."
|
||||
x = max(0, w // 2 - len(message) // 2)
|
||||
self.stdscr.addstr(5, x, message, curses.A_DIM)
|
||||
else:
|
||||
# Show a limited number of items, centered around the current selection
|
||||
max_display = min(h - 7, len(self.menu_items)) # Max number of items to display
|
||||
|
||||
# Calculate starting index for display
|
||||
half_display = max_display // 2
|
||||
if self.current_index < half_display:
|
||||
start_idx = 0
|
||||
elif self.current_index >= len(self.menu_items) - half_display:
|
||||
start_idx = max(0, len(self.menu_items) - max_display)
|
||||
else:
|
||||
start_idx = self.current_index - half_display
|
||||
|
||||
# Draw visible menu items
|
||||
for i in range(start_idx, min(start_idx + max_display, len(self.menu_items))):
|
||||
y = (i - start_idx) + 5 # Start items at line 5
|
||||
|
||||
# Highlight the selected item
|
||||
name = self.menu_items[i][0]
|
||||
if i == self.current_index:
|
||||
text = f" > {name} "
|
||||
attr = curses.A_REVERSE
|
||||
else:
|
||||
text = f" {name} "
|
||||
attr = curses.A_NORMAL
|
||||
|
||||
x = max(0, w // 2 - len(text) // 2)
|
||||
self.stdscr.addstr(y, x, text, attr)
|
||||
|
||||
# Draw speech rate indicator
|
||||
rateText = f"Speech Rate: {self.speech_rate}"
|
||||
self.stdscr.addstr(h-2, 2, rateText)
|
||||
|
||||
self.stdscr.refresh()
|
||||
|
||||
def cleanup(self, full_cleanup=False):
|
||||
"""Clean up resources before exiting or executing a command
|
||||
|
||||
Args:
|
||||
full_cleanup: If True, also close curses. Used when exiting or running a command.
|
||||
"""
|
||||
# Stop any speech
|
||||
self.stop_speech()
|
||||
|
||||
# Close speech client
|
||||
if self.speech_client:
|
||||
try:
|
||||
self.speech_client.close()
|
||||
except:
|
||||
pass
|
||||
self.speech_client = None
|
||||
|
||||
# Restore terminal settings if curses was initialized
|
||||
if full_cleanup and self.curses_initialized:
|
||||
try:
|
||||
curses.nocbreak()
|
||||
self.stdscr.keypad(False)
|
||||
curses.echo()
|
||||
curses.endwin()
|
||||
except:
|
||||
# If there's an error, just try a simple endwin
|
||||
try:
|
||||
curses.endwin()
|
||||
except:
|
||||
pass # Last resort, just continue
|
||||
|
||||
def load_disks_from_directory(self, directory_path):
|
||||
"""Load disk files from the specified directory"""
|
||||
# Expand the path (in case it contains ~)
|
||||
directory_path = os.path.expanduser(directory_path)
|
||||
|
||||
# Check if directory exists
|
||||
if not os.path.exists(directory_path) or not os.path.isdir(directory_path):
|
||||
print(f"Directory {directory_path} does not exist or is not a directory")
|
||||
return
|
||||
|
||||
try:
|
||||
# Get all .dsk files in the directory
|
||||
files = [f for f in os.listdir(directory_path) if os.path.isfile(os.path.join(directory_path, f))
|
||||
and f.lower().endswith(('.dsk', '.do'))]
|
||||
|
||||
# Sort files alphabetically
|
||||
files.sort()
|
||||
|
||||
# Create menu items
|
||||
for file in files:
|
||||
# Get full path to the file
|
||||
file_path = os.path.join(directory_path, file)
|
||||
|
||||
# Create display name - remove extension
|
||||
display_name = os.path.splitext(file)[0]
|
||||
|
||||
# Replace underscores with spaces for better readability
|
||||
display_name = display_name.replace('_', ' ')
|
||||
|
||||
# Add to menu items list
|
||||
self.menu_items.append((display_name, file_path))
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error loading disk directory: {e}")
|
||||
|
||||
def run(self):
|
||||
"""Run the menu system"""
|
||||
# Check if menu is empty
|
||||
if not self.menu_items:
|
||||
message = "No disk files found. Exiting."
|
||||
print(message)
|
||||
|
||||
# Speak the message
|
||||
self.init_speech()
|
||||
if self.speech_client:
|
||||
self.speak(message)
|
||||
# Wait for speech to finish (rough estimate)
|
||||
time.sleep(3)
|
||||
|
||||
# Clean up and exit properly
|
||||
self.cleanup(full_cleanup=True)
|
||||
sys.exit(0)
|
||||
|
||||
try:
|
||||
# Initialize curses
|
||||
self.stdscr = curses.initscr()
|
||||
self.curses_initialized = True
|
||||
curses.noecho()
|
||||
curses.cbreak()
|
||||
self.stdscr.keypad(True)
|
||||
|
||||
# Initial draw
|
||||
self.draw_menu()
|
||||
|
||||
# Welcome message
|
||||
self.speak(self.title)
|
||||
|
||||
# Wait for initial speech to finish before announcing first item
|
||||
time.sleep(1)
|
||||
self.announce_current_item(interrupt=False)
|
||||
|
||||
# Main loop
|
||||
while True:
|
||||
key = self.stdscr.getch()
|
||||
|
||||
# Stop any speech when a key is pressed
|
||||
self.stop_speech()
|
||||
|
||||
# Handle navigation
|
||||
if key == curses.KEY_UP:
|
||||
# Move to previous item
|
||||
self.current_index = (self.current_index - 1) % len(self.menu_items)
|
||||
self.draw_menu()
|
||||
self.announce_current_item()
|
||||
|
||||
elif key == curses.KEY_DOWN:
|
||||
# Move to next item
|
||||
self.current_index = (self.current_index + 1) % len(self.menu_items)
|
||||
self.draw_menu()
|
||||
self.announce_current_item()
|
||||
|
||||
elif key == curses.KEY_ENTER or key == 10 or key == 13: # Enter key
|
||||
self.execute_current_item()
|
||||
|
||||
elif key == ord('h') or key == ord('H'): # Help
|
||||
self.speak_help()
|
||||
|
||||
elif key == ord('['): # Decrease speech rate
|
||||
self.decrease_speech_rate()
|
||||
self.draw_menu()
|
||||
|
||||
elif key == ord(']'): # Increase speech rate
|
||||
self.increase_speech_rate()
|
||||
self.draw_menu()
|
||||
|
||||
elif key == 27 or key == ord('q') or key == ord('Q'): # Esc or Q
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
# End curses in case of error
|
||||
if self.curses_initialized:
|
||||
try:
|
||||
curses.endwin()
|
||||
except:
|
||||
pass
|
||||
print(f"An error occurred: {e}")
|
||||
finally:
|
||||
# Clean up - safe to call even if curses wasn't initialized
|
||||
self.cleanup(full_cleanup=True)
|
||||
|
||||
|
||||
# Run the menu
|
||||
if __name__ == "__main__":
|
||||
# Create the menu
|
||||
menu = VoicedDiskMenu()
|
||||
|
||||
# Load disk files from the Apple IIe disks directory
|
||||
menu.load_disks_from_directory("~/.local/games/apple2e/disks/")
|
||||
|
||||
# Run the menu
|
||||
menu.run()
|
||||
Executable
+33
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
check_usb_port() {
|
||||
usb_path=$(udevadm info --query=path --name="$device")
|
||||
# Check the bus this USB device is on
|
||||
bus_num=$(echo "$usb_path" | grep -o 'usb[0-9]*' | grep -o '[0-9]*' | head -n1)
|
||||
# Read the speed from sysfs
|
||||
speed_file="/sys/bus/usb/devices/usb${bus_num}/speed"
|
||||
if [[ -f "$speed_file" ]]; then
|
||||
speed=$(cat "$speed_file")
|
||||
if [[ $speed -lt 1000 ]]; then
|
||||
spd-say -Cw "Warning. USB 2.0 detected. Please power off the Pi and move the USB drive to a USB 3 port for better performance."
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
# set performance
|
||||
echo performance | sudo tee /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor
|
||||
|
||||
# Find the USB bus speed for the boot drive
|
||||
device=$(findmnt / -o SOURCE -n | sed 's/[0-9]*$//')
|
||||
if [[ "$device" == /dev/sd* ]]; then
|
||||
check_usb_port
|
||||
fi
|
||||
|
||||
# File system trim
|
||||
rootPartition=$(findmnt / -o SOURCE -n)
|
||||
if [[ $(lsblk -b --discard $rootPartition | awk 'NR==2 { print $NF }') -gt 0 ]]; then
|
||||
if ! systemctl is-enabled fstrim.timer ; then
|
||||
sudo systemctl enable fstrim.timer
|
||||
fi
|
||||
fi
|
||||
Executable
+931
@@ -0,0 +1,931 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Self-voiced Terminal Menu System for Stormux game image
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import threading
|
||||
import signal
|
||||
import curses
|
||||
import subprocess
|
||||
import speechd # Python bindings for Speech Dispatcher
|
||||
import configparser
|
||||
import pathlib
|
||||
import re
|
||||
import simpleaudio as sa
|
||||
|
||||
class VoicedMenu:
|
||||
def __init__(self, title="Stormux Game Menu"):
|
||||
self.title = title
|
||||
self.menuSections = {} # Dictionary to hold sections and their items
|
||||
self.sectionNames = [] # List to maintain section order
|
||||
self.currentSection = 0 # Index of current section
|
||||
self.currentItemIndices = {} # Current item index for each section
|
||||
self.stdscr = None
|
||||
|
||||
# System services dictionary - maps friendly names to service names
|
||||
self.systemMenuServices = {
|
||||
'Braille': 'brltty.path',
|
||||
'D L N A Server': 'minidlna.service',
|
||||
'Fenrir Screen Reader': 'fenrirscreenreader-tty.service',
|
||||
'Bluetooth': 'bluetooth.service',
|
||||
'SSH': 'sshd.service'
|
||||
}
|
||||
|
||||
# Config settings
|
||||
self.configDir = os.path.expanduser("~/.config/stormux")
|
||||
self.configFile = os.path.join(self.configDir, "game_launcher.conf")
|
||||
self.config = configparser.ConfigParser()
|
||||
|
||||
# Default settings
|
||||
self.speechRate = 0 # Normal speech rate (0 is default in speechd)
|
||||
self.volume = 50 # Default volume level
|
||||
|
||||
# Load settings
|
||||
self.load_settings()
|
||||
|
||||
# Initialize speech client
|
||||
self.speechClient = None
|
||||
self.init_speech()
|
||||
|
||||
# Track playing sound
|
||||
self.currentSound = None
|
||||
|
||||
def init_speech(self):
|
||||
"""Initialize the speech client"""
|
||||
try:
|
||||
self.speechClient = speechd.SSIPClient('stormux_menu')
|
||||
self.speechClient.set_priority(speechd.Priority.IMPORTANT)
|
||||
self.speechClient.set_punctuation(speechd.PunctuationMode.SOME)
|
||||
|
||||
# Apply speech rate from settings
|
||||
self.speechClient.set_rate(self.speechRate)
|
||||
except Exception as e:
|
||||
print(f"Could not initialize speech: {e}")
|
||||
# Fallback to None - the speak method will handle this
|
||||
|
||||
def load_settings(self):
|
||||
"""Load settings from config file"""
|
||||
# Create default settings if they don't exist
|
||||
if not os.path.exists(self.configFile):
|
||||
self.save_settings()
|
||||
return
|
||||
|
||||
try:
|
||||
self.config.read(self.configFile)
|
||||
|
||||
# Load speech settings
|
||||
if 'Speech' in self.config:
|
||||
self.speechRate = self.config.getint('Speech', 'rate', fallback=0)
|
||||
|
||||
# Load volume settings
|
||||
if 'Volume' in self.config:
|
||||
self.volume = self.config.getint('Volume', 'level', fallback=50)
|
||||
except Exception as e:
|
||||
print(f"Error loading settings: {e}")
|
||||
# If loading fails, we'll use default values
|
||||
|
||||
def save_settings(self):
|
||||
"""Save settings to config file"""
|
||||
# Ensure config directory exists
|
||||
os.makedirs(self.configDir, exist_ok=True)
|
||||
|
||||
# Update config object
|
||||
if 'Speech' not in self.config:
|
||||
self.config['Speech'] = {}
|
||||
|
||||
self.config['Speech']['rate'] = str(self.speechRate)
|
||||
|
||||
# Save volume settings
|
||||
if 'Volume' not in self.config:
|
||||
self.config['Volume'] = {}
|
||||
|
||||
self.config['Volume']['level'] = str(self.volume)
|
||||
|
||||
# Write to file
|
||||
try:
|
||||
with open(self.configFile, 'w') as f:
|
||||
self.config.write(f)
|
||||
except Exception as e:
|
||||
print(f"Error saving settings: {e}")
|
||||
|
||||
def increase_speech_rate(self):
|
||||
"""Increase speech rate"""
|
||||
self.speechRate = min(100, self.speechRate + 10) # Max is 100
|
||||
if self.speechClient:
|
||||
try:
|
||||
self.speechClient.set_rate(self.speechRate)
|
||||
self.speak(f"Speech rate: {self.speechRate}")
|
||||
except Exception as e:
|
||||
print(f"Error adjusting speech rate: {e}")
|
||||
|
||||
# Save the new setting
|
||||
self.save_settings()
|
||||
|
||||
def decrease_speech_rate(self):
|
||||
"""Decrease speech rate"""
|
||||
self.speechRate = max(-100, self.speechRate - 10) # Min is -100
|
||||
if self.speechClient:
|
||||
try:
|
||||
self.speechClient.set_rate(self.speechRate)
|
||||
self.speak(f"Speech rate: {self.speechRate}")
|
||||
except Exception as e:
|
||||
print(f"Error adjusting speech rate: {e}")
|
||||
|
||||
# Save the new setting
|
||||
self.save_settings()
|
||||
|
||||
def get_current_volume(self):
|
||||
"""Get the current system volume percentage"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['pactl', 'get-sink-volume', '@DEFAULT_SINK@'],
|
||||
capture_output=True, text=True, check=True
|
||||
)
|
||||
output = result.stdout
|
||||
# Extract percentage from output like "Volume: front-left: 27111 / 41% / -23.00 dB"
|
||||
match = re.search(r'(\d+)%', output)
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
return self.volume # Return saved volume if parsing fails
|
||||
except Exception as e:
|
||||
print(f"Error getting volume: {e}")
|
||||
return self.volume # Return saved volume if command fails
|
||||
|
||||
def set_volume(self, volumePercent):
|
||||
"""Set the system volume to the specified percentage"""
|
||||
# Ensure volume is between 0 and 150%
|
||||
volumePercent = max(0, min(150, volumePercent))
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
['pactl', 'set-sink-volume', '@DEFAULT_SINK@', f'{volumePercent}%'],
|
||||
check=True
|
||||
)
|
||||
self.volume = volumePercent
|
||||
self.save_settings()
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error setting volume: {e}")
|
||||
return False
|
||||
|
||||
def increase_volume(self):
|
||||
"""Increase the system volume by 5%"""
|
||||
currentVolume = self.get_current_volume()
|
||||
newVolume = min(150, currentVolume + 5) # Max 150%
|
||||
if self.set_volume(newVolume):
|
||||
self.speak(f"Volume {newVolume} percent")
|
||||
self.draw_menu()
|
||||
|
||||
def decrease_volume(self):
|
||||
"""Decrease the system volume by 5%"""
|
||||
currentVolume = self.get_current_volume()
|
||||
newVolume = max(0, currentVolume - 5) # Min 0%
|
||||
if self.set_volume(newVolume):
|
||||
self.speak(f"Volume {newVolume} percent")
|
||||
self.draw_menu()
|
||||
|
||||
def play_sound(self, soundName):
|
||||
"""Play a sound effect using simpleaudio with ability to cancel previous sounds"""
|
||||
try:
|
||||
# Cancel any currently playing sound
|
||||
self.stop_sound()
|
||||
|
||||
soundsDir = "/usr/share/sounds/stormux"
|
||||
soundFile = os.path.join(soundsDir, f"{soundName}.wav")
|
||||
|
||||
# Check if the file exists
|
||||
if not os.path.exists(soundFile):
|
||||
print(f"Sound file not found: {soundFile}")
|
||||
return False
|
||||
|
||||
# Play the sound and store the play_obj
|
||||
waveObj = sa.WaveObject.from_wave_file(soundFile)
|
||||
self.currentSound = waveObj.play()
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error playing sound {soundName}: {e}")
|
||||
return False
|
||||
|
||||
def stop_sound(self):
|
||||
"""Stop any currently playing sound"""
|
||||
if self.currentSound is not None and self.currentSound.is_playing():
|
||||
self.currentSound.stop()
|
||||
self.currentSound = None
|
||||
|
||||
def check_service_status(self, serviceName):
|
||||
"""Check if a system service is active"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['systemctl', 'is-active', serviceName],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
return result.stdout.strip() == 'active'
|
||||
except Exception as e:
|
||||
print(f"Error checking {serviceName} status: {e}")
|
||||
return False
|
||||
|
||||
def toggle_service(self, friendlyName):
|
||||
"""Toggle a system service on/off"""
|
||||
# Get the actual service name from the dictionary
|
||||
serviceName = self.systemMenuServices.get(friendlyName)
|
||||
if not serviceName:
|
||||
print(f"Unknown service: {friendlyName}")
|
||||
return
|
||||
|
||||
isActive = self.check_service_status(serviceName)
|
||||
|
||||
# Clean up curses before running the command with sudo
|
||||
curses.endwin()
|
||||
|
||||
# Clean up speech client
|
||||
if self.speechClient:
|
||||
self.speechClient.close()
|
||||
self.speechClient = None
|
||||
|
||||
# Execute the appropriate command based on current status
|
||||
action = ""
|
||||
if serviceName not in ["fenrirscreenreader-tty.service", "minidlna.service"]:
|
||||
action = "disable" if isActive else "enable"
|
||||
else:
|
||||
action = "stop" if isActive else "start"
|
||||
|
||||
command = f"sudo systemctl {action} {serviceName} --now"
|
||||
|
||||
try:
|
||||
os.system(command)
|
||||
print(f"{friendlyName} {action}d successfully")
|
||||
except Exception as e:
|
||||
print(f"Error {action}ing {friendlyName}: {e}")
|
||||
|
||||
# Restart speech client
|
||||
self.init_speech()
|
||||
|
||||
# Restart curses
|
||||
self.stdscr = curses.initscr()
|
||||
curses.noecho()
|
||||
curses.cbreak()
|
||||
self.stdscr.keypad(True)
|
||||
|
||||
# Announce the action
|
||||
self.speak(f"{friendlyName} {action}d")
|
||||
|
||||
# Update the menu with the new service status
|
||||
self.update_service_menu_items()
|
||||
|
||||
# Update Bluetooth menu items if Bluetooth service was toggled
|
||||
if friendlyName == "Bluetooth":
|
||||
self.update_bluetooth_menu_items()
|
||||
|
||||
# Redraw the menu
|
||||
self.draw_menu()
|
||||
|
||||
def toggle_screen(self, screenType):
|
||||
"""Toggle between HDMI screen and headless mode"""
|
||||
# Remove any existing configuration
|
||||
try:
|
||||
os.system("sudo rm /etc/X11/xorg.conf.d/10-*.conf")
|
||||
|
||||
# Copy the appropriate configuration file
|
||||
if screenType == "headless":
|
||||
configFile = "/home/stormux/.local/files/10-headless.conf"
|
||||
message = "HDMI Screen disabled."
|
||||
else:
|
||||
configFile = "/home/stormux/.local/files/10-screen.conf"
|
||||
message = "HDMI Screen enabled."
|
||||
|
||||
# Copy the configuration file
|
||||
os.system(f"sudo cp {configFile} /etc/X11/xorg.conf.d/")
|
||||
|
||||
self.speak(message, interrupt=False)
|
||||
|
||||
except Exception as e:
|
||||
message = f"Error changing screen configuration: {e}"
|
||||
print(message)
|
||||
self.speak(message)
|
||||
|
||||
def update_service_menu_items(self):
|
||||
"""Update all service-related menu items based on their current status"""
|
||||
# Remove any existing service items
|
||||
if "System" in self.menuSections:
|
||||
# Find and remove any service-related items
|
||||
self.menuSections["System"] = [item for item in self.menuSections["System"]
|
||||
if not any(service in item[0] for service in self.systemMenuServices.keys())]
|
||||
|
||||
# Add the appropriate items based on current status for each service
|
||||
for friendlyName, serviceName in self.systemMenuServices.items():
|
||||
isActive = self.check_service_status(serviceName)
|
||||
if isActive:
|
||||
self.add_item("System", f"Disable {friendlyName}",
|
||||
lambda fn=friendlyName: self.toggle_service(fn))
|
||||
else:
|
||||
self.add_item("System", f"Enable {friendlyName}",
|
||||
lambda fn=friendlyName: self.toggle_service(fn))
|
||||
|
||||
def update_bluetooth_menu_items(self):
|
||||
"""Update Bluetooth-related menu items in Accessories section"""
|
||||
if "Accessories" in self.menuSections:
|
||||
# Remove any existing Bluetooth-related items
|
||||
self.menuSections["Accessories"] = [item for item in self.menuSections["Accessories"]
|
||||
if "Bluetooth" not in item[0]]
|
||||
|
||||
# Add Bluetooth management item only if Bluetooth is enabled
|
||||
if self.check_service_status('bluetooth.service'):
|
||||
self.add_item("Accessories", "Manage Bluetooth Devices",
|
||||
"GAME=blueman-manager startx")
|
||||
|
||||
def add_section(self, sectionName):
|
||||
"""Add a new section to the menu"""
|
||||
if sectionName not in self.menuSections:
|
||||
self.menuSections[sectionName] = []
|
||||
self.sectionNames.append(sectionName)
|
||||
self.currentItemIndices[sectionName] = 0
|
||||
|
||||
def add_item(self, sectionName, name, command):
|
||||
"""Add a menu item to a specific section"""
|
||||
# Create section if it doesn't exist
|
||||
if sectionName not in self.menuSections:
|
||||
self.add_section(sectionName)
|
||||
|
||||
self.menuSections[sectionName].append((name, command))
|
||||
|
||||
def speak(self, text, interrupt=True):
|
||||
"""Speak the given text with option to interrupt existing speech"""
|
||||
if self.speechClient is None:
|
||||
return
|
||||
|
||||
try:
|
||||
if interrupt:
|
||||
self.stop_speech()
|
||||
|
||||
self.speechClient.speak(text)
|
||||
except Exception as e:
|
||||
# If speech fails, try to reinitialize and try once more
|
||||
try:
|
||||
self.init_speech()
|
||||
if self.speechClient:
|
||||
self.speechClient.speak(text)
|
||||
except:
|
||||
# If reinitializing fails, just give up silently
|
||||
pass
|
||||
|
||||
def stop_speech(self):
|
||||
"""Stop any ongoing speech"""
|
||||
if self.speechClient is None:
|
||||
return
|
||||
|
||||
try:
|
||||
self.speechClient.cancel()
|
||||
except Exception as e:
|
||||
# If cancel fails, try to reinitialize
|
||||
self.init_speech()
|
||||
|
||||
def get_current_items(self):
|
||||
"""Get items from the current section"""
|
||||
currentSectionName = self.sectionNames[self.currentSection]
|
||||
return self.menuSections[currentSectionName]
|
||||
|
||||
def get_current_item_index(self):
|
||||
"""Get the current item index in the current section"""
|
||||
currentSectionName = self.sectionNames[self.currentSection]
|
||||
return self.currentItemIndices[currentSectionName]
|
||||
|
||||
def set_current_item_index(self, index):
|
||||
"""Set the current item index for the current section"""
|
||||
currentSectionName = self.sectionNames[self.currentSection]
|
||||
self.currentItemIndices[currentSectionName] = index
|
||||
|
||||
def announce_current_section(self, interrupt=True):
|
||||
"""Announce the currently selected section"""
|
||||
if 0 <= self.currentSection < len(self.sectionNames):
|
||||
sectionName = self.sectionNames[self.currentSection]
|
||||
self.speak(sectionName, interrupt=interrupt)
|
||||
|
||||
def announce_current_item(self, interrupt=True):
|
||||
"""Announce the currently selected menu item"""
|
||||
if len(self.sectionNames) > 0:
|
||||
items = self.get_current_items()
|
||||
index = self.get_current_item_index()
|
||||
|
||||
if 0 <= index < len(items):
|
||||
name = items[index][0]
|
||||
self.speak(name, interrupt=interrupt)
|
||||
|
||||
def execute_current_item(self):
|
||||
"""Execute the currently selected menu item"""
|
||||
if len(self.sectionNames) > 0:
|
||||
items = self.get_current_items()
|
||||
index = self.get_current_item_index()
|
||||
|
||||
if 0 <= index < len(items):
|
||||
name, command = items[index]
|
||||
|
||||
# Announce we're launching the program
|
||||
self.speak(f"Launching {name}")
|
||||
|
||||
# Check if command is a function (for service toggles and other functions)
|
||||
if callable(command):
|
||||
command()
|
||||
return
|
||||
|
||||
# Save current terminal state before any changes
|
||||
savedTerminalState = None
|
||||
try:
|
||||
result = subprocess.run(['stty', '-g'], capture_output=True, text=True, check=True)
|
||||
savedTerminalState = result.stdout.strip()
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not save terminal state: {e}")
|
||||
|
||||
# Cleanup before running the command
|
||||
self.stop_speech()
|
||||
|
||||
# Close speech client
|
||||
if self.speechClient:
|
||||
try:
|
||||
self.speechClient.close()
|
||||
except:
|
||||
pass
|
||||
self.speechClient = None
|
||||
|
||||
# Curses cleanup - be thorough
|
||||
if self.stdscr:
|
||||
try:
|
||||
curses.nocbreak()
|
||||
self.stdscr.keypad(False)
|
||||
curses.echo()
|
||||
curses.endwin()
|
||||
except:
|
||||
pass
|
||||
self.stdscr = None
|
||||
|
||||
# Complete terminal reset to ensure clean state for games
|
||||
try:
|
||||
subprocess.run(['reset'], check=False, stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Ensure we're in a new process group to avoid signal conflicts
|
||||
# Execute the command with proper terminal isolation
|
||||
try:
|
||||
# For applications that need dialog/curses, we need to ensure they
|
||||
# become the controlling process with full terminal access
|
||||
# Use os.system with proper terminal restoration as it gives
|
||||
# the subprocess complete control over the terminal
|
||||
|
||||
# First, save our current process group
|
||||
original_pgrp = os.getpgrp()
|
||||
|
||||
# Execute using os.system which gives the subprocess complete terminal control
|
||||
exit_code = os.system(command)
|
||||
|
||||
# Try to regain terminal control after the subprocess exits
|
||||
try:
|
||||
# Get the current terminal file descriptor
|
||||
tty_fd = os.open('/dev/tty', os.O_RDWR)
|
||||
# Try to make our process group the foreground group again
|
||||
os.tcsetpgrp(tty_fd, original_pgrp)
|
||||
os.close(tty_fd)
|
||||
except:
|
||||
# If we can't regain control, that's okay
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error launching {name}: {e}")
|
||||
|
||||
# Another terminal reset after game exits to clean up any changes
|
||||
try:
|
||||
subprocess.run(['reset'], check=False, stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Restore the saved terminal state if we have it
|
||||
if savedTerminalState:
|
||||
try:
|
||||
subprocess.run(['stty', savedTerminalState], check=False)
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not restore terminal state: {e}")
|
||||
|
||||
# Brief delay to let terminal stabilize
|
||||
time.sleep(0.5)
|
||||
|
||||
# Reinitialize speech and display
|
||||
self.init_speech()
|
||||
|
||||
# Reinitialize curses with better error handling
|
||||
try:
|
||||
self.stdscr = curses.initscr()
|
||||
curses.noecho()
|
||||
curses.cbreak()
|
||||
self.stdscr.keypad(True)
|
||||
self.stdscr.clear()
|
||||
self.stdscr.refresh()
|
||||
except Exception as e:
|
||||
print(f"Error reinitializing display: {e}")
|
||||
# Try a more aggressive recovery
|
||||
try:
|
||||
curses.endwin()
|
||||
time.sleep(1)
|
||||
self.stdscr = curses.initscr()
|
||||
curses.noecho()
|
||||
curses.cbreak()
|
||||
self.stdscr.keypad(True)
|
||||
self.stdscr.clear()
|
||||
self.stdscr.refresh()
|
||||
except:
|
||||
print("Could not recover display. Please restart the menu.")
|
||||
sys.exit(1)
|
||||
|
||||
# Update service menu items and redraw
|
||||
self.update_service_menu_items()
|
||||
self.draw_menu()
|
||||
|
||||
# Let the user know the menu is activated
|
||||
self.speak("Game menu activated")
|
||||
|
||||
def speak_help(self):
|
||||
"""Speak help information"""
|
||||
helpText = """
|
||||
Navigation controls:
|
||||
Up arrow: Previous menu item.
|
||||
Down arrow: Next menu item.
|
||||
Left arrow: Previous section.
|
||||
Right arrow: Next section.
|
||||
Enter: Launch selected item.
|
||||
H key: Hear these instructions again.
|
||||
Left bracket: Decrease speech rate.
|
||||
Right bracket: Increase speech rate.
|
||||
9 key: Decrease volume.
|
||||
0 key: Increase volume.
|
||||
Escape: Refresh the menu.
|
||||
Q: Exit the menu.
|
||||
Any key will interrupt speech.
|
||||
"""
|
||||
self.speak(helpText)
|
||||
|
||||
def draw_menu(self):
|
||||
"""Draw the menu on the screen"""
|
||||
self.stdscr.clear()
|
||||
h, w = self.stdscr.getmaxyx()
|
||||
|
||||
# Draw title
|
||||
title = f" {self.title} "
|
||||
x = max(0, w // 2 - len(title) // 2)
|
||||
self.stdscr.addstr(1, x, title, curses.A_BOLD)
|
||||
|
||||
# Draw help line
|
||||
helpText = "Ãvigate | Enter: Select | H: Help | [ ] Rate | 9 0 Volume | Esc: Refresh"
|
||||
x = max(0, w // 2 - len(helpText) // 2)
|
||||
self.stdscr.addstr(3, x, helpText)
|
||||
|
||||
# Draw current section
|
||||
if len(self.sectionNames) > 0:
|
||||
currentSectionName = self.sectionNames[self.currentSection]
|
||||
sectionText = f"== {currentSectionName} =="
|
||||
x = max(0, w // 2 - len(sectionText) // 2)
|
||||
self.stdscr.addstr(5, x, sectionText, curses.A_BOLD)
|
||||
|
||||
# Draw menu items for current section
|
||||
items = self.get_current_items()
|
||||
currentItemIndex = self.get_current_item_index()
|
||||
|
||||
for i, (name, _) in enumerate(items):
|
||||
y = i + 7 # Start items 2 lines below section header
|
||||
if y < h - 1: # Ensure we don't draw outside the window
|
||||
# Highlight the selected item
|
||||
if i == currentItemIndex:
|
||||
text = f" > {name} "
|
||||
attr = curses.A_REVERSE
|
||||
else:
|
||||
text = f" {name} "
|
||||
attr = curses.A_NORMAL
|
||||
|
||||
x = max(0, w // 2 - len(text) // 2)
|
||||
self.stdscr.addstr(y, x, text, attr)
|
||||
|
||||
# Draw speech rate indicator
|
||||
rateText = f"Speech Rate: {self.speechRate}"
|
||||
self.stdscr.addstr(h-2, 2, rateText)
|
||||
|
||||
# Draw volume indicator
|
||||
volumeText = f"Volume: {self.get_current_volume()}%"
|
||||
self.stdscr.addstr(h-2, w-len(volumeText)-2, volumeText)
|
||||
|
||||
self.stdscr.refresh()
|
||||
|
||||
def cleanup(self):
|
||||
"""Clean up resources before exiting"""
|
||||
# Stop any speech
|
||||
self.stop_speech()
|
||||
|
||||
# Close speech client
|
||||
if self.speechClient:
|
||||
try:
|
||||
self.speechClient.close()
|
||||
except:
|
||||
pass
|
||||
self.speechClient = None
|
||||
|
||||
# Restore terminal settings
|
||||
curses.nocbreak()
|
||||
self.stdscr.keypad(False)
|
||||
curses.echo()
|
||||
curses.endwin()
|
||||
|
||||
def run(self):
|
||||
"""Run the menu system"""
|
||||
if not self.sectionNames:
|
||||
print("Menu is empty. Exiting.")
|
||||
return
|
||||
|
||||
try:
|
||||
# Initialize curses
|
||||
self.stdscr = curses.initscr()
|
||||
curses.noecho() # Don't echo keypresses
|
||||
curses.cbreak() # React to keys instantly
|
||||
self.stdscr.keypad(True) # Enable special keys
|
||||
|
||||
# Update all service menu items based on current status
|
||||
self.update_service_menu_items()
|
||||
|
||||
# Update Bluetooth menu items based on current status
|
||||
self.update_bluetooth_menu_items()
|
||||
|
||||
# Initial draw
|
||||
self.draw_menu()
|
||||
|
||||
# Welcome message - don't interrupt this initial speech
|
||||
self.speak(f"Welcome to {self.title}. Use left and right arrows to navigate sections. Up and down for items. Press H for help.")
|
||||
|
||||
# Wait for initial speech to finish before announcing section
|
||||
time.sleep(1)
|
||||
|
||||
# Announce initial section and item without interrupting welcome speech
|
||||
self.announce_current_section(interrupt=False)
|
||||
time.sleep(0.5) # Wait before announcing first item
|
||||
self.announce_current_item(interrupt=False)
|
||||
|
||||
# Main loop
|
||||
while True:
|
||||
key = self.stdscr.getch()
|
||||
|
||||
# Stop any speech when a key is pressed
|
||||
self.stop_speech()
|
||||
|
||||
# Handle navigation
|
||||
if key == curses.KEY_UP:
|
||||
# Move to previous item in current section
|
||||
items = self.get_current_items()
|
||||
if items:
|
||||
prevIndex = self.get_current_item_index()
|
||||
self.set_current_item_index((prevIndex - 1) % len(items))
|
||||
newIndex = self.get_current_item_index()
|
||||
self.draw_menu()
|
||||
# Play sound if selection actually changed
|
||||
if newIndex != prevIndex:
|
||||
self.play_sound("menu_move")
|
||||
self.announce_current_item()
|
||||
|
||||
elif key == curses.KEY_DOWN:
|
||||
# Move to next item in current section
|
||||
items = self.get_current_items()
|
||||
if items:
|
||||
prevIndex = self.get_current_item_index()
|
||||
self.set_current_item_index((prevIndex + 1) % len(items))
|
||||
newIndex = self.get_current_item_index()
|
||||
self.draw_menu()
|
||||
# Play sound if selection actually changed
|
||||
if newIndex != prevIndex:
|
||||
self.play_sound("menu_move")
|
||||
self.announce_current_item()
|
||||
|
||||
elif key == curses.KEY_LEFT:
|
||||
# Move to previous section
|
||||
if self.sectionNames:
|
||||
prevSection = self.currentSection
|
||||
self.currentSection = (self.currentSection - 1) % len(self.sectionNames)
|
||||
self.draw_menu()
|
||||
# Play category change sound if section actually changed
|
||||
if prevSection != self.currentSection:
|
||||
self.play_sound("menu_category")
|
||||
# Announce section and current item without interruption between them
|
||||
self.announce_current_section()
|
||||
time.sleep(0.5) # Brief pause between section and item announcement
|
||||
self.announce_current_item(interrupt=False) # Don't interrupt the section announcement
|
||||
|
||||
elif key == curses.KEY_RIGHT:
|
||||
# Move to next section
|
||||
if self.sectionNames:
|
||||
prevSection = self.currentSection
|
||||
self.currentSection = (self.currentSection + 1) % len(self.sectionNames)
|
||||
self.draw_menu()
|
||||
# Play category change sound if section actually changed
|
||||
if prevSection != self.currentSection:
|
||||
self.play_sound("menu_category")
|
||||
# Announce section and current item without interruption between them
|
||||
self.announce_current_section()
|
||||
time.sleep(0.5) # Brief pause between section and item announcement
|
||||
self.announce_current_item(interrupt=False) # Don't interrupt the section announcement
|
||||
|
||||
elif key == curses.KEY_ENTER or key == 10 or key == 13: # Enter key
|
||||
self.play_sound("menu_select")
|
||||
self.execute_current_item()
|
||||
|
||||
elif key == ord('h') or key == ord('H'): # Help
|
||||
self.speak_help()
|
||||
|
||||
elif key == ord('['): # Decrease speech rate
|
||||
self.decrease_speech_rate()
|
||||
self.draw_menu()
|
||||
|
||||
elif key == ord(']'): # Increase speech rate
|
||||
self.increase_speech_rate()
|
||||
self.draw_menu()
|
||||
|
||||
elif key == ord('9'): # Decrease volume
|
||||
self.decrease_volume()
|
||||
|
||||
elif key == ord('0'): # Increase volume
|
||||
self.increase_volume()
|
||||
|
||||
elif key == 27: # Esc key - restart the application
|
||||
try:
|
||||
# Announce restart first while speech still works
|
||||
self.speak("Restarting menu")
|
||||
|
||||
# Wait for speech to complete
|
||||
time.sleep(1)
|
||||
|
||||
# Now clean up resources
|
||||
self.cleanup()
|
||||
|
||||
# Try to kill speech-dispatcher if needed
|
||||
subprocess.run(["sudo", "killall", "speech-dispatcher"], check=False)
|
||||
|
||||
# Restart the application
|
||||
os.execv(sys.argv[0], sys.argv)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error during restart: {e}")
|
||||
# If restart fails, we need to recover the UI
|
||||
self.stdscr = curses.initscr()
|
||||
curses.noecho()
|
||||
curses.cbreak()
|
||||
self.stdscr.keypad(True)
|
||||
self.draw_menu()
|
||||
except Exception as e:
|
||||
# End curses in case of error
|
||||
curses.endwin()
|
||||
print(f"An error occurred: {e}")
|
||||
finally:
|
||||
# Clean up
|
||||
self.cleanup()
|
||||
|
||||
# Example usage
|
||||
if __name__ == "__main__":
|
||||
# Create the menu with sections
|
||||
menu = VoicedMenu(title="Stormux Gaming Menu")
|
||||
|
||||
# Add arcade section
|
||||
menu.add_section("Arcade")
|
||||
menu.add_item("Arcade", "Audio Disc", "GAME='Audio Disc' startx")
|
||||
menu.add_item("Arcade", "BallBouncer", "GAME=BallBouncer startx")
|
||||
menu.add_item("Arcade", "Battle of the Hunter", "GAME='Battle of the Hunter' startx")
|
||||
menu.add_item("Arcade", "Challenge of the Horse", "GAME='Challenge of the Horse' startx")
|
||||
menu.add_item("Arcade", "Clashes of the Sky", "GAME='Clashes of the Sky' startx")
|
||||
menu.add_item("Arcade", "Constant Motion", "GAME='Constant Motion' startx")
|
||||
menu.add_item("Arcade", "Crazy Party", "GAME='Crazy Party' startx")
|
||||
menu.add_item("Arcade", "Doom", "GAME=Doom startx")
|
||||
menu.add_item("Arcade", "Haunted House", "GAME='Haunted House' startx")
|
||||
menu.add_item("Arcade", "Haunted Party", "GAME='Haunted Party' startx")
|
||||
menu.add_item("Arcade", "kaskade", "GAME=Kaskade startx")
|
||||
menu.add_item("Arcade", "Oh Shitt", "GAME='Oh Shit' startx")
|
||||
menu.add_item("Arcade", "Q9 Action Game", "GAME='Q9 Action Game' startx")
|
||||
menu.add_item("Arcade", "River Raiders", "GAME='River Raiders' startx")
|
||||
menu.add_item("Arcade", "Scramble", "GAME=Scramble startx")
|
||||
menu.add_item("Arcade", "Screaming Strike 2", "GAME='Screaming Strike 2' startx")
|
||||
menu.add_item("Arcade", "Scrolling Battles", "GAME='Scrolling Battles' startx")
|
||||
menu.add_item("Arcade", "Shooter", "GAME=Shooter startx")
|
||||
menu.add_item("Arcade", "Side Party", "GAME='Side Party' startx")
|
||||
menu.add_item("Arcade", "Skateboarder Pro", "GAME='Skateboarder Pro' startx")
|
||||
menu.add_item("Arcade", "Sketchbook (Your World)", "GAME='Sketchbook (Your World)' startx")
|
||||
menu.add_item("Arcade", "Super Egg Hunt", "GAME='Super Egg Hunt' startx")
|
||||
menu.add_item("Arcade", "Super Liam", "GAME='Super Liam' startx")
|
||||
menu.add_item("Arcade", "The Blind Swordsman", "GAME='The Blind Swordsman' startx")
|
||||
menu.add_item("Arcade", "The Tornado Chicken", "GAME='The Tornado Chicken' startx")
|
||||
menu.add_item("Arcade", "Toy Mania", "GAME='Toy Mania' startx")
|
||||
menu.add_item("Arcade", "Villains From Beyond", "GAME='Villains From Beyond' startx")
|
||||
menu.add_item("Arcade", "Wicked Quest", "GAME='Wicked Quest' startx")
|
||||
menu.add_item("Arcade", "Zombowl", "GAME='Zombowl' startx")
|
||||
|
||||
# Add board and card games section
|
||||
menu.add_section("Board and Card Games")
|
||||
menu.add_item("Board and Card Games", "RS Games", "GAME='RS Games' startx")
|
||||
|
||||
# Add emulators section
|
||||
menu.add_section("Emulators")
|
||||
menu.add_item("Emulators", "Apple 2e", "GAME='Apple 2e' startx")
|
||||
menu.add_item("Emulators", "Apple 2e with disk", "/usr/local/bin/apple_2e.py")
|
||||
menu.add_item("Emulators", "Bop It", "GAME='Bop It' startx")
|
||||
menu.add_item("Emulators", "Dosbox", "GAME=Dosbox startx")
|
||||
menu.add_item("Emulators", "Game Console Menu", "/usr/local/bin/rom_launcher.py")
|
||||
menu.add_item("Emulators", "Retro Arch", "GAME='Retro Arch' startx")
|
||||
|
||||
# Add MUD section
|
||||
menu.add_section("MUDs")
|
||||
menu.add_item("MUDs", "Alter Aeon", "GAME='Alter Aeon' /home/stormux/.clirc")
|
||||
menu.add_item("MUDs", "Empire MUD", "GAME='Empire MUD' /home/stormux/.clirc")
|
||||
menu.add_item("MUDs", "End of Time", "GAME='End of Time' /home/stormux/.clirc")
|
||||
menu.add_item("MUDs", "Kallisti MUD", "GAME='Kallisti MUD' /home/stormux/.clirc")
|
||||
|
||||
# Add racing section
|
||||
menu.add_section("Racing")
|
||||
menu.add_item("Racing", "Mach1", "GAME=Mach1 startx")
|
||||
menu.add_item("Racing", "Mine Racer", "GAME='Mine Racer' startx")
|
||||
menu.add_item("Racing", "Top Speed 3", "GAME=\"Top Speed 3\" startx")
|
||||
menu.add_item("Racing", "Wheels of Prio", "GAME=\"Wheels of Prio\" startx")
|
||||
|
||||
# Add rpg section
|
||||
menu.add_section("RPG")
|
||||
menu.add_item("RPG", "Bokurano Daibouken", "GAME='Bokurano Daibouken' startx")
|
||||
menu.add_item("RPG", "Bokurano Daibouken 2", "GAME='Bokurano Daibouken 2' startx")
|
||||
menu.add_item("RPG", "Bokurano Daibouken 3", "GAME='Bokurano Daibouken 3' startx")
|
||||
menu.add_item("RPG", "Fantasy Story 2", "GAME='Fantasy Story 2' startx")
|
||||
menu.add_item("RPG", "Manamon 2", "GAME='Manamon 2' startx")
|
||||
menu.add_item("RPG", "Shadow Line", "GAME='Shadow Line' startx")
|
||||
|
||||
# Add sports section
|
||||
menu.add_section("Sports")
|
||||
menu.add_item("Sports", "Golf", "GAME=Golf startx")
|
||||
menu.add_item("Sports", "Pong", "GAME=Pong startx")
|
||||
|
||||
# Add strategy section
|
||||
menu.add_section("Strategy")
|
||||
menu.add_item("Strategy", "SoundRTS", "GAME=SoundRTS startx")
|
||||
menu.add_item("Strategy", "Warsim", "/home/stormux/.Warsim")
|
||||
|
||||
# Add text games section
|
||||
menu.add_section("Text Games")
|
||||
menu.add_item("Text Games", "BPG", "GAME=BPG /home/stormux/.clirc")
|
||||
menu.add_item("Text Games", "Colossal Cave Adventure", "GAME=/usr/bin/adventure /home/stormux/.clirc")
|
||||
menu.add_item("Text Games", "Go Fish", "GAME=/usr/bin/gofish /home/stormux/.clirc")
|
||||
menu.add_item("Text Games", "RS Games", "GAME=\"RS Games\" /home/stormux/.clirc")
|
||||
menu.add_item("Text Games", "Slay the Text", "GAME=\"Slay the Text\" /home/stormux/.clirc")
|
||||
menu.add_item("Text Games", "Stationfall", "GAME=Stationfall /home/stormux/.clirc")
|
||||
menu.add_item("Text Games", "Planetfall", "GAME=Planetfall /home/stormux/.clirc")
|
||||
menu.add_item("Text Games", "The Hitchhiker's Guide to the Galaxy", "GAME=\"The Hitchhiker's Guide to the Galaxy\" /home/stormux/.clirc")
|
||||
menu.add_item("Text Games", "Upheaval", "GAME=Upheaval /home/stormux/.Upheaval")
|
||||
menu.add_item("Text Games", "Zork 1", "GAME='Zork 1' /home/stormux/.clirc")
|
||||
menu.add_item("Text Games", "Zork 2", "GAME='Zork 2' /home/stormux/.clirc")
|
||||
menu.add_item("Text Games", "Zork 3", "GAME='Zork 3' /home/stormux/.clirc")
|
||||
|
||||
# Add web section
|
||||
menu.add_section("Web")
|
||||
menu.add_item("Web", "Aliens", "GAME=https://files.jantrid.net/aliens// startx")
|
||||
menu.add_item("Web", "Echo Commander", "GAME=https://echo-commander.vercel.app/ startx")
|
||||
menu.add_item("Web", "Periphery Synthetic EP", "GAME=https://shiftbacktick.itch.io/periphery-synthetic-ep startx")
|
||||
menu.add_item("Web", "QuentinC Play Room", "GAME=https://qcsalon.net/ startx")
|
||||
menu.add_item("Web", "soundStrider", "GAME=https://shiftbacktick.itch.io/soundstrider startx")
|
||||
|
||||
# Add help and documentation section
|
||||
menu.add_section("Help and Documentation")
|
||||
menu.add_item("Help and Documentation", "Navigating Help Documentation", "GAME=~/Documents/navigating_help.md /home/stormux/.clirc")
|
||||
menu.add_item("Help and Documentation", "Menu Controls", "GAME=~/Documents/game_menu_controls.md /home/stormux/.clirc")
|
||||
menu.add_item("Help and Documentation", "Game Notes", "GAME=~/Documents/game_notes.md /home/stormux/.clirc")
|
||||
menu.add_item("Help and Documentation", "Music Player", "GAME=~/Documents/music_player.md /home/stormux/.clirc")
|
||||
menu.add_item("Help and Documentation", "Terminal for Advanced Users", "GAME=~/Documents/terminal.md /home/stormux/.clirc")
|
||||
menu.add_item("Help and Documentation", "D L N A Server", "GAME=~/Documents/dlna.md /home/stormux/.clirc")
|
||||
menu.add_item("Help and Documentation", "Changing the Voice", "GAME=~/Documents/voices.md /home/stormux/.clirc")
|
||||
menu.add_item("Help and Documentation", "Change Log", "GAME=~/Documents/change_log.md /home/stormux/.clirc")
|
||||
menu.add_item("Help and Documentation", "Contacting Stormux", "GAME=~/Documents/contact.md /home/stormux/.clirc")
|
||||
menu.add_item("Help and Documentation", "Get help on IRC", "GAME=IRC /home/stormux/.clirc")
|
||||
|
||||
# Add accessories section
|
||||
menu.add_section("Accessories")
|
||||
menu.add_item("Accessories", "Music Player", "/usr/local/bin/music_player.py")
|
||||
menu.add_item("Accessories", "Web Browser", "GAME=Brave startx")
|
||||
|
||||
# Add system section
|
||||
menu.add_section("System")
|
||||
|
||||
# Add installer only on x86_64
|
||||
import platform
|
||||
if platform.machine() == "x86_64":
|
||||
menu.add_item("System", "Install System to Hard Drive", "GAME=Calamares startx")
|
||||
|
||||
menu.add_item("System", "Internet Configuration", "GAME=\"Network Configuration\" /home/stormux/.clirc")
|
||||
menu.add_item("System", "Use HDMI Screen", lambda: menu.toggle_screen("screen"))
|
||||
menu.add_item("System", "Disable HDMI Screen", lambda: menu.toggle_screen("headless"))
|
||||
menu.add_item("System", "Set System Default Speech Rate", "/usr/local/bin/speechd_rate.py")
|
||||
menu.add_item("System", "Set Default Voice", "/usr/local/bin/set-voice.py")
|
||||
menu.add_item("System", "Upload Files", "/home/stormux/.local/upload_server/uploader.py")
|
||||
menu.add_item("System", "Restart: Can Take Several Minutes", "sudo reboot")
|
||||
menu.add_item("System", "Power Off: Wait 2 Minutes Before Disconnecting Power", "sudo poweroff")
|
||||
# Service menu items will be added dynamically in run() method via update_service_menu_items()
|
||||
menu.add_item("System", "Resize to fill empty space on disk", "sudo growpartfs $(df --output='source' / | tail -1)")
|
||||
|
||||
# Run the menu
|
||||
menu.run()
|
||||
Executable
+621
@@ -0,0 +1,621 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import threading
|
||||
import signal
|
||||
import curses
|
||||
import subprocess
|
||||
import speechd
|
||||
import configparser
|
||||
import pathlib
|
||||
import re
|
||||
import random
|
||||
|
||||
class VoicedMusicPlayer:
|
||||
def __init__(self, title="Stormux Music Player"):
|
||||
self.title = title
|
||||
self.menuSections = {}
|
||||
self.sectionNames = []
|
||||
self.currentSection = 0
|
||||
self.currentItemIndices = {}
|
||||
self.stdscr = None
|
||||
self.cursesInitialized = False
|
||||
self.hasItems = False
|
||||
|
||||
self.navigationStack = []
|
||||
self.currentView = "main"
|
||||
self.currentAlbumPath = None
|
||||
self.currentAlbumName = None
|
||||
|
||||
self.configDir = os.path.expanduser("~/.config/stormux")
|
||||
self.configFile = os.path.join(self.configDir, "music_player.conf")
|
||||
self.config = configparser.ConfigParser()
|
||||
|
||||
self.speechRate = 0
|
||||
self.randomize = False
|
||||
|
||||
self.musicExtensions = ['.mp3', '.flac', '.ogg', '.wav', '.opus']
|
||||
self.musicDir = os.path.expanduser("~/Music")
|
||||
|
||||
self.load_settings()
|
||||
|
||||
self.speechClient = None
|
||||
self.init_speech()
|
||||
|
||||
def init_speech(self):
|
||||
try:
|
||||
self.speechClient = speechd.SSIPClient("music_player")
|
||||
self.speechClient.set_priority(speechd.Priority.IMPORTANT)
|
||||
self.speechClient.set_punctuation(speechd.PunctuationMode.SOME)
|
||||
self.speechClient.set_rate(self.speechRate)
|
||||
except Exception as e:
|
||||
print(f"Could not initialize speech: {e}")
|
||||
|
||||
def load_settings(self):
|
||||
if not os.path.exists(self.configFile):
|
||||
self.save_settings()
|
||||
return
|
||||
|
||||
try:
|
||||
self.config.read(self.configFile)
|
||||
|
||||
if 'Speech' in self.config:
|
||||
self.speechRate = self.config.getint('Speech', 'rate', fallback=0)
|
||||
|
||||
if 'Player' in self.config:
|
||||
self.randomize = self.config.getboolean('Player', 'randomize', fallback=False)
|
||||
except Exception as e:
|
||||
print(f"Error loading settings: {e}")
|
||||
|
||||
def save_settings(self):
|
||||
os.makedirs(self.configDir, exist_ok=True)
|
||||
|
||||
if 'Speech' not in self.config:
|
||||
self.config['Speech'] = {}
|
||||
|
||||
if 'Player' not in self.config:
|
||||
self.config['Player'] = {}
|
||||
|
||||
self.config['Speech']['rate'] = str(self.speechRate)
|
||||
self.config['Player']['randomize'] = str(self.randomize)
|
||||
|
||||
try:
|
||||
with open(self.configFile, 'w') as f:
|
||||
self.config.write(f)
|
||||
except Exception as e:
|
||||
print(f"Error saving settings: {e}")
|
||||
|
||||
def toggle_randomize(self):
|
||||
self.randomize = not self.randomize
|
||||
self.save_settings()
|
||||
if self.randomize:
|
||||
self.speak("Random playback on")
|
||||
else:
|
||||
self.speak("Sequential playback")
|
||||
|
||||
def increase_speech_rate(self):
|
||||
self.speechRate = min(100, self.speechRate + 10)
|
||||
if self.speechClient:
|
||||
try:
|
||||
self.speechClient.set_rate(self.speechRate)
|
||||
self.speak(f"Speech rate: {self.speechRate}")
|
||||
except Exception as e:
|
||||
print(f"Error adjusting speech rate: {e}")
|
||||
|
||||
self.save_settings()
|
||||
|
||||
def decrease_speech_rate(self):
|
||||
self.speechRate = max(-100, self.speechRate - 10)
|
||||
if self.speechClient:
|
||||
try:
|
||||
self.speechClient.set_rate(self.speechRate)
|
||||
self.speak(f"Speech rate: {self.speechRate}")
|
||||
except Exception as e:
|
||||
print(f"Error adjusting speech rate: {e}")
|
||||
|
||||
self.save_settings()
|
||||
|
||||
def add_section(self, sectionName):
|
||||
if sectionName not in self.menuSections:
|
||||
self.menuSections[sectionName] = []
|
||||
self.sectionNames.append(sectionName)
|
||||
self.currentItemIndices[sectionName] = 0
|
||||
|
||||
def add_item(self, sectionName, name, command, isDirectory=False, directoryPath=None):
|
||||
if sectionName not in self.menuSections:
|
||||
self.add_section(sectionName)
|
||||
|
||||
itemData = (name, command, isDirectory, directoryPath)
|
||||
self.menuSections[sectionName].append(itemData)
|
||||
self.hasItems = True
|
||||
|
||||
def speak(self, text, interrupt=True):
|
||||
if self.speechClient is None:
|
||||
return
|
||||
|
||||
try:
|
||||
if interrupt:
|
||||
self.stop_speech()
|
||||
|
||||
self.speechClient.speak(text)
|
||||
except Exception as e:
|
||||
try:
|
||||
self.init_speech()
|
||||
if self.speechClient:
|
||||
self.speechClient.speak(text)
|
||||
except:
|
||||
pass
|
||||
|
||||
def stop_speech(self):
|
||||
if self.speechClient is None:
|
||||
return
|
||||
|
||||
try:
|
||||
self.speechClient.cancel()
|
||||
except Exception as e:
|
||||
self.init_speech()
|
||||
|
||||
def get_current_items(self):
|
||||
if not self.sectionNames:
|
||||
return []
|
||||
currentSectionName = self.sectionNames[self.currentSection]
|
||||
return self.menuSections[currentSectionName]
|
||||
|
||||
def get_current_item_index(self):
|
||||
if not self.sectionNames:
|
||||
return 0
|
||||
currentSectionName = self.sectionNames[self.currentSection]
|
||||
return self.currentItemIndices[currentSectionName]
|
||||
|
||||
def set_current_item_index(self, index):
|
||||
if not self.sectionNames:
|
||||
return
|
||||
currentSectionName = self.sectionNames[self.currentSection]
|
||||
self.currentItemIndices[currentSectionName] = index
|
||||
|
||||
def announce_current_section(self, interrupt=True):
|
||||
if 0 <= self.currentSection < len(self.sectionNames):
|
||||
sectionName = self.sectionNames[self.currentSection]
|
||||
self.speak(sectionName, interrupt=interrupt)
|
||||
|
||||
def announce_current_item(self, interrupt=True):
|
||||
if len(self.sectionNames) > 0:
|
||||
items = self.get_current_items()
|
||||
currentIndex = self.get_current_item_index()
|
||||
|
||||
if items and 0 <= currentIndex < len(items):
|
||||
name = items[currentIndex][0]
|
||||
isDirectory = items[currentIndex][2]
|
||||
|
||||
if isDirectory:
|
||||
self.speak(f"{name}, folder", interrupt=interrupt)
|
||||
else:
|
||||
self.speak(name, interrupt=interrupt)
|
||||
else:
|
||||
self.speak("No items", interrupt=interrupt)
|
||||
|
||||
def get_music_files_in_dir(self, directoryPath):
|
||||
musicFiles = []
|
||||
|
||||
try:
|
||||
files = sorted([f for f in os.listdir(directoryPath) if os.path.isfile(os.path.join(directoryPath, f))])
|
||||
|
||||
for file in files:
|
||||
filePath = os.path.join(directoryPath, file)
|
||||
fileExt = os.path.splitext(file)[1].lower()
|
||||
|
||||
if fileExt in self.musicExtensions:
|
||||
musicFiles.append(filePath)
|
||||
except Exception as e:
|
||||
print(f"Error getting music files: {e}")
|
||||
|
||||
return musicFiles
|
||||
|
||||
def execute_current_item(self):
|
||||
if len(self.sectionNames) > 0:
|
||||
items = self.get_current_items()
|
||||
index = self.get_current_item_index()
|
||||
|
||||
if 0 <= index < len(items):
|
||||
name, command, isDirectory, directoryPath = items[index]
|
||||
|
||||
if isDirectory and directoryPath:
|
||||
self.open_album(directoryPath, name)
|
||||
return
|
||||
|
||||
# Build the base mpv command
|
||||
shuffle_flag = "--shuffle" if self.randomize else ""
|
||||
base_cmd = f"mpv --no-video --really-quiet {shuffle_flag}".strip()
|
||||
|
||||
if name == "Play All Music":
|
||||
command = f'{base_cmd} "{self.musicDir}"'
|
||||
|
||||
elif name == "Play All Root Music":
|
||||
# For root only, we do need the glob to avoid subdirectories
|
||||
command = f'{base_cmd} "{self.musicDir}"/*'
|
||||
|
||||
elif name.startswith("Play All ") and not isDirectory:
|
||||
# This handles both artist "Play All [Artist Name]" and album "Play All [Album Name]"
|
||||
if self.currentView == "album":
|
||||
# We're in album view, use the current album path
|
||||
album_path = self.currentAlbumPath
|
||||
if album_path:
|
||||
command = f'{base_cmd} "{album_path}"'
|
||||
else:
|
||||
# We're in main view, this is a "Play All [Artist Name]" command
|
||||
# The directoryPath should contain the actual artist directory path
|
||||
if directoryPath and os.path.exists(directoryPath):
|
||||
command = f'{base_cmd} "{directoryPath}"'
|
||||
|
||||
elif name.startswith("Play All ") and self.currentView == "album" and not isDirectory:
|
||||
# Album playback
|
||||
album_path = self.currentAlbumPath
|
||||
if album_path:
|
||||
command = f'{base_cmd} "{album_path}"'
|
||||
|
||||
if command:
|
||||
self.cleanup(fullCleanup=True)
|
||||
os.system(command)
|
||||
os.execv(sys.executable, ['python3'] + sys.argv)
|
||||
|
||||
def open_album(self, albumPath, albumName):
|
||||
oldSections = self.menuSections.copy()
|
||||
oldSectionNames = self.sectionNames.copy()
|
||||
oldCurrentSection = self.currentSection
|
||||
oldCurrentIndices = self.currentItemIndices.copy()
|
||||
|
||||
self.navigationStack.append({
|
||||
'sections': oldSections,
|
||||
'section_names': oldSectionNames,
|
||||
'current_section': oldCurrentSection,
|
||||
'current_indices': oldCurrentIndices,
|
||||
'view': self.currentView
|
||||
})
|
||||
|
||||
self.menuSections = {}
|
||||
self.sectionNames = []
|
||||
self.currentItemIndices = {}
|
||||
self.currentSection = 0
|
||||
|
||||
self.currentView = "album"
|
||||
self.currentAlbumPath = albumPath
|
||||
self.currentAlbumName = albumName
|
||||
|
||||
albumSection = f"{albumName}"
|
||||
self.add_section(albumSection)
|
||||
|
||||
musicFiles = self.get_music_files_in_dir(albumPath)
|
||||
|
||||
if musicFiles:
|
||||
self.add_item(albumSection, f"Play All {albumName}", "")
|
||||
self.add_item(albumSection, "Back to Artist", "", isDirectory=False)
|
||||
|
||||
for filePath in musicFiles:
|
||||
fileName = os.path.basename(filePath)
|
||||
displayName = os.path.splitext(fileName)[0].replace('_', ' ')
|
||||
command = f'mpv --no-video --really-quiet "{filePath}"'
|
||||
self.add_item(albumSection, displayName, command)
|
||||
else:
|
||||
self.add_item(albumSection, "Back to Artist", "", isDirectory=False)
|
||||
|
||||
self.draw_menu()
|
||||
self.announce_current_section()
|
||||
time.sleep(0.5)
|
||||
self.announce_current_item(interrupt=False)
|
||||
|
||||
def go_back(self):
|
||||
if self.navigationStack:
|
||||
prevState = self.navigationStack.pop()
|
||||
self.menuSections = prevState['sections']
|
||||
self.sectionNames = prevState['section_names']
|
||||
self.currentSection = prevState['current_section']
|
||||
self.currentItemIndices = prevState['current_indices']
|
||||
self.currentView = prevState['view']
|
||||
|
||||
if self.currentView != "album":
|
||||
self.currentAlbumPath = None
|
||||
self.currentAlbumName = None
|
||||
|
||||
self.draw_menu()
|
||||
self.announce_current_section()
|
||||
time.sleep(0.5)
|
||||
self.announce_current_item(interrupt=False)
|
||||
return True
|
||||
return False
|
||||
|
||||
def speak_help(self):
|
||||
helpText = """
|
||||
Navigation controls:
|
||||
Up arrow: Previous menu item.
|
||||
Down arrow: Next menu item.
|
||||
Left arrow: Previous artist.
|
||||
Right arrow: Next artist.
|
||||
Enter: Play selected item or enter album.
|
||||
Backspace: Go back to previous view.
|
||||
R key: Toggle random playback.
|
||||
H key: Hear these instructions again.
|
||||
Left bracket: Decrease speech rate.
|
||||
Right bracket: Increase speech rate.
|
||||
Escape or Q: Exit the menu.
|
||||
Any key will interrupt speech.
|
||||
"""
|
||||
self.speak(helpText)
|
||||
|
||||
def draw_menu(self):
|
||||
self.stdscr.clear()
|
||||
h, w = self.stdscr.getmaxyx()
|
||||
|
||||
title = f" {self.title} "
|
||||
x = max(0, w // 2 - len(title) // 2)
|
||||
self.stdscr.addstr(1, x, title, curses.A_BOLD)
|
||||
|
||||
helpText = "Navigate | Enter: Select | R: Random | H: Help | [ ] Rate | Q/Esc: Quit"
|
||||
x = max(0, w // 2 - len(helpText) // 2)
|
||||
self.stdscr.addstr(3, x, helpText)
|
||||
|
||||
randomText = "Mode: Random" if self.randomize else "Mode: Sequential"
|
||||
self.stdscr.addstr(3, w - len(randomText) - 2, randomText)
|
||||
|
||||
if self.currentView == "album" and self.currentAlbumName:
|
||||
contextText = f"Album: {self.currentAlbumName}"
|
||||
x = max(0, w // 2 - len(contextText) // 2 - 10)
|
||||
self.stdscr.addstr(5, x, contextText, curses.A_DIM)
|
||||
if len(self.sectionNames) > 0:
|
||||
currentSectionName = self.sectionNames[self.currentSection]
|
||||
sectionText = f"== {currentSectionName} =="
|
||||
x = max(0, w // 2 - len(sectionText) // 2)
|
||||
self.stdscr.addstr(5, x, sectionText, curses.A_BOLD)
|
||||
|
||||
items = self.get_current_items()
|
||||
currentItemIndex = self.get_current_item_index()
|
||||
|
||||
if not items:
|
||||
emptyMsg = "No items in this section"
|
||||
x = max(0, w // 2 - len(emptyMsg) // 2)
|
||||
self.stdscr.addstr(7, x, emptyMsg, curses.A_DIM)
|
||||
else:
|
||||
for i, (name, _, isDirectory, _) in enumerate(items):
|
||||
y = i + 7
|
||||
if y < h - 1:
|
||||
if i == currentItemIndex:
|
||||
prefix = " > "
|
||||
attr = curses.A_REVERSE
|
||||
else:
|
||||
prefix = " "
|
||||
attr = curses.A_NORMAL
|
||||
|
||||
if isDirectory:
|
||||
text = f"{prefix}{name} [Album]"
|
||||
else:
|
||||
text = f"{prefix}{name}"
|
||||
|
||||
x = max(0, w // 2 - len(text) // 2)
|
||||
self.stdscr.addstr(y, x, text, attr)
|
||||
|
||||
rateText = f"Speech Rate: {self.speechRate}"
|
||||
self.stdscr.addstr(h-2, 2, rateText)
|
||||
|
||||
self.stdscr.refresh()
|
||||
|
||||
def cleanup(self, fullCleanup=False):
|
||||
self.stop_speech()
|
||||
|
||||
if self.speechClient:
|
||||
try:
|
||||
self.speechClient.close()
|
||||
except:
|
||||
pass
|
||||
self.speechClient = None
|
||||
|
||||
if fullCleanup and self.cursesInitialized:
|
||||
try:
|
||||
curses.nocbreak()
|
||||
self.stdscr.keypad(False)
|
||||
curses.echo()
|
||||
curses.endwin()
|
||||
except:
|
||||
try:
|
||||
curses.endwin()
|
||||
except:
|
||||
pass
|
||||
|
||||
def load_music_from_directory(self, directoryPath):
|
||||
directoryPath = os.path.expanduser(directoryPath)
|
||||
self.musicDir = directoryPath
|
||||
|
||||
if not os.path.exists(directoryPath) or not os.path.isdir(directoryPath):
|
||||
print(f"Directory {directoryPath} does not exist or is not a directory")
|
||||
return
|
||||
|
||||
rootMusicFiles = self.get_music_files_in_dir(directoryPath)
|
||||
musicDirs = [d for d in os.listdir(directoryPath) if os.path.isdir(os.path.join(directoryPath, d))]
|
||||
|
||||
for artistDir in sorted(musicDirs):
|
||||
artistPath = os.path.join(directoryPath, artistDir)
|
||||
artistName = artistDir.replace('_', ' ')
|
||||
|
||||
self.add_section(artistName)
|
||||
|
||||
artistAllMusicFiles = []
|
||||
artistMusicFiles = self.get_music_files_in_dir(artistPath)
|
||||
artistAllMusicFiles.extend(artistMusicFiles)
|
||||
|
||||
albumDirs = [d for d in os.listdir(artistPath) if os.path.isdir(os.path.join(artistPath, d))]
|
||||
|
||||
for albumDir in albumDirs:
|
||||
albumPath = os.path.join(artistPath, albumDir)
|
||||
albumMusicFiles = self.get_music_files_in_dir(albumPath)
|
||||
artistAllMusicFiles.extend(albumMusicFiles)
|
||||
|
||||
if artistAllMusicFiles:
|
||||
self.add_item(artistName, f"Play All {artistName}", "", isDirectory=False, directoryPath=artistPath)
|
||||
|
||||
for albumDir in sorted(albumDirs):
|
||||
albumPath = os.path.join(artistPath, albumDir)
|
||||
albumName = albumDir.replace('_', ' ')
|
||||
|
||||
albumMusicFiles = self.get_music_files_in_dir(albumPath)
|
||||
|
||||
if albumMusicFiles:
|
||||
self.add_item(artistName, albumName, "", isDirectory=True, directoryPath=albumPath)
|
||||
|
||||
for filePath in sorted(artistMusicFiles):
|
||||
fileName = os.path.basename(filePath)
|
||||
displayName = os.path.splitext(fileName)[0].replace('_', ' ')
|
||||
command = f'mpv --no-video --really-quiet "{filePath}"'
|
||||
self.add_item(artistName, displayName, command)
|
||||
|
||||
hasAnyMusic = bool(rootMusicFiles) or bool(musicDirs)
|
||||
if hasAnyMusic:
|
||||
self.add_section("All Music")
|
||||
|
||||
self.add_item("All Music", "Play All Music", "")
|
||||
|
||||
if rootMusicFiles:
|
||||
self.add_item("All Music", "Play All Root Music", "")
|
||||
|
||||
for filePath in sorted(rootMusicFiles):
|
||||
fileName = os.path.basename(filePath)
|
||||
displayName = os.path.splitext(fileName)[0].replace('_', ' ')
|
||||
command = f'mpv --no-video --really-quiet "{filePath}"'
|
||||
self.add_item("All Music", displayName, command)
|
||||
|
||||
if not self.sectionNames:
|
||||
self.add_section("Music Library")
|
||||
self.add_item("Music Library", "No music found", "")
|
||||
|
||||
def run(self):
|
||||
if not self.sectionNames:
|
||||
message = "Menu is empty. No music folders found. Exiting."
|
||||
print(message)
|
||||
|
||||
self.init_speech()
|
||||
if self.speechClient:
|
||||
self.speak(message)
|
||||
time.sleep(3)
|
||||
|
||||
self.cleanup(fullCleanup=True)
|
||||
sys.exit(0)
|
||||
|
||||
if not self.hasItems:
|
||||
message = "No music files found in any sections. Exiting."
|
||||
print(message)
|
||||
|
||||
self.init_speech()
|
||||
if self.speechClient:
|
||||
self.speak(message)
|
||||
time.sleep(3)
|
||||
|
||||
self.cleanup(fullCleanup=True)
|
||||
sys.exit(0)
|
||||
|
||||
try:
|
||||
self.stdscr = curses.initscr()
|
||||
self.cursesInitialized = True
|
||||
curses.noecho()
|
||||
curses.cbreak()
|
||||
self.stdscr.keypad(True)
|
||||
|
||||
self.draw_menu()
|
||||
self.speak("Music Player")
|
||||
time.sleep(1)
|
||||
self.announce_current_section(interrupt=False)
|
||||
time.sleep(0.5)
|
||||
self.announce_current_item(interrupt=False)
|
||||
|
||||
while True:
|
||||
key = self.stdscr.getch()
|
||||
self.stop_speech()
|
||||
|
||||
if key == curses.KEY_UP:
|
||||
items = self.get_current_items()
|
||||
if items:
|
||||
index = self.get_current_item_index()
|
||||
self.set_current_item_index((index - 1) % len(items))
|
||||
self.draw_menu()
|
||||
self.announce_current_item()
|
||||
|
||||
elif key == curses.KEY_DOWN:
|
||||
items = self.get_current_items()
|
||||
if items:
|
||||
index = self.get_current_item_index()
|
||||
self.set_current_item_index((index + 1) % len(items))
|
||||
self.draw_menu()
|
||||
self.announce_current_item()
|
||||
|
||||
elif key == curses.KEY_LEFT:
|
||||
if self.currentView == "main":
|
||||
if self.sectionNames and len(self.sectionNames) > 1:
|
||||
try:
|
||||
self.currentSection = (self.currentSection - 1) % len(self.sectionNames)
|
||||
self.draw_menu()
|
||||
self.announce_current_section()
|
||||
time.sleep(0.5)
|
||||
self.announce_current_item(interrupt=False)
|
||||
except Exception as e:
|
||||
print(f"Error navigating: {e}")
|
||||
|
||||
elif key == curses.KEY_RIGHT:
|
||||
if self.currentView == "main":
|
||||
if self.sectionNames and len(self.sectionNames) > 1:
|
||||
try:
|
||||
self.currentSection = (self.currentSection + 1) % len(self.sectionNames)
|
||||
self.draw_menu()
|
||||
self.announce_current_section()
|
||||
time.sleep(0.5)
|
||||
self.announce_current_item(interrupt=False)
|
||||
except Exception as e:
|
||||
print(f"Error navigating: {e}")
|
||||
|
||||
elif key == curses.KEY_ENTER or key == 10 or key == 13:
|
||||
items = self.get_current_items()
|
||||
if items:
|
||||
index = self.get_current_item_index()
|
||||
if 0 <= index < len(items):
|
||||
name, command, isDirectory, directoryPath = items[index]
|
||||
|
||||
if self.currentView == "album" and name == "Back to Artist":
|
||||
self.go_back()
|
||||
else:
|
||||
self.execute_current_item()
|
||||
|
||||
elif key == curses.KEY_BACKSPACE or key == 8 or key == 127:
|
||||
if self.currentView != "main":
|
||||
self.go_back()
|
||||
|
||||
elif key == ord('r') or key == ord('R'):
|
||||
self.toggle_randomize()
|
||||
self.draw_menu()
|
||||
|
||||
elif key == ord('h') or key == ord('H'):
|
||||
self.speak_help()
|
||||
|
||||
elif key == ord('['):
|
||||
self.decrease_speech_rate()
|
||||
self.draw_menu()
|
||||
|
||||
elif key == ord(']'):
|
||||
self.increase_speech_rate()
|
||||
self.draw_menu()
|
||||
|
||||
elif key == 27 or key == ord('q') or key == ord('Q'):
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
if self.cursesInitialized:
|
||||
try:
|
||||
curses.endwin()
|
||||
except:
|
||||
pass
|
||||
print(f"An error occurred: {e}")
|
||||
finally:
|
||||
self.cleanup(fullCleanup=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
player = VoicedMusicPlayer(title="Stormux Music Player")
|
||||
player.load_music_from_directory("~/Music")
|
||||
player.run()
|
||||
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Simple OCR Screen Reader
|
||||
A lightweight tool that performs OCR on the screen and speaks the results
|
||||
Optimized for Arch Linux ARM on Raspberry Pi with DWM
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import subprocess
|
||||
from PIL import Image, ImageOps
|
||||
import pytesseract
|
||||
|
||||
def capture_screen(max_retries=3, initial_delay=0.2):
|
||||
"""
|
||||
Capture the screen using scrot with robust checking and retries
|
||||
|
||||
Args:
|
||||
max_retries: Maximum number of attempts to read the image
|
||||
initial_delay: Initial delay in seconds (will increase with retries)
|
||||
"""
|
||||
temp_file = "/tmp/ocr_capture.png"
|
||||
|
||||
try:
|
||||
# Capture the screen
|
||||
subprocess.run(["scrot", temp_file], check=True)
|
||||
|
||||
# Wait and retry approach with validity checking
|
||||
delay = initial_delay
|
||||
for attempt in range(max_retries):
|
||||
time.sleep(delay)
|
||||
|
||||
# Check if file exists and has content
|
||||
if os.path.exists(temp_file) and os.path.getsize(temp_file) > 0:
|
||||
try:
|
||||
# Try to verify the image is valid
|
||||
with Image.open(temp_file) as test_img:
|
||||
# Just accessing a property forces PIL to validate the image
|
||||
test_img.size
|
||||
|
||||
# If we get here, the image is valid
|
||||
return Image.open(temp_file)
|
||||
except (IOError, OSError) as e:
|
||||
# Image exists but isn't valid yet
|
||||
if attempt < max_retries - 1:
|
||||
# Increase delay exponentially for next attempt
|
||||
delay *= 2
|
||||
continue
|
||||
else:
|
||||
raise Exception(f"Image file exists but is not valid after {max_retries} attempts")
|
||||
|
||||
# File doesn't exist or is empty
|
||||
if attempt < max_retries - 1:
|
||||
# Increase delay exponentially for next attempt
|
||||
delay *= 2
|
||||
else:
|
||||
raise Exception(f"Screenshot file not created properly after {max_retries} attempts")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error capturing screen: {e}")
|
||||
raise
|
||||
finally:
|
||||
# Ensure file is removed even if an error occurs
|
||||
if os.path.exists(temp_file):
|
||||
os.remove(temp_file)
|
||||
|
||||
def process_image(img, scale_factor=1.5):
|
||||
"""Process the image to improve OCR accuracy"""
|
||||
# Scale the image to improve OCR
|
||||
if scale_factor != 1:
|
||||
width, height = img.size
|
||||
img = img.resize((int(width * scale_factor), int(height * scale_factor)),
|
||||
Image.Resampling.BICUBIC)
|
||||
|
||||
# Convert to grayscale for faster processing
|
||||
img = ImageOps.grayscale(img)
|
||||
|
||||
# Improve contrast for better text recognition
|
||||
img = ImageOps.autocontrast(img)
|
||||
|
||||
return img
|
||||
|
||||
def perform_ocr(img, lang='eng'):
|
||||
"""Perform OCR on the image"""
|
||||
# Use tessaract with optimized settings
|
||||
# --oem 1: Use LSTM OCR Engine
|
||||
# --psm 6: Assume a single uniform block of text
|
||||
text = pytesseract.image_to_string(img, lang=lang, config='--oem 1 --psm 6')
|
||||
|
||||
return text
|
||||
|
||||
def speak_text(text):
|
||||
"""Speak the text using speech-dispatcher"""
|
||||
# Filter out empty lines and clean up the text
|
||||
lines = [line.strip() for line in text.split('\n') if line.strip()]
|
||||
cleaned_text = ' '.join(lines)
|
||||
|
||||
# Use speech-dispatcher to speak the text
|
||||
if cleaned_text:
|
||||
subprocess.run(["spd-say", "-Cw", cleaned_text])
|
||||
else:
|
||||
subprocess.run(["spd-say", "-Cw", "No text detected"])
|
||||
|
||||
def main():
|
||||
# Limit tesseract thread usage to improve performance on Pi
|
||||
os.environ["OMP_THREAD_LIMIT"] = "4"
|
||||
|
||||
try:
|
||||
# Announce start
|
||||
subprocess.run(["spd-say", "-Cw", "performing OCR"])
|
||||
|
||||
# Capture screen
|
||||
img = capture_screen()
|
||||
|
||||
# Process image
|
||||
processed_img = process_image(img, scale_factor=1.5)
|
||||
|
||||
# Perform OCR
|
||||
text = perform_ocr(processed_img)
|
||||
|
||||
# Speak the results
|
||||
speak_text(text)
|
||||
|
||||
except Exception as e:
|
||||
# Let the user know something went wrong
|
||||
error_msg = f"Error during OCR: {str(e)}"
|
||||
print(error_msg)
|
||||
try:
|
||||
subprocess.run(["spd-say", "-Cw", "OCR failed"])
|
||||
except:
|
||||
# If even speech fails, at least we tried
|
||||
pass
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
recordingDir=~/Audio
|
||||
mkdir -p "$recordingDir"
|
||||
|
||||
pidFile="/tmp/game_recording.pid"
|
||||
|
||||
if [[ -f "$pidFile" ]]; then
|
||||
pid=$(cat "$pidFile")
|
||||
if ps -p "$pid" > /dev/null 2>&1; then
|
||||
kill "$pid"
|
||||
spd-say -Cw "Recording stopped"
|
||||
play -qV0 "|sox -np synth .07 sq 400" "|sox -np synth .5 sq 800" fade h 0 .5 .5 norm -20 reverse
|
||||
else
|
||||
spd-say -Cw "Recording process not found, cleaning up"
|
||||
fi
|
||||
rm "$pidFile"
|
||||
else
|
||||
spd-say -Cw "Recording starting in"
|
||||
for i in {3..1}; do
|
||||
spd-say -Cw "$i"
|
||||
sleep 0.5
|
||||
done
|
||||
play -qV0 "|sox -np synth .07 sq 400" "|sox -np synth .5 sq 800" fade h 0 .5 .5 norm -20
|
||||
ffmpeg -f pulse -i "$(pactl get-default-sink).monitor" "$recordingDir/game_$(date +%F_%H-%M-%S).ogg" &
|
||||
echo "$!" > "$pidFile"
|
||||
fi
|
||||
Executable
+507
@@ -0,0 +1,507 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Self-voiced Terminal Menu ROM launcher
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import threading
|
||||
import signal
|
||||
import curses
|
||||
import subprocess
|
||||
import speechd # Python bindings for Speech Dispatcher
|
||||
import configparser
|
||||
import pathlib
|
||||
import re
|
||||
|
||||
class VoicedMenu:
|
||||
def __init__(self, title="Stormux Game Menu"):
|
||||
self.title = title
|
||||
self.menuSections = {} # Dictionary to hold sections and their items
|
||||
self.sectionNames = [] # List to maintain section order
|
||||
self.currentSection = 0 # Index of current section
|
||||
self.currentItemIndices = {} # Current item index for each section
|
||||
self.stdscr = None
|
||||
self.curses_initialized = False # Flag to track if curses has been initialized
|
||||
self.has_items = False # Flag to track if any section has items
|
||||
|
||||
# Config settings
|
||||
self.configDir = os.path.expanduser("~/.config/stormux")
|
||||
self.configFile = os.path.join(self.configDir, "game_launcher.conf")
|
||||
self.config = configparser.ConfigParser()
|
||||
|
||||
# Default settings
|
||||
self.speechRate = 0 # Normal speech rate (0 is default in speechd)
|
||||
|
||||
# Load settings
|
||||
self.load_settings()
|
||||
|
||||
# Initialize speech client
|
||||
self.speechClient = None
|
||||
self.init_speech()
|
||||
|
||||
def init_speech(self):
|
||||
"""Initialize the speech client"""
|
||||
try:
|
||||
# Use a fixed client ID
|
||||
self.speechClient = speechd.SSIPClient("rom_menu")
|
||||
self.speechClient.set_priority(speechd.Priority.IMPORTANT)
|
||||
self.speechClient.set_punctuation(speechd.PunctuationMode.SOME)
|
||||
|
||||
# Apply speech rate from settings
|
||||
self.speechClient.set_rate(self.speechRate)
|
||||
except Exception as e:
|
||||
print(f"Could not initialize speech: {e}")
|
||||
# Fallback to None - the speak method will handle this
|
||||
|
||||
def load_settings(self):
|
||||
"""Load settings from config file"""
|
||||
# Create default settings if they don't exist
|
||||
if not os.path.exists(self.configFile):
|
||||
self.save_settings()
|
||||
return
|
||||
|
||||
try:
|
||||
self.config.read(self.configFile)
|
||||
|
||||
# Load speech settings
|
||||
if 'Speech' in self.config:
|
||||
self.speechRate = self.config.getint('Speech', 'rate', fallback=0)
|
||||
except Exception as e:
|
||||
print(f"Error loading settings: {e}")
|
||||
# If loading fails, we'll use default values
|
||||
|
||||
def save_settings(self):
|
||||
"""Save settings to config file"""
|
||||
# Ensure config directory exists
|
||||
os.makedirs(self.configDir, exist_ok=True)
|
||||
|
||||
# Update config object
|
||||
if 'Speech' not in self.config:
|
||||
self.config['Speech'] = {}
|
||||
|
||||
self.config['Speech']['rate'] = str(self.speechRate)
|
||||
|
||||
# Write to file
|
||||
try:
|
||||
with open(self.configFile, 'w') as f:
|
||||
self.config.write(f)
|
||||
except Exception as e:
|
||||
print(f"Error saving settings: {e}")
|
||||
|
||||
def increase_speech_rate(self):
|
||||
"""Increase speech rate"""
|
||||
self.speechRate = min(100, self.speechRate + 10) # Max is 100
|
||||
if self.speechClient:
|
||||
try:
|
||||
self.speechClient.set_rate(self.speechRate)
|
||||
self.speak(f"Speech rate: {self.speechRate}")
|
||||
except Exception as e:
|
||||
print(f"Error adjusting speech rate: {e}")
|
||||
|
||||
# Save the new setting
|
||||
self.save_settings()
|
||||
|
||||
def decrease_speech_rate(self):
|
||||
"""Decrease speech rate"""
|
||||
self.speechRate = max(-100, self.speechRate - 10) # Min is -100
|
||||
if self.speechClient:
|
||||
try:
|
||||
self.speechClient.set_rate(self.speechRate)
|
||||
self.speak(f"Speech rate: {self.speechRate}")
|
||||
except Exception as e:
|
||||
print(f"Error adjusting speech rate: {e}")
|
||||
|
||||
# Save the new setting
|
||||
self.save_settings()
|
||||
|
||||
def add_section(self, sectionName):
|
||||
"""Add a new section to the menu"""
|
||||
if sectionName not in self.menuSections:
|
||||
self.menuSections[sectionName] = []
|
||||
self.sectionNames.append(sectionName)
|
||||
self.currentItemIndices[sectionName] = 0
|
||||
|
||||
def add_item(self, sectionName, name, command):
|
||||
"""Add a menu item to a specific section"""
|
||||
# Create section if it doesn't exist
|
||||
if sectionName not in self.menuSections:
|
||||
self.add_section(sectionName)
|
||||
|
||||
self.menuSections[sectionName].append((name, command))
|
||||
self.has_items = True # Mark that we have at least one item
|
||||
|
||||
def speak(self, text, interrupt=True):
|
||||
"""Speak the given text with option to interrupt existing speech"""
|
||||
if self.speechClient is None:
|
||||
return
|
||||
|
||||
try:
|
||||
if interrupt:
|
||||
self.stop_speech()
|
||||
|
||||
self.speechClient.speak(text)
|
||||
except Exception as e:
|
||||
# If speech fails, try to reinitialize and try once more
|
||||
try:
|
||||
self.init_speech()
|
||||
if self.speechClient:
|
||||
self.speechClient.speak(text)
|
||||
except:
|
||||
# If reinitializing fails, just give up silently
|
||||
pass
|
||||
|
||||
def stop_speech(self):
|
||||
"""Stop any ongoing speech"""
|
||||
if self.speechClient is None:
|
||||
return
|
||||
|
||||
try:
|
||||
self.speechClient.cancel()
|
||||
except Exception as e:
|
||||
# If cancel fails, try to reinitialize
|
||||
self.init_speech()
|
||||
|
||||
def get_current_items(self):
|
||||
"""Get items from the current section"""
|
||||
if not self.sectionNames:
|
||||
return []
|
||||
currentSectionName = self.sectionNames[self.currentSection]
|
||||
return self.menuSections[currentSectionName]
|
||||
|
||||
def get_current_item_index(self):
|
||||
"""Get the current item index in the current section"""
|
||||
if not self.sectionNames:
|
||||
return 0
|
||||
currentSectionName = self.sectionNames[self.currentSection]
|
||||
return self.currentItemIndices[currentSectionName]
|
||||
|
||||
def set_current_item_index(self, index):
|
||||
"""Set the current item index for the current section"""
|
||||
if not self.sectionNames:
|
||||
return
|
||||
currentSectionName = self.sectionNames[self.currentSection]
|
||||
self.currentItemIndices[currentSectionName] = index
|
||||
|
||||
def announce_current_section(self, interrupt=True):
|
||||
"""Announce the currently selected section"""
|
||||
if 0 <= self.currentSection < len(self.sectionNames):
|
||||
sectionName = self.sectionNames[self.currentSection]
|
||||
self.speak(sectionName, interrupt=interrupt)
|
||||
|
||||
def announce_current_item(self, interrupt=True):
|
||||
"""Announce the currently selected menu item"""
|
||||
if len(self.sectionNames) > 0:
|
||||
items = self.get_current_items()
|
||||
index = self.get_current_item_index()
|
||||
|
||||
if 0 <= index < len(items):
|
||||
name = items[index][0]
|
||||
self.speak(name, interrupt=interrupt)
|
||||
|
||||
def execute_current_item(self):
|
||||
"""Execute the currently selected menu item"""
|
||||
if len(self.sectionNames) > 0:
|
||||
items = self.get_current_items()
|
||||
index = self.get_current_item_index()
|
||||
|
||||
if 0 <= index < len(items):
|
||||
name, command = items[index]
|
||||
|
||||
# Clean up resources before executing the command
|
||||
self.cleanup(full_cleanup=True) # This handles curses properly
|
||||
|
||||
# Execute the command and exit
|
||||
os.system(command)
|
||||
sys.exit(0) # Now safe to exit
|
||||
|
||||
def speak_help(self):
|
||||
"""Speak help information"""
|
||||
helpText = """
|
||||
Navigation controls:
|
||||
Up arrow: Previous menu item.
|
||||
Down arrow: Next menu item.
|
||||
Left arrow: Previous section.
|
||||
Right arrow: Next section.
|
||||
Enter: Launch selected item.
|
||||
H key: Hear these instructions again.
|
||||
Left bracket: Decrease speech rate.
|
||||
Right bracket: Increase speech rate.
|
||||
Escape or Q: Exit the menu.
|
||||
Any key will interrupt speech.
|
||||
"""
|
||||
self.speak(helpText)
|
||||
|
||||
def draw_menu(self):
|
||||
"""Draw the menu on the screen"""
|
||||
self.stdscr.clear()
|
||||
h, w = self.stdscr.getmaxyx()
|
||||
|
||||
# Draw title
|
||||
title = f" {self.title} "
|
||||
x = max(0, w // 2 - len(title) // 2)
|
||||
self.stdscr.addstr(1, x, title, curses.A_BOLD)
|
||||
|
||||
# Draw help line
|
||||
helpText = "Ãavigate | Enter: Select | H: Help | [ ] Rate | Q/Esc: Quit"
|
||||
x = max(0, w // 2 - len(helpText) // 2)
|
||||
self.stdscr.addstr(3, x, helpText)
|
||||
|
||||
# Draw current section
|
||||
if len(self.sectionNames) > 0:
|
||||
currentSectionName = self.sectionNames[self.currentSection]
|
||||
sectionText = f"== {currentSectionName} =="
|
||||
x = max(0, w // 2 - len(sectionText) // 2)
|
||||
self.stdscr.addstr(5, x, sectionText, curses.A_BOLD)
|
||||
|
||||
# Draw menu items for current section
|
||||
items = self.get_current_items()
|
||||
currentItemIndex = self.get_current_item_index()
|
||||
|
||||
if not items:
|
||||
# Display a message if the section is empty
|
||||
emptyMsg = "No items in this section"
|
||||
x = max(0, w // 2 - len(emptyMsg) // 2)
|
||||
self.stdscr.addstr(7, x, emptyMsg, curses.A_DIM)
|
||||
else:
|
||||
for i, (name, _) in enumerate(items):
|
||||
y = i + 7 # Start items 2 lines below section header
|
||||
if y < h - 1: # Ensure we don't draw outside the window
|
||||
# Highlight the selected item
|
||||
if i == currentItemIndex:
|
||||
text = f" > {name} "
|
||||
attr = curses.A_REVERSE
|
||||
else:
|
||||
text = f" {name} "
|
||||
attr = curses.A_NORMAL
|
||||
|
||||
x = max(0, w // 2 - len(text) // 2)
|
||||
self.stdscr.addstr(y, x, text, attr)
|
||||
|
||||
# Draw speech rate indicator
|
||||
rateText = f"Speech Rate: {self.speechRate}"
|
||||
self.stdscr.addstr(h-2, 2, rateText)
|
||||
|
||||
self.stdscr.refresh()
|
||||
|
||||
def cleanup(self, full_cleanup=False):
|
||||
"""Clean up resources before exiting or executing a command
|
||||
|
||||
Args:
|
||||
full_cleanup: If True, also close curses. Used when exiting or running a command.
|
||||
"""
|
||||
# Stop any speech
|
||||
self.stop_speech()
|
||||
|
||||
# Close speech client
|
||||
if self.speechClient:
|
||||
try:
|
||||
self.speechClient.close()
|
||||
except:
|
||||
pass
|
||||
self.speechClient = None
|
||||
|
||||
# Restore terminal settings if curses was initialized
|
||||
if full_cleanup and self.curses_initialized:
|
||||
try:
|
||||
curses.nocbreak()
|
||||
self.stdscr.keypad(False)
|
||||
curses.echo()
|
||||
curses.endwin()
|
||||
except:
|
||||
# If there's an error, just try a simple endwin
|
||||
try:
|
||||
curses.endwin()
|
||||
except:
|
||||
pass # Last resort, just continue
|
||||
|
||||
def load_roms_from_directory(self, directoryPath):
|
||||
"""Load ROMs from the specified directory and add them to the menu"""
|
||||
# Expand the path (in case it contains ~)
|
||||
directoryPath = os.path.expanduser(directoryPath)
|
||||
|
||||
# Check if directory exists
|
||||
if not os.path.exists(directoryPath) or not os.path.isdir(directoryPath):
|
||||
print(f"Directory {directoryPath} does not exist or is not a directory")
|
||||
return
|
||||
|
||||
# Get all subdirectories in the roms directory
|
||||
try:
|
||||
subdirs = [d for d in os.listdir(directoryPath) if os.path.isdir(os.path.join(directoryPath, d))]
|
||||
|
||||
# For each subdirectory, create a section
|
||||
for subdir in subdirs:
|
||||
sectionPath = os.path.join(directoryPath, subdir)
|
||||
|
||||
# Get all files in the subdirectory
|
||||
files = [f for f in os.listdir(sectionPath) if os.path.isfile(os.path.join(sectionPath, f))]
|
||||
|
||||
# If the directory has files, add it as a section
|
||||
if files:
|
||||
# Add the section
|
||||
self.add_section(subdir)
|
||||
|
||||
# Add files as menu items
|
||||
for file in files:
|
||||
# Get full path to the file
|
||||
filePath = os.path.join(sectionPath, file)
|
||||
|
||||
# Create display name - remove extension
|
||||
displayName = os.path.splitext(file)[0]
|
||||
|
||||
# Replace underscores with spaces for better readability
|
||||
displayName = displayName.replace('_', ' ')
|
||||
|
||||
# Properly escape special characters in file path
|
||||
escapedPath = filePath.replace('"', '\\"')
|
||||
|
||||
# Add the item to the section - use double quotes for the GAME variable
|
||||
self.add_item(subdir, displayName, f'export GAME="{escapedPath}" && startx')
|
||||
except Exception as e:
|
||||
print(f"Error loading ROMs directory: {e}")
|
||||
|
||||
def run(self):
|
||||
"""Run the menu system"""
|
||||
# Check if menu is completely empty
|
||||
if not self.sectionNames:
|
||||
message = "No games found."
|
||||
print(message)
|
||||
|
||||
# Speak the message
|
||||
self.init_speech() # Make sure speech is initialized
|
||||
if self.speechClient:
|
||||
self.speak(message)
|
||||
# Wait for speech to finish (rough estimate)
|
||||
time.sleep(3)
|
||||
|
||||
# Clean up and exit properly
|
||||
self.cleanup(full_cleanup=True)
|
||||
sys.exit(0)
|
||||
|
||||
# Check if any sections have items
|
||||
if not self.has_items:
|
||||
message = "No ROMs found in any sections. Exiting."
|
||||
print(message)
|
||||
|
||||
# Speak the message
|
||||
self.init_speech() # Make sure speech is initialized
|
||||
if self.speechClient:
|
||||
self.speak(message)
|
||||
# Wait for speech to finish (rough estimate)
|
||||
time.sleep(3)
|
||||
|
||||
# Clean up and exit properly
|
||||
self.cleanup(full_cleanup=True)
|
||||
sys.exit(0)
|
||||
|
||||
try:
|
||||
# Initialize curses
|
||||
self.stdscr = curses.initscr()
|
||||
self.curses_initialized = True # Mark curses as initialized
|
||||
curses.noecho() # Don't echo keypresses
|
||||
curses.cbreak() # React to keys instantly
|
||||
self.stdscr.keypad(True) # Enable special keys
|
||||
|
||||
# Initial draw
|
||||
self.draw_menu()
|
||||
|
||||
# Welcome message - don't interrupt this initial speech
|
||||
self.speak("Roms menu")
|
||||
|
||||
# Wait for initial speech to finish before announcing section
|
||||
time.sleep(1)
|
||||
|
||||
# Announce initial section and item without interrupting welcome speech
|
||||
self.announce_current_section(interrupt=False)
|
||||
time.sleep(0.5) # Wait before announcing first item
|
||||
self.announce_current_item(interrupt=False)
|
||||
|
||||
# Main loop
|
||||
while True:
|
||||
key = self.stdscr.getch()
|
||||
|
||||
# Stop any speech when a key is pressed
|
||||
self.stop_speech()
|
||||
|
||||
# Handle navigation
|
||||
if key == curses.KEY_UP:
|
||||
# Move to previous item in current section
|
||||
items = self.get_current_items()
|
||||
if items:
|
||||
index = self.get_current_item_index()
|
||||
self.set_current_item_index((index - 1) % len(items))
|
||||
self.draw_menu()
|
||||
self.announce_current_item()
|
||||
|
||||
elif key == curses.KEY_DOWN:
|
||||
# Move to next item in current section
|
||||
items = self.get_current_items()
|
||||
if items:
|
||||
index = self.get_current_item_index()
|
||||
self.set_current_item_index((index + 1) % len(items))
|
||||
self.draw_menu()
|
||||
self.announce_current_item()
|
||||
|
||||
elif key == curses.KEY_LEFT:
|
||||
# Move to previous section
|
||||
if self.sectionNames:
|
||||
self.currentSection = (self.currentSection - 1) % len(self.sectionNames)
|
||||
self.draw_menu()
|
||||
# Announce section and current item without interruption between them
|
||||
self.announce_current_section()
|
||||
time.sleep(0.5) # Brief pause between section and item announcement
|
||||
self.announce_current_item(interrupt=False) # Don't interrupt the section announcement
|
||||
|
||||
elif key == curses.KEY_RIGHT:
|
||||
# Move to next section
|
||||
if self.sectionNames:
|
||||
self.currentSection = (self.currentSection + 1) % len(self.sectionNames)
|
||||
self.draw_menu()
|
||||
# Announce section and current item without interruption between them
|
||||
self.announce_current_section()
|
||||
time.sleep(0.5) # Brief pause between section and item announcement
|
||||
self.announce_current_item(interrupt=False) # Don't interrupt the section announcement
|
||||
|
||||
elif key == curses.KEY_ENTER or key == 10 or key == 13: # Enter key
|
||||
items = self.get_current_items()
|
||||
if items: # Only execute if there are items
|
||||
self.execute_current_item() # This now handles cleanup and exit
|
||||
|
||||
elif key == ord('h') or key == ord('H'): # Help
|
||||
self.speak_help()
|
||||
|
||||
elif key == ord('['): # Decrease speech rate
|
||||
self.decrease_speech_rate()
|
||||
self.draw_menu()
|
||||
|
||||
elif key == ord(']'): # Increase speech rate
|
||||
self.increase_speech_rate()
|
||||
self.draw_menu()
|
||||
|
||||
elif key == 27 or key == ord('q') or key == ord('Q'): # Esc or Q
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
# End curses in case of error
|
||||
if self.curses_initialized:
|
||||
try:
|
||||
curses.endwin()
|
||||
except:
|
||||
pass
|
||||
print(f"An error occurred: {e}")
|
||||
finally:
|
||||
# Clean up - safe to call even if curses wasn't initialized
|
||||
self.cleanup(full_cleanup=True)
|
||||
|
||||
|
||||
# Example usage
|
||||
if __name__ == "__main__":
|
||||
# Create the menu with sections
|
||||
menu = VoicedMenu(title="")
|
||||
|
||||
# Load ROMs from the ~/Roms directory
|
||||
menu.load_roms_from_directory("~/Roms")
|
||||
|
||||
# Run the menu
|
||||
menu.run()
|
||||
Executable
+358
@@ -0,0 +1,358 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Self-voiced Terminal Menu for Speech Dispatcher Voice Selection
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import curses
|
||||
import speechd
|
||||
import subprocess
|
||||
import configparser
|
||||
|
||||
class VoiceSelectionMenu:
|
||||
def __init__(self, title="Speech Dispatcher Voice Selection"):
|
||||
self.title = title
|
||||
self.voice_modules = [] # List to store available voice modules
|
||||
self.current_index = 0 # Index of current selection
|
||||
self.stdscr = None
|
||||
self.curses_initialized = False # Flag to track if curses has been initialized
|
||||
|
||||
# Initialize speech client
|
||||
self.speech_client = None
|
||||
self.init_speech()
|
||||
|
||||
# Load available voice modules
|
||||
self.load_voice_modules()
|
||||
|
||||
def init_speech(self):
|
||||
"""Initialize the speech client"""
|
||||
try:
|
||||
self.speech_client = speechd.SSIPClient("voice_selection_menu")
|
||||
self.speech_client.set_priority(speechd.Priority.IMPORTANT)
|
||||
self.speech_client.set_punctuation(speechd.PunctuationMode.SOME)
|
||||
except Exception as e:
|
||||
print(f"Could not initialize speech: {e}")
|
||||
# Fallback to None - the speak method will handle this
|
||||
|
||||
def load_voice_modules(self):
|
||||
"""Load available speech-dispatcher modules"""
|
||||
try:
|
||||
# Execute the command to get available output modules
|
||||
result = subprocess.run(['spd-say', '-O'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True)
|
||||
|
||||
# Process the output to get the list of modules
|
||||
lines = result.stdout.strip().split('\n')
|
||||
# Skip the first line (header)
|
||||
modules = [line.strip() for line in lines[1:] if line.strip()]
|
||||
|
||||
# Store the modules
|
||||
self.voice_modules = modules
|
||||
|
||||
if not modules:
|
||||
print("No speech-dispatcher modules found.")
|
||||
except Exception as e:
|
||||
print(f"Error loading voice modules: {e}")
|
||||
self.voice_modules = []
|
||||
|
||||
def speak(self, text, interrupt=True, module=None):
|
||||
"""Speak the given text with option to interrupt existing speech"""
|
||||
if self.speech_client is None:
|
||||
return
|
||||
|
||||
try:
|
||||
if interrupt:
|
||||
self.stop_speech()
|
||||
|
||||
# If a specific module is requested, try to use it
|
||||
if module:
|
||||
current_module = self.speech_client.get_output_module()
|
||||
self.speech_client.set_output_module(module)
|
||||
self.speech_client.speak(text)
|
||||
# Restore previous module after speaking
|
||||
self.speech_client.set_output_module(current_module)
|
||||
else:
|
||||
# Use default module
|
||||
self.speech_client.speak(text)
|
||||
except Exception as e:
|
||||
# If speech fails, try to reinitialize and try once more
|
||||
try:
|
||||
self.init_speech()
|
||||
if self.speech_client:
|
||||
self.speech_client.speak(text)
|
||||
except:
|
||||
# If reinitializing fails, just give up silently
|
||||
pass
|
||||
|
||||
def stop_speech(self):
|
||||
"""Stop any ongoing speech"""
|
||||
if self.speech_client is None:
|
||||
return
|
||||
|
||||
try:
|
||||
self.speech_client.cancel()
|
||||
except Exception as e:
|
||||
# If cancel fails, try to reinitialize
|
||||
self.init_speech()
|
||||
|
||||
def announce_current_item(self, interrupt=True):
|
||||
"""Announce the currently selected voice module"""
|
||||
if self.voice_modules and 0 <= self.current_index < len(self.voice_modules):
|
||||
module = self.voice_modules[self.current_index]
|
||||
self.speak(f"Module {module}", interrupt=interrupt)
|
||||
|
||||
def test_selected_module(self):
|
||||
"""Test the currently selected voice module"""
|
||||
if self.voice_modules and 0 <= self.current_index < len(self.voice_modules):
|
||||
module = self.voice_modules[self.current_index]
|
||||
test_message = f"This is a test of the {module} speech-dispatcher module. If you can hear this message, press enter to set {module} as your default module. If enter is not pressed within 15 seconds, no changes will be made to your system."
|
||||
|
||||
# Speak using the selected module - should not be interrupted
|
||||
self.speak(test_message, interrupt=False, module=module)
|
||||
|
||||
# Draw a message instructing the user to press Enter
|
||||
h, w = self.stdscr.getmaxyx()
|
||||
confirm_msg = "Press ENTER within 15 seconds to confirm selection or any other key to cancel."
|
||||
x = max(0, w // 2 - len(confirm_msg) // 2)
|
||||
self.stdscr.addstr(h-2, x, confirm_msg, curses.A_BOLD)
|
||||
self.stdscr.refresh()
|
||||
|
||||
# Wait for user confirmation with timeout
|
||||
self.stdscr.timeout(15000) # 15 seconds timeout
|
||||
key = self.stdscr.getch()
|
||||
self.stdscr.timeout(-1) # Reset timeout
|
||||
|
||||
# Check if Enter was pressed
|
||||
if key == curses.KEY_ENTER or key == 10 or key == 13:
|
||||
return True
|
||||
else:
|
||||
self.speak("Confirmation not received, no changes made to your speech-dispatcher configuration.", interrupt=False)
|
||||
return False
|
||||
return False
|
||||
|
||||
def set_default_module(self):
|
||||
"""Set the selected module as the default speech-dispatcher module"""
|
||||
if self.voice_modules and 0 <= self.current_index < len(self.voice_modules):
|
||||
module = self.voice_modules[self.current_index]
|
||||
|
||||
# Test the module first
|
||||
if not self.test_selected_module():
|
||||
return
|
||||
|
||||
try:
|
||||
# Clean up before executing system commands
|
||||
self.cleanup(full_cleanup=False)
|
||||
|
||||
# Use sed to update the DefaultModule in speechd.conf
|
||||
sed_cmd = f"sudo sed -i '/^\\s*#\\?\\s*DefaultModule\\s\\+/c\\DefaultModule {module}' /etc/speech-dispatcher/speechd.conf"
|
||||
subprocess.run(sed_cmd, shell=True, check=True)
|
||||
|
||||
# Restart speech-dispatcher
|
||||
subprocess.run("sudo killall speech-dispatcher", shell=True, check=False)
|
||||
|
||||
# Re-initialize speech after changes
|
||||
time.sleep(1) # Give a moment for the service to restart
|
||||
self.init_speech()
|
||||
|
||||
# Notify the user that the change is complete - should not be interrupted
|
||||
self.speak(f"The {module} module is now being used for this system.", interrupt=False)
|
||||
|
||||
# Return to the menu after speech finishes
|
||||
# No sleep here - the next UI repaint doesn't depend on speech finishing
|
||||
self.draw_menu()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error setting default module: {e}")
|
||||
self.speak("An error occurred while attempting to set the default module.", interrupt=False)
|
||||
|
||||
def speak_help(self):
|
||||
"""Speak help information"""
|
||||
helpText = """
|
||||
Navigation controls:
|
||||
Up arrow: Previous voice module.
|
||||
Down arrow: Next voice module.
|
||||
Enter: Test and set the selected voice module.
|
||||
H key: Hear these instructions again.
|
||||
Escape or Q: Exit the menu.
|
||||
Any key will interrupt speech.
|
||||
"""
|
||||
self.speak(helpText, interrupt=False)
|
||||
|
||||
def draw_menu(self):
|
||||
"""Draw the menu on the screen"""
|
||||
self.stdscr.clear()
|
||||
h, w = self.stdscr.getmaxyx()
|
||||
|
||||
# Draw title
|
||||
title = f" {self.title} "
|
||||
x = max(0, w // 2 - len(title) // 2)
|
||||
self.stdscr.addstr(1, x, title, curses.A_BOLD)
|
||||
|
||||
# Draw help line
|
||||
helpText = "Up/Down: Navigate | Enter: Test & Set | H: Help | Q/Esc: Quit"
|
||||
x = max(0, w // 2 - len(helpText) // 2)
|
||||
self.stdscr.addstr(3, x, helpText)
|
||||
|
||||
# Check if we have items
|
||||
if not self.voice_modules:
|
||||
message = "No speech-dispatcher modules found."
|
||||
x = max(0, w // 2 - len(message) // 2)
|
||||
self.stdscr.addstr(5, x, message, curses.A_DIM)
|
||||
else:
|
||||
# Show a limited number of items, centered around the current selection
|
||||
max_display = min(h - 7, len(self.voice_modules)) # Max number of items to display
|
||||
|
||||
# Calculate starting index for display
|
||||
half_display = max_display // 2
|
||||
if self.current_index < half_display:
|
||||
start_idx = 0
|
||||
elif self.current_index >= len(self.voice_modules) - half_display:
|
||||
start_idx = max(0, len(self.voice_modules) - max_display)
|
||||
else:
|
||||
start_idx = self.current_index - half_display
|
||||
|
||||
# Draw visible menu items
|
||||
for i in range(start_idx, min(start_idx + max_display, len(self.voice_modules))):
|
||||
y = (i - start_idx) + 5 # Start items at line 5
|
||||
|
||||
# Highlight the selected item
|
||||
module = self.voice_modules[i]
|
||||
if i == self.current_index:
|
||||
text = f" > {module} "
|
||||
attr = curses.A_REVERSE
|
||||
else:
|
||||
text = f" {module} "
|
||||
attr = curses.A_NORMAL
|
||||
|
||||
x = max(0, w // 2 - len(text) // 2)
|
||||
self.stdscr.addstr(y, x, text, attr)
|
||||
|
||||
self.stdscr.refresh()
|
||||
|
||||
def cleanup(self, full_cleanup=False):
|
||||
"""Clean up resources before exiting or executing a command
|
||||
|
||||
Args:
|
||||
full_cleanup: If True, also close curses. Used when exiting.
|
||||
"""
|
||||
# Stop any speech
|
||||
self.stop_speech()
|
||||
|
||||
# Close speech client
|
||||
if self.speech_client:
|
||||
try:
|
||||
self.speech_client.close()
|
||||
except:
|
||||
pass
|
||||
self.speech_client = None
|
||||
|
||||
# Restore terminal settings if curses was initialized
|
||||
if full_cleanup and self.curses_initialized:
|
||||
try:
|
||||
curses.nocbreak()
|
||||
self.stdscr.keypad(False)
|
||||
curses.echo()
|
||||
curses.endwin()
|
||||
except:
|
||||
# If there's an error, just try a simple endwin
|
||||
try:
|
||||
curses.endwin()
|
||||
except:
|
||||
pass # Last resort, just continue
|
||||
|
||||
def run(self):
|
||||
"""Run the menu system"""
|
||||
# Check if menu is empty or only has one item
|
||||
if not self.voice_modules:
|
||||
message = "No speech-dispatcher modules found. Exiting."
|
||||
print(message)
|
||||
|
||||
# Clean up and exit properly
|
||||
self.cleanup(full_cleanup=True)
|
||||
sys.exit(0)
|
||||
elif len(self.voice_modules) == 1:
|
||||
message = f"{self.voice_modules[0]} is the only available module and is already set for this system."
|
||||
print(message)
|
||||
|
||||
# Speak the message
|
||||
self.init_speech()
|
||||
if self.speech_client:
|
||||
# Use speech_client.speak directly with wait flag to ensure it completes
|
||||
self.speech_client.speak(message)
|
||||
# Wait for speech to complete
|
||||
self.speech_client.close()
|
||||
|
||||
# Clean up and exit properly
|
||||
self.cleanup(full_cleanup=True)
|
||||
sys.exit(0)
|
||||
|
||||
try:
|
||||
# Initialize curses
|
||||
self.stdscr = curses.initscr()
|
||||
self.curses_initialized = True
|
||||
curses.noecho()
|
||||
curses.cbreak()
|
||||
self.stdscr.keypad(True)
|
||||
|
||||
# Initial draw
|
||||
self.draw_menu()
|
||||
|
||||
# Welcome message
|
||||
self.speak(self.title, interrupt=False)
|
||||
|
||||
# Announce first item after welcome finishes
|
||||
self.announce_current_item(interrupt=False)
|
||||
|
||||
# Main loop
|
||||
while True:
|
||||
key = self.stdscr.getch()
|
||||
|
||||
# Stop any speech when a key is pressed
|
||||
self.stop_speech()
|
||||
|
||||
# Handle navigation
|
||||
if key == curses.KEY_UP:
|
||||
# Move to previous item
|
||||
self.current_index = (self.current_index - 1) % len(self.voice_modules)
|
||||
self.draw_menu()
|
||||
self.announce_current_item()
|
||||
|
||||
elif key == curses.KEY_DOWN:
|
||||
# Move to next item
|
||||
self.current_index = (self.current_index + 1) % len(self.voice_modules)
|
||||
self.draw_menu()
|
||||
self.announce_current_item()
|
||||
|
||||
elif key == curses.KEY_ENTER or key == 10 or key == 13: # Enter key
|
||||
self.set_default_module()
|
||||
|
||||
elif key == ord('h') or key == ord('H'): # Help
|
||||
self.speak_help()
|
||||
|
||||
elif key == 27 or key == ord('q') or key == ord('Q'): # Esc or Q
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
# End curses in case of error
|
||||
if self.curses_initialized:
|
||||
try:
|
||||
curses.endwin()
|
||||
except:
|
||||
pass
|
||||
print(f"An error occurred: {e}")
|
||||
finally:
|
||||
# Clean up - safe to call even if curses wasn't initialized
|
||||
self.cleanup(full_cleanup=True)
|
||||
|
||||
|
||||
# Run the menu
|
||||
if __name__ == "__main__":
|
||||
# Create the menu
|
||||
menu = VoiceSelectionMenu()
|
||||
|
||||
# Run the menu
|
||||
menu.run()
|
||||
Executable
+303
@@ -0,0 +1,303 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Self-voiced Speech Rate Configuration Menu
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import curses
|
||||
import speechd # Python bindings for Speech Dispatcher
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
class SpeechRateMenu:
|
||||
def __init__(self, title="Speech Rate Configuration"):
|
||||
self.title = title
|
||||
self.currentRate = 0 # Default rate
|
||||
self.stdscr = None
|
||||
self.cursesInitialized = False # Flag to track if curses has been initialized
|
||||
self.configFile = "/etc/speech-dispatcher/speechd.conf"
|
||||
|
||||
# Load current rate from config FIRST
|
||||
self.load_current_rate()
|
||||
|
||||
# Initialize speech client AFTER loading the rate
|
||||
self.speechClient = None
|
||||
self.init_speech()
|
||||
|
||||
def init_speech(self):
|
||||
"""Initialize the speech client"""
|
||||
try:
|
||||
self.speechClient = speechd.SSIPClient("speech_rate_menu")
|
||||
self.speechClient.set_priority(speechd.Priority.IMPORTANT)
|
||||
self.speechClient.set_punctuation(speechd.PunctuationMode.SOME)
|
||||
|
||||
# Apply the loaded rate to the speech client
|
||||
self.speechClient.set_rate(self.currentRate)
|
||||
except Exception as e:
|
||||
# Fallback to None - the speak method will handle this
|
||||
pass
|
||||
|
||||
def load_current_rate(self):
|
||||
"""Load the current default rate from speechd.conf"""
|
||||
try:
|
||||
with open(self.configFile, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
# First check for uncommented DefaultRate with flexible whitespace
|
||||
activeMatch = re.search(r'^\s*DefaultRate\s+(-?\d+)', content, re.MULTILINE)
|
||||
if activeMatch:
|
||||
self.currentRate = int(activeMatch.group(1))
|
||||
else:
|
||||
# If DefaultRate is commented out, get the value from commented line
|
||||
commentedMatch = re.search(r'^\s*#\s*DefaultRate\s+(-?\d+)', content, re.MULTILINE)
|
||||
if commentedMatch:
|
||||
self.currentRate = int(commentedMatch.group(1))
|
||||
|
||||
except Exception:
|
||||
# If loading fails, we'll use default value 0
|
||||
pass
|
||||
|
||||
def save_rate_to_config(self):
|
||||
"""Save the current rate to the speech-dispatcher config file"""
|
||||
try:
|
||||
# We need to use sudo to modify the system config file
|
||||
# This assumes the user has sudo privileges or the script is run as root
|
||||
|
||||
# Create a temporary file with the modified content
|
||||
with open(self.configFile, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
# Check if DefaultRate is already uncommented
|
||||
if re.search(r'^\s*DefaultRate\s+', content, re.MULTILINE):
|
||||
# Replace the existing DefaultRate line, preserving leading whitespace
|
||||
newContent = re.sub(
|
||||
r'^(\s*)DefaultRate\s+(-?\d+)',
|
||||
r'\1DefaultRate ' + str(self.currentRate),
|
||||
content,
|
||||
flags=re.MULTILINE
|
||||
)
|
||||
else:
|
||||
# Uncomment and update the DefaultRate line, preserving leading whitespace
|
||||
newContent = re.sub(
|
||||
r'^(\s*)#\s*DefaultRate\s+(-?\d+)',
|
||||
r'\1DefaultRate ' + str(self.currentRate),
|
||||
content,
|
||||
flags=re.MULTILINE
|
||||
)
|
||||
|
||||
# Write to a temporary file
|
||||
tempFile = "/tmp/speechd.conf.new"
|
||||
with open(tempFile, 'w') as f:
|
||||
f.write(newContent)
|
||||
|
||||
# Use sudo to move the file to the correct location
|
||||
cmd = f"sudo mv {tempFile} {self.configFile}"
|
||||
subprocess.run(cmd, shell=True, check=True)
|
||||
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def adjust_rate(self, amount):
|
||||
"""Adjust the speech rate by the given amount"""
|
||||
# Rate should be between -50 and 100
|
||||
newRate = max(-50, min(100, self.currentRate + amount))
|
||||
|
||||
if newRate != self.currentRate:
|
||||
self.currentRate = newRate
|
||||
if self.speechClient:
|
||||
try:
|
||||
self.speechClient.set_rate(self.currentRate)
|
||||
self.speak(f"Speech rate {self.currentRate}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def speak(self, text, interrupt=True):
|
||||
"""Speak the given text with option to interrupt existing speech"""
|
||||
if self.speechClient is None:
|
||||
return
|
||||
|
||||
try:
|
||||
if interrupt:
|
||||
self.stop_speech()
|
||||
|
||||
self.speechClient.speak(text)
|
||||
except Exception:
|
||||
# If speech fails, try to reinitialize and try once more
|
||||
try:
|
||||
self.init_speech()
|
||||
if self.speechClient:
|
||||
self.speechClient.speak(text)
|
||||
except:
|
||||
# If reinitializing fails, just give up silently
|
||||
pass
|
||||
|
||||
def stop_speech(self):
|
||||
"""Stop any ongoing speech"""
|
||||
if self.speechClient is None:
|
||||
return
|
||||
|
||||
try:
|
||||
self.speechClient.cancel()
|
||||
except Exception:
|
||||
# If cancel fails, try to reinitialize
|
||||
self.init_speech()
|
||||
|
||||
def draw_menu(self):
|
||||
"""Draw the menu on the screen"""
|
||||
self.stdscr.clear()
|
||||
h, w = self.stdscr.getmaxyx()
|
||||
|
||||
# Draw title
|
||||
title = f" {self.title} "
|
||||
x = max(0, w // 2 - len(title) // 2)
|
||||
self.stdscr.addstr(1, x, title, curses.A_BOLD)
|
||||
|
||||
# Draw help line
|
||||
helpText = "Up/Down: Adjust Rate | Enter: Save | Q/Esc: Quit"
|
||||
x = max(0, w // 2 - len(helpText) // 2)
|
||||
self.stdscr.addstr(3, x, helpText)
|
||||
|
||||
# Draw current rate
|
||||
rateText = f"Current Rate: {self.currentRate}"
|
||||
x = max(0, w // 2 - len(rateText) // 2)
|
||||
self.stdscr.addstr(5, x, rateText, curses.A_REVERSE)
|
||||
|
||||
# Draw rate visualization
|
||||
barWidth = 50 # Width of the visualization bar
|
||||
barX = max(0, w // 2 - barWidth // 2)
|
||||
|
||||
# Map rate (-50 to 100) to bar position (0 to barWidth)
|
||||
rateRange = 150 # Total range (from -50 to 100)
|
||||
normalizedRate = self.currentRate + 50 # Shift to 0-150 range
|
||||
position = int((normalizedRate / rateRange) * barWidth)
|
||||
|
||||
# Draw the bar
|
||||
barY = 7
|
||||
self.stdscr.addstr(barY, barX, "┌" + "─" * barWidth + "┐")
|
||||
self.stdscr.addstr(barY + 1, barX, "│" + " " * barWidth + "│")
|
||||
self.stdscr.addstr(barY + 2, barX, "└" + "─" * barWidth + "┘")
|
||||
|
||||
# Draw the position marker
|
||||
if 0 <= position < barWidth:
|
||||
self.stdscr.addstr(barY + 1, barX + 1 + position, "█", curses.A_BOLD)
|
||||
|
||||
# Add labels for min and max
|
||||
self.stdscr.addstr(barY + 3, barX, "-50")
|
||||
self.stdscr.addstr(barY + 3, barX + barWidth - 3, "100")
|
||||
|
||||
# Note about saving
|
||||
note = "Press Enter to save the rate to system config"
|
||||
x = max(0, w // 2 - len(note) // 2)
|
||||
self.stdscr.addstr(h - 3, x, note, curses.A_DIM)
|
||||
|
||||
# Warning about system config
|
||||
warning = "Note: Saving requires sudo privileges"
|
||||
x = max(0, w // 2 - len(warning) // 2)
|
||||
self.stdscr.addstr(h - 2, x, warning, curses.A_DIM)
|
||||
|
||||
self.stdscr.refresh()
|
||||
|
||||
def cleanup(self, fullCleanup=False):
|
||||
"""Clean up resources before exiting
|
||||
|
||||
Args:
|
||||
fullCleanup: If True, also close curses. Used when exiting.
|
||||
"""
|
||||
# Stop any speech
|
||||
self.stop_speech()
|
||||
|
||||
# Close speech client
|
||||
if self.speechClient:
|
||||
try:
|
||||
self.speechClient.close()
|
||||
except:
|
||||
pass
|
||||
self.speechClient = None
|
||||
|
||||
# Restore terminal settings if curses was initialized
|
||||
if fullCleanup and self.cursesInitialized:
|
||||
try:
|
||||
curses.nocbreak()
|
||||
self.stdscr.keypad(False)
|
||||
curses.echo()
|
||||
curses.endwin()
|
||||
except:
|
||||
# If there's an error, just try a simple endwin
|
||||
try:
|
||||
curses.endwin()
|
||||
except:
|
||||
pass # Last resort, just continue
|
||||
|
||||
def run(self):
|
||||
"""Run the menu system"""
|
||||
try:
|
||||
# Initialize curses
|
||||
self.stdscr = curses.initscr()
|
||||
self.cursesInitialized = True
|
||||
curses.noecho()
|
||||
curses.cbreak()
|
||||
self.stdscr.keypad(True)
|
||||
|
||||
# Initial draw
|
||||
self.draw_menu()
|
||||
|
||||
# Welcome message
|
||||
self.speak(f"The current rate for the default voice is {self.currentRate}.")
|
||||
|
||||
# Main loop
|
||||
while True:
|
||||
key = self.stdscr.getch()
|
||||
|
||||
# Stop any speech when a key is pressed
|
||||
self.stop_speech()
|
||||
|
||||
# Handle navigation
|
||||
if key == curses.KEY_UP:
|
||||
# Increase rate by 10
|
||||
self.adjust_rate(10)
|
||||
self.draw_menu()
|
||||
|
||||
elif key == curses.KEY_DOWN:
|
||||
# Decrease rate by 10
|
||||
self.adjust_rate(-10)
|
||||
self.draw_menu()
|
||||
|
||||
elif key == curses.KEY_ENTER or key == 10 or key == 13: # Enter key
|
||||
# Save the rate
|
||||
self.speak("Saving speech rate to system configuration.")
|
||||
success = self.save_rate_to_config()
|
||||
if success:
|
||||
self.speak(f"Speech rate {self.currentRate} has been saved successfully.")
|
||||
else:
|
||||
self.speak("Failed to save speech rate. You may need root privileges.")
|
||||
|
||||
# Wait briefly to allow speech to complete before exiting
|
||||
time.sleep(3)
|
||||
break # Exit the loop after saving
|
||||
|
||||
elif key == 27 or key == ord('q') or key == ord('Q'): # Esc or Q
|
||||
self.speak("Exiting speech rate configuration.")
|
||||
break
|
||||
|
||||
except Exception:
|
||||
# End curses in case of error
|
||||
if self.cursesInitialized:
|
||||
try:
|
||||
curses.endwin()
|
||||
except:
|
||||
pass
|
||||
finally:
|
||||
# Clean up - safe to call even if curses wasn't initialized
|
||||
self.cleanup(fullCleanup=True)
|
||||
|
||||
|
||||
# Run the menu
|
||||
if __name__ == "__main__":
|
||||
# Create the menu
|
||||
menu = SpeechRateMenu()
|
||||
|
||||
# Run the menu
|
||||
menu.run()
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
date_time=$(curl -s http://worldtimeapi.org/api/ip | grep -oP '(?<="datetime":")[^"]*')
|
||||
date -s "$date_time"
|
||||
@@ -0,0 +1 @@
|
||||
*.wav filter=lfs diff=lfs merge=lfs -text
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user