Initial commit

This commit is contained in:
Storm Dragon
2025-07-12 13:48:20 -04:00
commit 52e1656e42
24 changed files with 3874 additions and 0 deletions
+33
View File
@@ -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
+14
View File
@@ -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
+113
View File
@@ -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
+6
View File
@@ -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
+351
View File
@@ -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
+7
View File
@@ -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
+390
View File
@@ -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()
+33
View File
@@ -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
+931
View File
@@ -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()
+621
View File
@@ -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()
+138
View File
@@ -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()
+27
View File
@@ -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
+507
View File
@@ -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()
+358
View File
@@ -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()
+303
View File
@@ -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()
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
date_time=$(curl -s http://worldtimeapi.org/api/ip | grep -oP '(?<="datetime":")[^"]*')
date -s "$date_time"
+1
View File
@@ -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.