commit 52e1656e4217c2458fb0e75ae5166ae523d6e1b5 Author: Storm Dragon Date: Sat Jul 12 13:48:20 2025 -0400 Initial commit diff --git a/home/stormux/.Upheaval b/home/stormux/.Upheaval new file mode 100644 index 0000000..dcaefcf --- /dev/null +++ b/home/stormux/.Upheaval @@ -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 diff --git a/home/stormux/.Warsim b/home/stormux/.Warsim new file mode 100755 index 0000000..e5c3302 --- /dev/null +++ b/home/stormux/.Warsim @@ -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 diff --git a/home/stormux/.clirc b/home/stormux/.clirc new file mode 100755 index 0000000..ada969b --- /dev/null +++ b/home/stormux/.clirc @@ -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 diff --git a/home/stormux/.inputrc b/home/stormux/.inputrc new file mode 100644 index 0000000..0dcf6ea --- /dev/null +++ b/home/stormux/.inputrc @@ -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 diff --git a/home/stormux/.xinitrc b/home/stormux/.xinitrc new file mode 100755 index 0000000..e773dbb --- /dev/null +++ b/home/stormux/.xinitrc @@ -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 diff --git a/home/stormux/.xprofile b/home/stormux/.xprofile new file mode 100644 index 0000000..4aa3523 --- /dev/null +++ b/home/stormux/.xprofile @@ -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 diff --git a/usr/lib/systemd/system/fenrirscreenreader-tty.service b/usr/lib/systemd/system/fenrirscreenreader-tty.service new file mode 100644 index 0000000..6307648 --- /dev/null +++ b/usr/lib/systemd/system/fenrirscreenreader-tty.service @@ -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 diff --git a/usr/lib/systemd/system/shutdown-sound.service b/usr/lib/systemd/system/shutdown-sound.service new file mode 100644 index 0000000..3e54a71 --- /dev/null +++ b/usr/lib/systemd/system/shutdown-sound.service @@ -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 diff --git a/usr/local/bin/apple_2e.py b/usr/local/bin/apple_2e.py new file mode 100755 index 0000000..7e9c93d --- /dev/null +++ b/usr/local/bin/apple_2e.py @@ -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() diff --git a/usr/local/bin/diagnostics.sh b/usr/local/bin/diagnostics.sh new file mode 100755 index 0000000..d7eb075 --- /dev/null +++ b/usr/local/bin/diagnostics.sh @@ -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 diff --git a/usr/local/bin/game_launcher.py b/usr/local/bin/game_launcher.py new file mode 100755 index 0000000..1695b90 --- /dev/null +++ b/usr/local/bin/game_launcher.py @@ -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() diff --git a/usr/local/bin/music_player.py b/usr/local/bin/music_player.py new file mode 100755 index 0000000..8b7cf14 --- /dev/null +++ b/usr/local/bin/music_player.py @@ -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() diff --git a/usr/local/bin/ocr.py b/usr/local/bin/ocr.py new file mode 100644 index 0000000..ea99239 --- /dev/null +++ b/usr/local/bin/ocr.py @@ -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() diff --git a/usr/local/bin/record.sh b/usr/local/bin/record.sh new file mode 100755 index 0000000..32bee03 --- /dev/null +++ b/usr/local/bin/record.sh @@ -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 diff --git a/usr/local/bin/rom_launcher.py b/usr/local/bin/rom_launcher.py new file mode 100755 index 0000000..6caf87e --- /dev/null +++ b/usr/local/bin/rom_launcher.py @@ -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() diff --git a/usr/local/bin/set-voice.py b/usr/local/bin/set-voice.py new file mode 100755 index 0000000..0651045 --- /dev/null +++ b/usr/local/bin/set-voice.py @@ -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() diff --git a/usr/local/bin/speechd_rate.py b/usr/local/bin/speechd_rate.py new file mode 100755 index 0000000..ddf32f0 --- /dev/null +++ b/usr/local/bin/speechd_rate.py @@ -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() diff --git a/usr/local/bin/sync_time.sh b/usr/local/bin/sync_time.sh new file mode 100755 index 0000000..aea1925 --- /dev/null +++ b/usr/local/bin/sync_time.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash + +date_time=$(curl -s http://worldtimeapi.org/api/ip | grep -oP '(?<="datetime":")[^"]*') +date -s "$date_time" diff --git a/usr/share/sounds/stormux/.gitattributes b/usr/share/sounds/stormux/.gitattributes new file mode 100644 index 0000000..d899f65 --- /dev/null +++ b/usr/share/sounds/stormux/.gitattributes @@ -0,0 +1 @@ +*.wav filter=lfs diff=lfs merge=lfs -text diff --git a/usr/share/sounds/stormux/menu_category.wav b/usr/share/sounds/stormux/menu_category.wav new file mode 100644 index 0000000..1720a28 --- /dev/null +++ b/usr/share/sounds/stormux/menu_category.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fa3c7bfc7fb89a8cf457f614800b1c6864e75e73498f41741e9ae9980f98c185 +size 79424 diff --git a/usr/share/sounds/stormux/menu_move.wav b/usr/share/sounds/stormux/menu_move.wav new file mode 100644 index 0000000..9c0773f --- /dev/null +++ b/usr/share/sounds/stormux/menu_move.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c313b4c70e7b6fb4ee4b9eb21cd90ef3ec335a3be6565cded42d42e1e1c0821a +size 48044 diff --git a/usr/share/sounds/stormux/menu_select.wav b/usr/share/sounds/stormux/menu_select.wav new file mode 100644 index 0000000..3803207 --- /dev/null +++ b/usr/share/sounds/stormux/menu_select.wav @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0e5ce46d4c556edc642c315c8ba029dbf7c67a9263379d312324263f790e307c +size 144044 diff --git a/usr/share/sounds/stormux/start.opus b/usr/share/sounds/stormux/start.opus new file mode 100644 index 0000000..ef542c9 Binary files /dev/null and b/usr/share/sounds/stormux/start.opus differ diff --git a/usr/share/sounds/stormux/stop.opus b/usr/share/sounds/stormux/stop.opus new file mode 100644 index 0000000..b346979 Binary files /dev/null and b/usr/share/sounds/stormux/stop.opus differ