Reorganize files for new image. Add new paths in home directory. Start on game launchers and installers.
This commit is contained in:
@@ -6,7 +6,7 @@ ConditionPathExists=/sys/class/power_supply
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/local/bin/battery_monitor.py
|
||||
ExecStart=/home/stormux/.local/bin/battery_monitor.py
|
||||
Restart=always
|
||||
RestartSec=30
|
||||
User=stormux
|
||||
@@ -24,4 +24,4 @@ Environment=PULSE_RUNTIME_PATH=/run/user/1000/pulse
|
||||
# This requires sudoers configuration
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
WantedBy=default.target
|
||||
|
||||
@@ -1,403 +0,0 @@
|
||||
#!/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)
|
||||
|
||||
# Handle special boot options
|
||||
if path == "TEXTALKER_ONLY":
|
||||
# Boot with TextTalker only (original Apple 2e option)
|
||||
os.system('export GAME="Apple 2e" && startx')
|
||||
elif path.startswith("DISK_ONLY:"):
|
||||
# Boot with disk only, no TextTalker
|
||||
os.system(f'export GAME="{path}" && startx')
|
||||
else:
|
||||
# Boot with TextTalker + specified disk (default behavior)
|
||||
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"""
|
||||
# Add special boot options first
|
||||
self.menu_items.append(("Boot with TextTalker only", "TEXTALKER_ONLY"))
|
||||
|
||||
# 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 disk files
|
||||
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 both TextTalker + disk and disk-only options
|
||||
self.menu_items.append((f"{display_name} (with TextTalker)", file_path))
|
||||
self.menu_items.append((f"{display_name} (disk only)", f"DISK_ONLY:{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()
|
||||
@@ -1,622 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import curses
|
||||
import configparser
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
try:
|
||||
import simpleaudio as sa
|
||||
except Exception:
|
||||
sa = None
|
||||
|
||||
try:
|
||||
import speechd
|
||||
except Exception:
|
||||
speechd = None
|
||||
|
||||
|
||||
FEEDBACK_SOUND = "/usr/share/sounds/stormux/menu_move.wav"
|
||||
TEST_SOUND = "/usr/share/sounds/stormux/menu_select.wav"
|
||||
HELP_TEXT = (
|
||||
"Use left and right arrows to select a control. "
|
||||
"Use up and down arrows to adjust it. "
|
||||
"Press space to read the current value. "
|
||||
"Press h to hear this help again. "
|
||||
"Press enter to confirm a pending device change or apply the selected device. "
|
||||
"Press escape or q to exit."
|
||||
)
|
||||
|
||||
|
||||
def clamp_percent(value, minimum, maximum):
|
||||
return max(minimum, min(maximum, value))
|
||||
|
||||
|
||||
def format_percent_text(value):
|
||||
return f"{value} percent"
|
||||
|
||||
|
||||
def cycle_index(current_index, delta, total_count):
|
||||
if total_count <= 0:
|
||||
return 0
|
||||
return (current_index + delta) % total_count
|
||||
|
||||
|
||||
def pending_change_expired(change, now_value=None):
|
||||
if change is None:
|
||||
return False
|
||||
if now_value is None:
|
||||
now_value = time.time()
|
||||
return (now_value - change.started_at) >= change.timeout_seconds
|
||||
|
||||
|
||||
def build_default_command(device_kind, device_name):
|
||||
if device_kind == "sink":
|
||||
return ["pactl", "set-default-sink", str(device_name)]
|
||||
if device_kind == "source":
|
||||
return ["pactl", "set-default-source", str(device_name)]
|
||||
raise ValueError(f"Unsupported device kind: {device_kind}")
|
||||
|
||||
|
||||
def build_volume_command(device_kind, device_name, volume_percent):
|
||||
bounded = clamp_percent(volume_percent, 0, 150)
|
||||
if device_kind == "sink":
|
||||
return ["pactl", "set-sink-volume", str(device_name), f"{bounded}%"]
|
||||
if device_kind == "source":
|
||||
return ["pactl", "set-source-volume", str(device_name), f"{bounded}%"]
|
||||
raise ValueError(f"Unsupported device kind: {device_kind}")
|
||||
|
||||
|
||||
def is_monitor_source(device_name, description):
|
||||
lowered_name = (device_name or "").lower()
|
||||
lowered_description = (description or "").lower()
|
||||
return lowered_name.endswith(".monitor") or lowered_description.startswith("monitor of ")
|
||||
|
||||
|
||||
def extract_devices(payload, exclude_monitors=False):
|
||||
devices = []
|
||||
for item in payload:
|
||||
device_id = item.get("index")
|
||||
node_name = item.get("name", "")
|
||||
properties = item.get("properties") or {}
|
||||
description = (
|
||||
properties.get("device.description")
|
||||
or item.get("description")
|
||||
or properties.get("node.description")
|
||||
or node_name
|
||||
or f"Device {device_id}"
|
||||
)
|
||||
|
||||
if device_id is None:
|
||||
continue
|
||||
|
||||
if exclude_monitors and is_monitor_source(node_name, description):
|
||||
continue
|
||||
|
||||
devices.append(
|
||||
{
|
||||
"id": device_id,
|
||||
"name": description,
|
||||
"node_name": node_name,
|
||||
}
|
||||
)
|
||||
return devices
|
||||
|
||||
|
||||
def parse_percent_volume(output_text):
|
||||
match = re.search(r"(\d+)%", output_text)
|
||||
if not match:
|
||||
raise RuntimeError(f"Unexpected percent volume output: {output_text}")
|
||||
return int(match.group(1))
|
||||
|
||||
|
||||
def parse_fraction_volume(output_text):
|
||||
match = re.search(r"([0-9]*\.?[0-9]+)", output_text)
|
||||
if not match:
|
||||
raise RuntimeError(f"Unexpected fractional volume output: {output_text}")
|
||||
return round(float(match.group(1)) * 100)
|
||||
|
||||
|
||||
def build_external_sound_command(file_path):
|
||||
player_candidates = [
|
||||
("pw-play", ["pw-play", file_path]),
|
||||
("paplay", ["paplay", file_path]),
|
||||
("aplay", ["aplay", file_path]),
|
||||
("play", ["play", "-q", file_path]),
|
||||
]
|
||||
for executable, command in player_candidates:
|
||||
if shutil.which(executable) is not None:
|
||||
return command
|
||||
return None
|
||||
|
||||
|
||||
def build_feedback_tone_command():
|
||||
if shutil.which("sox") is None:
|
||||
return None
|
||||
return ["sox", "-nqdV0", "synth", ".1", "tri", "840", "fade", ".04", ".1", ".04"]
|
||||
|
||||
|
||||
def find_device_index(devices, *, node_name=None, device_id=None):
|
||||
for index, device in enumerate(devices):
|
||||
if device_id is not None and device["id"] == device_id:
|
||||
return index
|
||||
if node_name is not None and device["node_name"] == node_name:
|
||||
return index
|
||||
return 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class PendingDeviceChange:
|
||||
device_kind: str
|
||||
previous_target: str
|
||||
candidate_target: str
|
||||
candidate_name: str
|
||||
started_at: float = 0.0
|
||||
timeout_seconds: int = 15
|
||||
|
||||
|
||||
class AudioBackend:
|
||||
def run_command(self, command):
|
||||
return subprocess.run(command, capture_output=True, text=True, check=True)
|
||||
|
||||
def list_sinks(self):
|
||||
payload = json.loads(self.run_command(["pactl", "--format=json", "list", "sinks"]).stdout)
|
||||
return extract_devices(payload)
|
||||
|
||||
def list_sources(self):
|
||||
payload = json.loads(self.run_command(["pactl", "--format=json", "list", "sources"]).stdout)
|
||||
return extract_devices(payload, exclude_monitors=True)
|
||||
|
||||
def get_default_sink_name(self):
|
||||
return self.run_command(["pactl", "get-default-sink"]).stdout.strip()
|
||||
|
||||
def get_default_source_name(self):
|
||||
return self.run_command(["pactl", "get-default-source"]).stdout.strip()
|
||||
|
||||
def set_default_device(self, device_kind, device_name):
|
||||
self.run_command(build_default_command(device_kind, device_name))
|
||||
|
||||
def set_device_volume(self, device_kind, device_name, percent_value):
|
||||
self.run_command(build_volume_command(device_kind, device_name, percent_value))
|
||||
|
||||
def get_device_volume(self, device_kind, device_name):
|
||||
if device_kind == "sink":
|
||||
output = self.run_command(["pactl", "get-sink-volume", str(device_name)]).stdout.strip()
|
||||
elif device_kind == "source":
|
||||
output = self.run_command(["pactl", "get-source-volume", str(device_name)]).stdout.strip()
|
||||
else:
|
||||
raise ValueError(f"Unsupported device kind: {device_kind}")
|
||||
return parse_percent_volume(output)
|
||||
|
||||
|
||||
class AudioFeedback:
|
||||
def __init__(self):
|
||||
self.current_sound = None
|
||||
self.current_process = None
|
||||
|
||||
def stop_sound(self):
|
||||
if self.current_sound is not None and self.current_sound.is_playing():
|
||||
self.current_sound.stop()
|
||||
self.current_sound = None
|
||||
if self.current_process is not None and self.current_process.poll() is None:
|
||||
self.current_process.terminate()
|
||||
self.current_process = None
|
||||
|
||||
def play_sound_file(self, file_path):
|
||||
if sa is not None and os.path.exists(file_path):
|
||||
try:
|
||||
self.stop_sound()
|
||||
self.current_sound = sa.WaveObject.from_wave_file(file_path).play()
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
external_command = build_external_sound_command(file_path)
|
||||
if external_command is not None:
|
||||
try:
|
||||
self.stop_sound()
|
||||
self.current_process = subprocess.Popen(
|
||||
external_command,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
curses.beep()
|
||||
|
||||
def play_feedback_beep(self):
|
||||
tone_command = build_feedback_tone_command()
|
||||
if tone_command is not None:
|
||||
try:
|
||||
self.stop_sound()
|
||||
self.current_process = subprocess.Popen(
|
||||
tone_command,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.play_sound_file(FEEDBACK_SOUND)
|
||||
|
||||
def play_test_beep(self):
|
||||
self.play_sound_file(TEST_SOUND)
|
||||
|
||||
|
||||
class AudioManagerApp:
|
||||
def __init__(self):
|
||||
self.backend = AudioBackend()
|
||||
self.feedback = AudioFeedback()
|
||||
self.speech_client = None
|
||||
self.config_dir = os.path.expanduser("~/.config/stormux")
|
||||
self.config_file = os.path.join(self.config_dir, "game_launcher.conf")
|
||||
self.config = configparser.ConfigParser()
|
||||
self.speech_rate = 0
|
||||
self.speech_pitch = 0
|
||||
self.stdscr = None
|
||||
self.focus_names = [
|
||||
"Output Volume",
|
||||
"Microphone Volume",
|
||||
"Output Device",
|
||||
"Microphone Device",
|
||||
]
|
||||
self.focus_index = 0
|
||||
self.output_devices = []
|
||||
self.input_devices = []
|
||||
self.output_index = 0
|
||||
self.input_index = 0
|
||||
self.default_output_id = None
|
||||
self.default_input_id = None
|
||||
self.default_output_name = ""
|
||||
self.default_input_name = ""
|
||||
self.output_volume = 50
|
||||
self.input_volume = 50
|
||||
self.pending_change = None
|
||||
self.status_message = ""
|
||||
self.load_speech_settings()
|
||||
|
||||
def load_speech_settings(self):
|
||||
if not os.path.exists(self.config_file):
|
||||
return
|
||||
|
||||
try:
|
||||
self.config.read(self.config_file)
|
||||
if "Speech" in self.config:
|
||||
self.speech_rate = self.config.getint("Speech", "rate", fallback=0)
|
||||
self.speech_pitch = self.config.getint("Speech", "pitch", fallback=0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def init_speech(self):
|
||||
if speechd is None:
|
||||
return
|
||||
self.speech_client = speechd.SSIPClient("audio_manager")
|
||||
self.speech_client.set_priority(speechd.Priority.IMPORTANT)
|
||||
self.speech_client.set_punctuation(speechd.PunctuationMode.SOME)
|
||||
self.speech_client.set_rate(self.speech_rate)
|
||||
self.speech_client.set_pitch(self.speech_pitch)
|
||||
|
||||
def speak(self, text, interrupt=True):
|
||||
if not self.speech_client:
|
||||
return
|
||||
|
||||
try:
|
||||
if interrupt:
|
||||
self.speech_client.cancel()
|
||||
self.speech_client.speak(text)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def set_status(self, message, speak_message=False):
|
||||
self.status_message = message
|
||||
if speak_message:
|
||||
self.speak(message)
|
||||
|
||||
def get_selected_output_device(self):
|
||||
if not self.output_devices:
|
||||
return None
|
||||
return self.output_devices[self.output_index]
|
||||
|
||||
def get_selected_input_device(self):
|
||||
if not self.input_devices:
|
||||
return None
|
||||
return self.input_devices[self.input_index]
|
||||
|
||||
def refresh_state(self, preserve_selection=False):
|
||||
previous_output_id = None
|
||||
previous_input_id = None
|
||||
if preserve_selection:
|
||||
selected_output = self.get_selected_output_device()
|
||||
selected_input = self.get_selected_input_device()
|
||||
previous_output_id = None if selected_output is None else selected_output["id"]
|
||||
previous_input_id = None if selected_input is None else selected_input["id"]
|
||||
|
||||
self.output_devices = self.backend.list_sinks()
|
||||
self.input_devices = self.backend.list_sources()
|
||||
|
||||
if not self.output_devices:
|
||||
raise RuntimeError("No output devices found.")
|
||||
if not self.input_devices:
|
||||
raise RuntimeError("No microphone devices found.")
|
||||
|
||||
default_output_name = self.backend.get_default_sink_name()
|
||||
default_input_name = self.backend.get_default_source_name()
|
||||
self.default_output_name = default_output_name
|
||||
self.default_input_name = default_input_name
|
||||
|
||||
self.output_index = find_device_index(
|
||||
self.output_devices,
|
||||
device_id=previous_output_id if preserve_selection else None,
|
||||
node_name=None if preserve_selection else default_output_name,
|
||||
)
|
||||
self.input_index = find_device_index(
|
||||
self.input_devices,
|
||||
device_id=previous_input_id if preserve_selection else None,
|
||||
node_name=None if preserve_selection else default_input_name,
|
||||
)
|
||||
|
||||
self.default_output_id = self.output_devices[
|
||||
find_device_index(self.output_devices, node_name=default_output_name)
|
||||
]["id"]
|
||||
self.default_input_id = self.input_devices[
|
||||
find_device_index(self.input_devices, node_name=default_input_name)
|
||||
]["id"]
|
||||
|
||||
self.output_volume = self.backend.get_device_volume("sink", self.default_output_name)
|
||||
self.input_volume = self.backend.get_device_volume("source", self.default_input_name)
|
||||
|
||||
def apply_volume_delta(self, delta):
|
||||
try:
|
||||
if self.focus_index == 0:
|
||||
new_value = clamp_percent(self.output_volume + delta, 0, 150)
|
||||
self.backend.set_device_volume("sink", self.default_output_name, new_value)
|
||||
self.output_volume = new_value
|
||||
self.feedback.play_feedback_beep()
|
||||
elif self.focus_index == 1:
|
||||
new_value = clamp_percent(self.input_volume + delta, 0, 150)
|
||||
self.backend.set_device_volume("source", self.default_input_name, new_value)
|
||||
self.input_volume = new_value
|
||||
self.feedback.play_feedback_beep()
|
||||
except Exception as error:
|
||||
self.set_status(f"Volume change failed: {error}", speak_message=True)
|
||||
|
||||
def announce_current_focus(self):
|
||||
if self.focus_index == 0:
|
||||
self.speak(format_percent_text(self.output_volume))
|
||||
elif self.focus_index == 1:
|
||||
self.speak(format_percent_text(self.input_volume))
|
||||
elif self.focus_index == 2:
|
||||
device = self.get_selected_output_device()
|
||||
if device:
|
||||
self.speak(device["name"])
|
||||
elif self.focus_index == 3:
|
||||
device = self.get_selected_input_device()
|
||||
if device:
|
||||
self.speak(device["name"])
|
||||
|
||||
def move_focus(self, delta):
|
||||
self.focus_index = cycle_index(self.focus_index, delta, len(self.focus_names))
|
||||
self.speak(self.focus_names[self.focus_index])
|
||||
|
||||
def handle_up(self):
|
||||
if self.focus_index in (0, 1):
|
||||
self.apply_volume_delta(5)
|
||||
return
|
||||
|
||||
if self.focus_index == 2 and self.output_devices:
|
||||
self.output_index = cycle_index(self.output_index, -1, len(self.output_devices))
|
||||
self.speak(self.output_devices[self.output_index]["name"])
|
||||
elif self.focus_index == 3 and self.input_devices:
|
||||
self.input_index = cycle_index(self.input_index, -1, len(self.input_devices))
|
||||
self.speak(self.input_devices[self.input_index]["name"])
|
||||
|
||||
def handle_down(self):
|
||||
if self.focus_index in (0, 1):
|
||||
self.apply_volume_delta(-5)
|
||||
return
|
||||
|
||||
if self.focus_index == 2 and self.output_devices:
|
||||
self.output_index = cycle_index(self.output_index, 1, len(self.output_devices))
|
||||
self.speak(self.output_devices[self.output_index]["name"])
|
||||
elif self.focus_index == 3 and self.input_devices:
|
||||
self.input_index = cycle_index(self.input_index, 1, len(self.input_devices))
|
||||
self.speak(self.input_devices[self.input_index]["name"])
|
||||
|
||||
def start_pending_device_change(self, device_kind, previous_target, candidate):
|
||||
try:
|
||||
self.backend.set_default_device(device_kind, candidate["node_name"])
|
||||
self.pending_change = PendingDeviceChange(
|
||||
device_kind="output device" if device_kind == "sink" else "microphone device",
|
||||
previous_target=previous_target,
|
||||
candidate_target=candidate["node_name"],
|
||||
candidate_name=candidate["name"],
|
||||
started_at=time.time(),
|
||||
)
|
||||
|
||||
if device_kind == "sink":
|
||||
self.default_output_id = candidate["id"]
|
||||
self.default_output_name = candidate["node_name"]
|
||||
self.output_volume = self.backend.get_device_volume("sink", self.default_output_name)
|
||||
self.feedback.play_test_beep()
|
||||
else:
|
||||
self.default_input_id = candidate["id"]
|
||||
self.default_input_name = candidate["node_name"]
|
||||
self.input_volume = self.backend.get_device_volume("source", self.default_input_name)
|
||||
|
||||
self.set_status(
|
||||
f"{self.pending_change.device_kind.capitalize()} changed to {candidate['name']}. Press enter within 15 seconds to keep it.",
|
||||
speak_message=True,
|
||||
)
|
||||
except Exception as error:
|
||||
self.set_status(f"Device change failed: {error}", speak_message=True)
|
||||
|
||||
def confirm_pending_change(self):
|
||||
if self.pending_change is None:
|
||||
return
|
||||
|
||||
message = f"{self.pending_change.device_kind.capitalize()} confirmed."
|
||||
self.pending_change = None
|
||||
self.refresh_state()
|
||||
self.set_status(message, speak_message=True)
|
||||
|
||||
def rollback_pending_change(self):
|
||||
if self.pending_change is None:
|
||||
return
|
||||
|
||||
try:
|
||||
change = self.pending_change
|
||||
rollback_kind = "sink" if change.device_kind == "output device" else "source"
|
||||
self.backend.set_default_device(rollback_kind, change.previous_target)
|
||||
self.pending_change = None
|
||||
self.refresh_state()
|
||||
self.set_status(
|
||||
f"Restored previous {change.device_kind}.",
|
||||
speak_message=True,
|
||||
)
|
||||
except Exception as error:
|
||||
self.pending_change = None
|
||||
self.set_status(f"Failed to restore previous device: {error}", speak_message=True)
|
||||
|
||||
def activate_selected_device(self):
|
||||
if self.pending_change is not None:
|
||||
self.confirm_pending_change()
|
||||
return
|
||||
|
||||
if self.focus_index == 2:
|
||||
candidate = self.get_selected_output_device()
|
||||
if candidate is None:
|
||||
return
|
||||
if candidate["id"] == self.default_output_id:
|
||||
self.speak(f"Already using {candidate['name']}")
|
||||
return
|
||||
self.start_pending_device_change("sink", self.default_output_name, candidate)
|
||||
elif self.focus_index == 3:
|
||||
candidate = self.get_selected_input_device()
|
||||
if candidate is None:
|
||||
return
|
||||
if candidate["id"] == self.default_input_id:
|
||||
self.speak(f"Already using {candidate['name']}")
|
||||
return
|
||||
self.start_pending_device_change("source", self.default_input_name, candidate)
|
||||
else:
|
||||
self.announce_current_focus()
|
||||
|
||||
def draw(self):
|
||||
self.stdscr.clear()
|
||||
self.stdscr.addstr(1, 2, "Stormux Audio Manager", curses.A_BOLD)
|
||||
|
||||
output_device = self.get_selected_output_device()
|
||||
input_device = self.get_selected_input_device()
|
||||
rows = [
|
||||
f"Output Volume: {format_percent_text(self.output_volume)}",
|
||||
f"Microphone Volume: {format_percent_text(self.input_volume)}",
|
||||
f"Output Device: {'' if output_device is None else output_device['name']}",
|
||||
f"Microphone Device: {'' if input_device is None else input_device['name']}",
|
||||
]
|
||||
|
||||
for row_index, row_text in enumerate(rows):
|
||||
attribute = curses.A_REVERSE if row_index == self.focus_index else curses.A_NORMAL
|
||||
self.stdscr.addstr(4 + row_index, 2, row_text, attribute)
|
||||
|
||||
help_text = "Left/Right: Move Up/Down: Change Space: Speak Enter: Apply or Confirm H: Help Q/Esc: Exit"
|
||||
self.stdscr.addstr(10, 2, help_text)
|
||||
|
||||
if self.pending_change is not None:
|
||||
seconds_left = max(
|
||||
0,
|
||||
self.pending_change.timeout_seconds - int(time.time() - self.pending_change.started_at),
|
||||
)
|
||||
self.stdscr.addstr(
|
||||
12,
|
||||
2,
|
||||
f"Pending {self.pending_change.device_kind}: confirm within {seconds_left} seconds.",
|
||||
curses.A_BOLD,
|
||||
)
|
||||
|
||||
if self.status_message:
|
||||
self.stdscr.addstr(14, 2, self.status_message)
|
||||
|
||||
self.stdscr.refresh()
|
||||
|
||||
def cleanup(self):
|
||||
self.feedback.stop_sound()
|
||||
if self.speech_client is not None:
|
||||
try:
|
||||
self.speech_client.close()
|
||||
except Exception:
|
||||
pass
|
||||
if self.stdscr is not None:
|
||||
try:
|
||||
curses.nocbreak()
|
||||
self.stdscr.keypad(False)
|
||||
curses.echo()
|
||||
curses.endwin()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
self.init_speech()
|
||||
self.refresh_state()
|
||||
|
||||
self.stdscr = curses.initscr()
|
||||
curses.noecho()
|
||||
curses.cbreak()
|
||||
self.stdscr.keypad(True)
|
||||
self.stdscr.timeout(250)
|
||||
try:
|
||||
curses.curs_set(0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.speak("Stormux audio manager, press h for help.")
|
||||
|
||||
while True:
|
||||
if pending_change_expired(self.pending_change):
|
||||
self.rollback_pending_change()
|
||||
|
||||
self.draw()
|
||||
key = self.stdscr.getch()
|
||||
|
||||
if key == -1:
|
||||
continue
|
||||
if key == curses.KEY_LEFT:
|
||||
self.move_focus(-1)
|
||||
elif key == curses.KEY_RIGHT:
|
||||
self.move_focus(1)
|
||||
elif key == curses.KEY_UP:
|
||||
self.handle_up()
|
||||
elif key == curses.KEY_DOWN:
|
||||
self.handle_down()
|
||||
elif key in (10, 13, curses.KEY_ENTER):
|
||||
self.activate_selected_device()
|
||||
elif key == ord(" "):
|
||||
self.announce_current_focus()
|
||||
elif key in (ord("h"), ord("H")):
|
||||
self.speak(HELP_TEXT, interrupt=False)
|
||||
elif key in (ord("q"), ord("Q"), 27):
|
||||
self.rollback_pending_change()
|
||||
break
|
||||
finally:
|
||||
self.cleanup()
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
AudioManagerApp().run()
|
||||
except Exception as error:
|
||||
print(f"Audio manager could not start: {error}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,271 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Battery Monitor for Stormux Gaming Image
|
||||
|
||||
Monitors battery levels and provides warnings at 10% and 5%,
|
||||
with automatic shutdown at 3% to prevent data loss.
|
||||
|
||||
Only activates if a real battery is detected to avoid false alarms.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import subprocess
|
||||
import logging
|
||||
import wave
|
||||
import numpy as np
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import speechd
|
||||
import simpleaudio as sa
|
||||
except ImportError as e:
|
||||
print(f"Required module missing: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Set up logging - create log directory if it doesn't exist
|
||||
try:
|
||||
log_dir = Path.home() / '.config' / 'stormux'
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_file = log_dir / 'battery_monitor.log'
|
||||
except:
|
||||
# Fallback to /tmp if home directory not accessible
|
||||
log_file = '/tmp/battery_monitor.log'
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler(log_file),
|
||||
logging.StreamHandler()
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class BatteryMonitor:
|
||||
def __init__(self):
|
||||
# Settings
|
||||
self.enabled = True
|
||||
self.warning_10_percent = True
|
||||
self.warning_5_percent = True
|
||||
self.shutdown_3_percent = True
|
||||
self.check_interval = 30
|
||||
self.speech_enabled = True
|
||||
|
||||
# Warning state tracking
|
||||
self.warned_10 = False
|
||||
self.warned_5 = False
|
||||
|
||||
# Initialize speech client
|
||||
self.speech_client = None
|
||||
self.init_speech()
|
||||
|
||||
# Ensure config directory exists (handled in logging setup above)
|
||||
|
||||
def init_speech(self):
|
||||
"""Initialize speech-dispatcher client"""
|
||||
try:
|
||||
self.speech_client = speechd.SSIPClient("battery_monitor")
|
||||
self.speech_client.set_priority(speechd.Priority.IMPORTANT)
|
||||
logger.info("Speech client initialized")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize speech: {e}")
|
||||
self.speech_enabled = False
|
||||
|
||||
def has_battery(self):
|
||||
"""Check if system has a real battery"""
|
||||
power_supply_dir = Path('/sys/class/power_supply')
|
||||
if not power_supply_dir.exists():
|
||||
return False
|
||||
|
||||
for item in power_supply_dir.iterdir():
|
||||
type_file = item / 'type'
|
||||
if type_file.exists():
|
||||
try:
|
||||
if type_file.read_text().strip() == 'Battery':
|
||||
logger.info(f"Battery detected: {item.name}")
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
logger.info("No battery detected")
|
||||
return False
|
||||
|
||||
def get_battery_level(self):
|
||||
"""Get current battery percentage"""
|
||||
power_supply_dir = Path('/sys/class/power_supply')
|
||||
|
||||
for item in power_supply_dir.iterdir():
|
||||
type_file = item / 'type'
|
||||
capacity_file = item / 'capacity'
|
||||
|
||||
if (type_file.exists() and capacity_file.exists()):
|
||||
try:
|
||||
if type_file.read_text().strip() == 'Battery':
|
||||
capacity = int(capacity_file.read_text().strip())
|
||||
return capacity
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
def is_on_ac_power(self):
|
||||
"""Check if system is plugged into AC power"""
|
||||
power_supply_dir = Path('/sys/class/power_supply')
|
||||
|
||||
for item in power_supply_dir.iterdir():
|
||||
type_file = item / 'type'
|
||||
online_file = item / 'online'
|
||||
|
||||
if (type_file.exists() and online_file.exists()):
|
||||
try:
|
||||
device_type = type_file.read_text().strip()
|
||||
if device_type in ['ADP', 'Mains', 'AC']:
|
||||
online = int(online_file.read_text().strip())
|
||||
return online == 1
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return False
|
||||
|
||||
def speak(self, message):
|
||||
"""Speak message using speech-dispatcher"""
|
||||
if not self.speech_enabled or not self.speech_client:
|
||||
logger.warning("Speech not available")
|
||||
return
|
||||
|
||||
try:
|
||||
self.speech_client.cancel()
|
||||
self.speech_client.speak(message)
|
||||
logger.info(f"Speaking: {message}")
|
||||
except Exception as e:
|
||||
logger.error(f"Speech error: {e}")
|
||||
|
||||
def generate_urgent_sound(self):
|
||||
"""Generate urgent beep sound in memory"""
|
||||
try:
|
||||
# Generate urgent beeping sound
|
||||
sample_rate = 44100
|
||||
duration = 0.5
|
||||
frequency = 800
|
||||
|
||||
# Create beep pattern: 3 short beeps
|
||||
beeps = []
|
||||
for _ in range(3):
|
||||
t = np.linspace(0, duration, int(sample_rate * duration))
|
||||
wave_data = np.sin(2 * np.pi * frequency * t)
|
||||
# Add fade in/out
|
||||
fade_samples = int(0.05 * sample_rate)
|
||||
wave_data[:fade_samples] *= np.linspace(0, 1, fade_samples)
|
||||
wave_data[-fade_samples:] *= np.linspace(1, 0, fade_samples)
|
||||
beeps.extend(wave_data)
|
||||
# Add silence between beeps
|
||||
beeps.extend([0] * int(0.2 * sample_rate))
|
||||
|
||||
# Convert to 16-bit integers
|
||||
audio_data = np.array(beeps) * 32767
|
||||
audio_data = audio_data.astype(np.int16)
|
||||
|
||||
return audio_data, sample_rate
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Sound generation error: {e}")
|
||||
return None, None
|
||||
|
||||
def play_urgent_sound(self):
|
||||
"""Play urgent warning sound"""
|
||||
try:
|
||||
audio_data, sample_rate = self.generate_urgent_sound()
|
||||
if audio_data is not None:
|
||||
play_obj = sa.play_buffer(audio_data, 1, 2, sample_rate)
|
||||
play_obj.wait_done()
|
||||
logger.info("Urgent sound played")
|
||||
except Exception as e:
|
||||
logger.error(f"Sound playback error: {e}")
|
||||
|
||||
def handle_low_battery(self, level):
|
||||
"""Handle low battery warnings and actions"""
|
||||
# Don't give warnings if we're plugged into AC power
|
||||
if self.is_on_ac_power():
|
||||
return
|
||||
|
||||
if level <= 3:
|
||||
if self.shutdown_3_percent:
|
||||
logger.critical(f"Battery at {level}% - initiating shutdown")
|
||||
self.speak("Critical battery level. System shutting down now.")
|
||||
time.sleep(3) # Give speech time to complete
|
||||
subprocess.run(['sudo', 'systemctl', 'poweroff'], check=True)
|
||||
return
|
||||
|
||||
elif level <= 5 and not self.warned_5:
|
||||
if self.warning_5_percent:
|
||||
logger.warning(f"Battery at {level}% - urgent warning")
|
||||
self.play_urgent_sound()
|
||||
self.speak("Extremely low battery. Computer will shut down soon.")
|
||||
self.warned_5 = True
|
||||
|
||||
elif level <= 10 and not self.warned_10:
|
||||
if self.warning_10_percent:
|
||||
logger.warning(f"Battery at {level}% - first warning")
|
||||
self.speak("Low battery warning. Please connect power adapter.")
|
||||
self.warned_10 = True
|
||||
|
||||
def reset_warnings_if_charging(self):
|
||||
"""Reset warning flags if system is charging"""
|
||||
if self.is_on_ac_power():
|
||||
if self.warned_10 or self.warned_5:
|
||||
logger.info("AC power connected - resetting warning flags")
|
||||
self.warned_10 = False
|
||||
self.warned_5 = False
|
||||
|
||||
def monitor(self):
|
||||
"""Main monitoring loop"""
|
||||
if not self.enabled:
|
||||
logger.info("Battery monitoring disabled")
|
||||
return
|
||||
|
||||
if not self.has_battery():
|
||||
logger.info("No battery detected - monitoring disabled")
|
||||
return
|
||||
|
||||
logger.info("Starting battery monitoring")
|
||||
|
||||
while True:
|
||||
try:
|
||||
level = self.get_battery_level()
|
||||
if level is not None:
|
||||
logger.debug(f"Battery level: {level}%")
|
||||
self.reset_warnings_if_charging()
|
||||
self.handle_low_battery(level)
|
||||
else:
|
||||
logger.warning("Could not read battery level")
|
||||
|
||||
time.sleep(self.check_interval)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Battery monitoring stopped by user")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Monitoring error: {e}")
|
||||
time.sleep(self.check_interval)
|
||||
|
||||
def main():
|
||||
try:
|
||||
monitor = BatteryMonitor()
|
||||
monitor.monitor()
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Battery monitor stopped")
|
||||
except Exception as e:
|
||||
logger.error(f"Fatal error: {e}")
|
||||
sys.exit(1)
|
||||
finally:
|
||||
# Clean up speech client
|
||||
try:
|
||||
if 'monitor' in locals() and monitor.speech_client:
|
||||
monitor.speech_client.close()
|
||||
except:
|
||||
pass
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,89 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
die() {
|
||||
echo "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'EOF' >&2
|
||||
Usage: convert-to-video.sh /path/to/audio.file
|
||||
Creates a slideshow video from stormux images and the provided audio.
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
|
||||
require_commands() {
|
||||
for cmd in "$@"; do
|
||||
command -v "$cmd" >/dev/null 2>&1 || die "Missing dependency: $cmd"
|
||||
done
|
||||
}
|
||||
|
||||
locate_images() {
|
||||
local candidate="."
|
||||
local have_all=true
|
||||
|
||||
for i in 1 2 3 4 5; do
|
||||
[ -f "$candidate/stormux${i}.png" ] || have_all=false
|
||||
done
|
||||
|
||||
if [ "$have_all" = true ]; then
|
||||
printf '%s\n' "$candidate"
|
||||
return
|
||||
fi
|
||||
|
||||
candidate="/usr/share/stormux/slideshow"
|
||||
for i in 1 2 3 4 5; do
|
||||
[ -f "$candidate/stormux${i}.png" ] || die "Missing image: $candidate/stormux${i}.png"
|
||||
done
|
||||
|
||||
printf '%s\n' "$candidate"
|
||||
}
|
||||
|
||||
detect_container() {
|
||||
local audio_file="$1"
|
||||
local codec
|
||||
|
||||
codec="$(ffprobe -v error -select_streams a:0 -show_entries stream=codec_name -of csv=p=0 "$audio_file" || true)"
|
||||
case "$codec" in
|
||||
aac|mp3) echo "mp4" ;;
|
||||
*) echo "mkv" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
build_video() {
|
||||
local audio_file="$1"
|
||||
local image_dir="$2"
|
||||
local container="$3"
|
||||
local output_filename="$4"
|
||||
local output_dir="$HOME/Videos"
|
||||
|
||||
mkdir -p "$output_dir"
|
||||
|
||||
ffmpeg -y \
|
||||
-stream_loop -1 -framerate 1/35 -start_number 1 -i "$image_dir/stormux%d.png" \
|
||||
-i "$audio_file" \
|
||||
-map 0:v:0 -map 1:a:0 \
|
||||
-c:v libx264 -crf 18 -preset veryfast -r 30 -pix_fmt yuv420p \
|
||||
-c:a copy \
|
||||
-shortest \
|
||||
"$output_dir/${output_filename##*/}"
|
||||
}
|
||||
|
||||
[ "$#" -eq 1 ] || usage
|
||||
|
||||
audio="$1"
|
||||
[ -f "$audio" ] || die "Audio file not found: $audio"
|
||||
|
||||
require_commands ffmpeg ffprobe
|
||||
|
||||
image_dir="$(locate_images)"
|
||||
container="$(detect_container "$audio")"
|
||||
base="$(basename "$audio")"
|
||||
name="${base%.*}"
|
||||
output="${name}.${container}"
|
||||
|
||||
build_video "$audio" "$image_dir" "$container" "$output"
|
||||
|
||||
echo "Wrote $HOME/Videos/$output"
|
||||
@@ -1,35 +0,0 @@
|
||||
#!/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 system and move the USB drive to a USB 3 port for better performance."
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
# set performance (if scaling governor exists)
|
||||
if [[ -d /sys/devices/system/cpu/cpu0/cpufreq ]]; then
|
||||
echo performance | sudo tee /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# 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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,556 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Stormux Gaming Image - Ultra-Reliable Disk Installer
|
||||
# Clones USB system to internal disk with proper bootloader installation
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Initialize logging
|
||||
LOGDIR="/home/stormux/Logs"
|
||||
LOGFILE="$LOGDIR/install_to_disk.log"
|
||||
mkdir -p "$LOGDIR"
|
||||
|
||||
# Logging function
|
||||
log() {
|
||||
echo "$*" | tee -a "$LOGFILE"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo "[ERROR] $*" | tee -a "$LOGFILE" >&2
|
||||
}
|
||||
|
||||
cleanup_mounts() {
|
||||
# Clean up any leftover mounts
|
||||
local mount_point="${1:-/mnt/stormux_target}"
|
||||
if [[ -d "$mount_point" ]]; then
|
||||
sudo umount "$mount_point/boot" 2>/dev/null || true
|
||||
sudo umount "$mount_point/proc" 2>/dev/null || true
|
||||
sudo umount "$mount_point/sys" 2>/dev/null || true
|
||||
sudo umount "$mount_point/dev" 2>/dev/null || true
|
||||
sudo umount "$mount_point" 2>/dev/null || true
|
||||
sudo rmdir "$mount_point" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
error_exit() {
|
||||
log_error "$1"
|
||||
echo "Installation failed. Log file: $LOGFILE"
|
||||
cleanup_mounts
|
||||
restore_speech
|
||||
echo
|
||||
read -rp "Press enter to continue..."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Speech management
|
||||
disable_speech() {
|
||||
echo "command tempdisablespeech" | socat - UNIX-CLIENT:/tmp/fenrirscreenreader-deamon.sock 2>/dev/null || true
|
||||
}
|
||||
|
||||
restore_speech() {
|
||||
echo "command toggletempdisablespeech" | socat - UNIX-CLIENT:/tmp/fenrirscreenreader-deamon.sock 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Function to find the source USB device
|
||||
find_source_device() {
|
||||
local root_device
|
||||
root_device=$(findmnt -n -o SOURCE /)
|
||||
|
||||
if [[ "$root_device" =~ ^/dev/(sd[a-z]|nvme[0-9]n[0-9]|mmcblk[0-9]) ]]; then
|
||||
# Extract just the device name (remove partition number)
|
||||
echo "$root_device" | sed 's/[0-9]*$//' | sed 's/p$//'
|
||||
else
|
||||
# Fallback: look for devices with STORMUX label
|
||||
local labeled_device
|
||||
labeled_device=$(lsblk -no NAME,LABEL | grep -i stormux | head -1 | awk '{print "/dev/" $1}' | sed 's/[0-9]*$//' | sed 's/p$//')
|
||||
if [[ -n "$labeled_device" ]]; then
|
||||
echo "$labeled_device"
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to detect target disks (excluding source)
|
||||
detect_target_disks() {
|
||||
local source_device="$1"
|
||||
local disks=()
|
||||
|
||||
while IFS= read -r disk; do
|
||||
# Skip partitions, loop devices, CD-ROMs, and source device
|
||||
if [[ ! "$disk" =~ (sd[a-z][0-9]+|nvme[0-9]+n[0-9]+p[0-9]+|mmcblk[0-9]+p[0-9]+)$ ]] && \
|
||||
[[ ! "$disk" =~ ^/dev/loop ]] && \
|
||||
[[ ! "$disk" =~ ^/dev/sr ]] && \
|
||||
[[ "$disk" != "$source_device" ]]; then
|
||||
if [[ -b "$disk" ]]; then
|
||||
disks+=("$disk")
|
||||
fi
|
||||
fi
|
||||
done < <(lsblk -dpno NAME 2>/dev/null)
|
||||
|
||||
printf '%s\n' "${disks[@]}"
|
||||
}
|
||||
|
||||
# Function to get disk info
|
||||
get_disk_info() {
|
||||
local disk="$1"
|
||||
local size
|
||||
local model
|
||||
size=$(lsblk -dpno SIZE "$disk" 2>/dev/null | tr -d ' ')
|
||||
model=$(lsblk -dpno MODEL "$disk" 2>/dev/null | tr -d ' ' || echo "Unknown")
|
||||
echo "$size - $model"
|
||||
}
|
||||
|
||||
# Function to detect partitions by filesystem type
|
||||
# shellcheck disable=SC2154
|
||||
detect_partitions() {
|
||||
local device="$1"
|
||||
# shellcheck disable=SC2178
|
||||
local -n result=$2 # nameref to associative array
|
||||
|
||||
log "Detecting partition structure on $device..."
|
||||
|
||||
# Get all partitions
|
||||
while IFS= read -r line; do
|
||||
local part fstype label
|
||||
part=$(echo "$line" | awk '{print $1}')
|
||||
# Remove lsblk tree characters that break mount commands
|
||||
part="${part//[├─└│]/}"
|
||||
# Add /dev/ prefix if not present
|
||||
[[ "$part" != /dev/* ]] && part="/dev/$part"
|
||||
fstype=$(echo "$line" | awk '{print $2}')
|
||||
label=$(echo "$line" | awk '{print $3}')
|
||||
|
||||
# Skip if it's the device itself, not a partition
|
||||
[[ "$part" == "$device" ]] && continue
|
||||
|
||||
case "$fstype" in
|
||||
"")
|
||||
# BIOS boot partition (no filesystem)
|
||||
result[bios]="$part"
|
||||
log " BIOS boot partition: $part"
|
||||
;;
|
||||
"vfat")
|
||||
# EFI partition
|
||||
result[efi]="$part"
|
||||
log " EFI partition: $part (label: ${label:-none})"
|
||||
;;
|
||||
"ext4")
|
||||
# Root partition
|
||||
result[root]="$part"
|
||||
log " Root partition: $part (label: ${label:-none})"
|
||||
;;
|
||||
*)
|
||||
log " Unknown partition type: $part ($fstype)"
|
||||
;;
|
||||
esac
|
||||
done < <(lsblk -no NAME,FSTYPE,LABEL "$device" 2>/dev/null | tail -n +2)
|
||||
|
||||
# Validate we found all required partitions
|
||||
if [[ -z "${result[root]:-}" ]]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Function to regenerate UUIDs and update labels
|
||||
regenerate_partition_identifiers() {
|
||||
local device="$1"
|
||||
local -n partitions=$2
|
||||
declare -A uuid_map
|
||||
|
||||
log "Regenerating partition UUIDs and labels..."
|
||||
|
||||
# Process EFI partition
|
||||
if [[ -n "${partitions[efi]:-}" ]]; then
|
||||
local efi_part="${partitions[efi]}"
|
||||
local old_uuid
|
||||
old_uuid=$(lsblk -no UUID "$efi_part" 2>/dev/null || echo "")
|
||||
|
||||
# Change label
|
||||
if command -v fatlabel >/dev/null 2>&1; then
|
||||
sudo fatlabel "$efi_part" "BOOT-HDD" 2>/dev/null || log "Warning: Could not rename EFI partition"
|
||||
fi
|
||||
|
||||
# Generate new UUID for FAT
|
||||
if command -v mlabel >/dev/null 2>&1; then
|
||||
sudo mlabel -s -i "$efi_part" :: 2>/dev/null || log "Warning: Could not change FAT serial"
|
||||
fi
|
||||
|
||||
local new_uuid
|
||||
new_uuid=$(lsblk -no UUID "$efi_part" 2>/dev/null || echo "")
|
||||
|
||||
if [[ -n "$old_uuid" && -n "$new_uuid" ]]; then
|
||||
uuid_map["$old_uuid"]="$new_uuid"
|
||||
log " EFI UUID: $old_uuid -> $new_uuid"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Process root partition
|
||||
if [[ -n "${partitions[root]:-}" ]]; then
|
||||
local root_part="${partitions[root]}"
|
||||
local old_uuid
|
||||
old_uuid=$(lsblk -no UUID "$root_part" 2>/dev/null || echo "")
|
||||
|
||||
# Change label and UUID
|
||||
if command -v tune2fs >/dev/null 2>&1; then
|
||||
sudo tune2fs -L "STORMUX-HDD" "$root_part" 2>/dev/null || log "Warning: Could not rename root partition"
|
||||
sudo tune2fs -U random "$root_part" 2>/dev/null || log "Warning: Could not change root UUID"
|
||||
fi
|
||||
|
||||
local new_uuid
|
||||
new_uuid=$(lsblk -no UUID "$root_part" 2>/dev/null || echo "")
|
||||
|
||||
if [[ -n "$old_uuid" && -n "$new_uuid" ]]; then
|
||||
uuid_map["$old_uuid"]="$new_uuid"
|
||||
log " Root UUID: $old_uuid -> $new_uuid"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Return UUID mappings
|
||||
for old in "${!uuid_map[@]}"; do
|
||||
echo "UUID_MAP:$old:${uuid_map[$old]}"
|
||||
done
|
||||
}
|
||||
|
||||
# Function to update fstab with new UUIDs
|
||||
update_fstab() {
|
||||
local mount_point="$1"
|
||||
shift
|
||||
local mappings=("$@")
|
||||
|
||||
log "Updating fstab with new UUIDs..."
|
||||
|
||||
local fstab="$mount_point/etc/fstab"
|
||||
|
||||
if [[ ! -f "$fstab" ]]; then
|
||||
log_error "fstab not found at $fstab"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Backup original
|
||||
sudo cp "$fstab" "$fstab.backup" || log "Warning: Could not backup fstab"
|
||||
|
||||
# Apply UUID mappings
|
||||
local temp_fstab
|
||||
temp_fstab=$(mktemp)
|
||||
sudo cp "$fstab" "$temp_fstab"
|
||||
|
||||
for mapping in "${mappings[@]}"; do
|
||||
if [[ "$mapping" =~ ^UUID_MAP:([^:]+):([^:]+)$ ]]; then
|
||||
local old_uuid="${BASH_REMATCH[1]}"
|
||||
local new_uuid="${BASH_REMATCH[2]}"
|
||||
|
||||
sed -i "s/UUID=$old_uuid/UUID=$new_uuid/g" "$temp_fstab"
|
||||
log " Updated fstab: $old_uuid -> $new_uuid"
|
||||
fi
|
||||
done
|
||||
|
||||
sudo cp "$temp_fstab" "$fstab"
|
||||
rm -f "$temp_fstab"
|
||||
|
||||
log "fstab updated successfully"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Function to install GRUB in chroot
|
||||
install_grub() {
|
||||
local mount_point="$1"
|
||||
local target_device="$2"
|
||||
local -n parts=$3
|
||||
|
||||
log "Installing GRUB bootloader to $target_device..."
|
||||
|
||||
# Ensure EFI partition is mounted inside chroot
|
||||
if [[ -n "${parts[efi]:-}" ]]; then
|
||||
sudo mkdir -p "$mount_point/boot"
|
||||
if ! mountpoint -q "$mount_point/boot"; then
|
||||
sudo mount "${parts[efi]}" "$mount_point/boot" || error_exit "Failed to mount EFI partition"
|
||||
log " Mounted EFI partition at /boot"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Bind mount necessary filesystems for chroot
|
||||
sudo mount --bind /dev "$mount_point/dev" || error_exit "Failed to bind mount /dev"
|
||||
sudo mount --bind /sys "$mount_point/sys" || error_exit "Failed to bind mount /sys"
|
||||
sudo mount --bind /proc "$mount_point/proc" || error_exit "Failed to bind mount /proc"
|
||||
log " Prepared chroot environment"
|
||||
|
||||
# Install GRUB for BIOS (allow to fail gracefully)
|
||||
log " Installing GRUB for BIOS boot (warnings expected on UEFI systems)..."
|
||||
if sudo arch-chroot "$mount_point" grub-install --target=i386-pc --recheck "$target_device" 2>&1 | tee -a "$LOGFILE"; then
|
||||
log " BIOS boot installation succeeded"
|
||||
else
|
||||
log " BIOS boot installation completed with warnings (expected on UEFI systems)"
|
||||
fi
|
||||
|
||||
# Install GRUB for UEFI (must succeed)
|
||||
# Use --removable flag to install to default EFI fallback location
|
||||
log " Installing GRUB for UEFI boot..."
|
||||
if ! sudo arch-chroot "$mount_point" grub-install --target=x86_64-efi --efi-directory=/boot --bootloader-id=STORMUX-HDD --recheck --removable 2>&1 | tee -a "$LOGFILE"; then
|
||||
error_exit "UEFI GRUB installation failed"
|
||||
fi
|
||||
log " UEFI boot installation succeeded"
|
||||
|
||||
# Verify /etc/default/grub exists before generating config
|
||||
if [[ ! -f "$mount_point/etc/default/grub" ]]; then
|
||||
log_error "/etc/default/grub not found on target system"
|
||||
error_exit "Cannot generate GRUB config without /etc/default/grub"
|
||||
fi
|
||||
|
||||
# Generate GRUB configuration
|
||||
log " Generating GRUB configuration..."
|
||||
if ! sudo arch-chroot "$mount_point" grub-mkconfig -o /boot/grub/grub.cfg 2>&1 | tee -a "$LOGFILE"; then
|
||||
error_exit "GRUB configuration generation failed"
|
||||
fi
|
||||
log " GRUB configuration generated successfully"
|
||||
|
||||
# Unmount bind mounts
|
||||
sudo umount "$mount_point/proc" 2>/dev/null || true
|
||||
sudo umount "$mount_point/sys" 2>/dev/null || true
|
||||
sudo umount "$mount_point/dev" 2>/dev/null || true
|
||||
|
||||
log "GRUB installation completed successfully"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Function to validate installation
|
||||
validate_installation() {
|
||||
local mount_point="$1"
|
||||
|
||||
log "Validating installation..."
|
||||
|
||||
# Check fstab exists and has valid entries
|
||||
if [[ ! -f "$mount_point/etc/fstab" ]]; then
|
||||
log_error "fstab not found"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check GRUB config exists
|
||||
if [[ ! -f "$mount_point/boot/grub/grub.cfg" ]]; then
|
||||
log_error "GRUB configuration not found"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check for baremetal marker
|
||||
if [[ ! -f "$mount_point/home/stormux/.baremetal" ]]; then
|
||||
log_error "Baremetal marker not found"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log "Validation passed"
|
||||
return 0
|
||||
}
|
||||
|
||||
###################
|
||||
# Main Installation
|
||||
###################
|
||||
|
||||
clear
|
||||
log "========================================"
|
||||
log "Stormux Gaming Image - Disk Installer"
|
||||
log "========================================"
|
||||
log "Log file: $LOGFILE"
|
||||
echo
|
||||
echo "This will clone the USB system to an internal disk."
|
||||
echo
|
||||
|
||||
# Find source device
|
||||
log "Detecting source USB device..."
|
||||
SOURCE_DEVICE=$(find_source_device)
|
||||
if [[ -z "$SOURCE_DEVICE" ]]; then
|
||||
error_exit "Could not detect source USB device"
|
||||
fi
|
||||
|
||||
SOURCE_SIZE=$(lsblk -dpno SIZE "$SOURCE_DEVICE" 2>/dev/null | tr -d ' ')
|
||||
log "Source device: $SOURCE_DEVICE ($SOURCE_SIZE)"
|
||||
echo "Source device: $SOURCE_DEVICE ($SOURCE_SIZE)"
|
||||
echo
|
||||
|
||||
# Detect target disks
|
||||
log "Detecting target disks..."
|
||||
mapfile -t target_disks < <(detect_target_disks "$SOURCE_DEVICE")
|
||||
|
||||
if [[ ${#target_disks[@]} -eq 0 ]]; then
|
||||
error_exit "No suitable target disks found"
|
||||
fi
|
||||
|
||||
# Display target disks
|
||||
echo "Available target disks:"
|
||||
log "Available target disks:"
|
||||
for i in "${!target_disks[@]}"; do
|
||||
disk="${target_disks[$i]}"
|
||||
info=$(get_disk_info "$disk")
|
||||
echo "$((i+1)). $disk - $info"
|
||||
log " $((i+1)). $disk - $info"
|
||||
done
|
||||
|
||||
# Get disk selection
|
||||
while true; do
|
||||
echo
|
||||
echo "Enter the number of the disk to install to:"
|
||||
read -r selection
|
||||
|
||||
if [[ "$selection" =~ ^[0-9]+$ ]] && [[ "$selection" -ge 1 ]] && [[ "$selection" -le ${#target_disks[@]} ]]; then
|
||||
TARGET_DEVICE="${target_disks[$((selection-1))]}"
|
||||
break
|
||||
else
|
||||
echo "Invalid selection. Please enter a number between 1 and ${#target_disks[@]}."
|
||||
fi
|
||||
done
|
||||
|
||||
log "Selected target device: $TARGET_DEVICE"
|
||||
|
||||
# Safety check: ensure target != source
|
||||
if [[ "$TARGET_DEVICE" == "$SOURCE_DEVICE" ]]; then
|
||||
error_exit "Target device cannot be the same as source device"
|
||||
fi
|
||||
|
||||
# Check target disk size
|
||||
TARGET_SIZE_BYTES=$(lsblk -dpno SIZE -b "$TARGET_DEVICE" 2>/dev/null)
|
||||
SOURCE_SIZE_BYTES=$(lsblk -dpno SIZE -b "$SOURCE_DEVICE" 2>/dev/null)
|
||||
|
||||
if [[ "$TARGET_SIZE_BYTES" -lt "$SOURCE_SIZE_BYTES" ]]; then
|
||||
error_exit "Target disk is smaller than source USB"
|
||||
fi
|
||||
|
||||
# Final confirmation
|
||||
target_info=$(get_disk_info "$TARGET_DEVICE")
|
||||
echo
|
||||
echo "FINAL WARNING:"
|
||||
echo "Source: $SOURCE_DEVICE ($SOURCE_SIZE)"
|
||||
echo "Target: $TARGET_DEVICE ($target_info)"
|
||||
echo "ALL DATA ON THE TARGET DISK WILL BE PERMANENTLY DESTROYED!"
|
||||
echo
|
||||
log "Final confirmation - Target: $TARGET_DEVICE"
|
||||
echo "Type 'yes' to continue or any other key to cancel:"
|
||||
read -r CONFIRM
|
||||
|
||||
if [[ "$CONFIRM" != "yes" ]]; then
|
||||
log "Installation cancelled by user"
|
||||
echo "Installation cancelled."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
log "User confirmed installation"
|
||||
|
||||
# Disable speech during installation
|
||||
echo
|
||||
echo "Fenrir will be silent during installation except for progress beeps."
|
||||
echo "Press Enter to begin..."
|
||||
read -r
|
||||
disable_speech
|
||||
|
||||
# Unmount any mounted partitions on target disk
|
||||
log "Unmounting target disk partitions..."
|
||||
sudo umount "${TARGET_DEVICE}"* 2>/dev/null || true
|
||||
|
||||
# Clone the USB to target disk
|
||||
log "Starting disk clone operation..."
|
||||
echo "Cloning USB system to target disk..."
|
||||
echo "This will take several minutes depending on USB size and disk speed."
|
||||
echo
|
||||
|
||||
if ! sudo dd if="$SOURCE_DEVICE" of="$TARGET_DEVICE" bs=4M oflag=sync status=progress 2>&1 | tee -a "$LOGFILE"; then
|
||||
error_exit "Failed to clone USB to target disk"
|
||||
fi
|
||||
|
||||
log "Disk clone completed successfully"
|
||||
|
||||
# Sync and refresh partition table
|
||||
log "Syncing data to disk..."
|
||||
sudo sync
|
||||
|
||||
log "Refreshing partition table..."
|
||||
# Use -s flag for script mode, pipe 'Fix' response for GPT expansion
|
||||
echo "Fix" | sudo partprobe -s "$TARGET_DEVICE" 2>&1 | tee -a "$LOGFILE" || log "Warning: partprobe reported issues"
|
||||
sudo udevadm settle --timeout=10 || log "Warning: udevadm settle timeout"
|
||||
sleep 2
|
||||
|
||||
# Detect partition structure
|
||||
log "Analyzing cloned partition structure..."
|
||||
declare -A target_partitions
|
||||
if ! detect_partitions "$TARGET_DEVICE" target_partitions; then
|
||||
error_exit "Failed to detect partition structure on target disk"
|
||||
fi
|
||||
|
||||
# Validate partition structure
|
||||
if [[ -z "${target_partitions[root]:-}" ]]; then
|
||||
error_exit "Could not find root partition on target disk"
|
||||
fi
|
||||
|
||||
log "Partition detection complete:"
|
||||
log " BIOS: ${target_partitions[bios]:-none}"
|
||||
log " EFI: ${target_partitions[efi]:-none}"
|
||||
log " Root: ${target_partitions[root]}"
|
||||
|
||||
# Regenerate UUIDs and labels BEFORE mounting
|
||||
log "Regenerating partition identifiers..."
|
||||
mapfile -t uuid_mappings < <(regenerate_partition_identifiers "$TARGET_DEVICE" target_partitions)
|
||||
log "Generated ${#uuid_mappings[@]} UUID mappings"
|
||||
|
||||
# Trigger udev to update with new UUIDs
|
||||
log "Updating system device database..."
|
||||
sudo udevadm trigger --subsystem-match=block
|
||||
sudo udevadm settle --timeout=10 || log "Warning: udevadm settle timeout"
|
||||
sleep 1
|
||||
|
||||
# Mount root partition
|
||||
TEMP_MOUNT="/mnt/stormux_target"
|
||||
sudo mkdir -p "$TEMP_MOUNT"
|
||||
|
||||
log "Mounting root partition ${target_partitions[root]}..."
|
||||
if ! sudo mount "${target_partitions[root]}" "$TEMP_MOUNT" 2>&1 | tee -a "$LOGFILE"; then
|
||||
error_exit "Failed to mount root partition"
|
||||
fi
|
||||
|
||||
log "Root partition mounted successfully"
|
||||
|
||||
# Update fstab with new UUIDs
|
||||
if ! update_fstab "$TEMP_MOUNT" "${uuid_mappings[@]}"; then
|
||||
error_exit "Failed to update fstab"
|
||||
fi
|
||||
|
||||
# Create baremetal marker
|
||||
log "Creating baremetal system marker..."
|
||||
sudo touch "$TEMP_MOUNT/home/stormux/.baremetal"
|
||||
sudo chown 1000:1000 "$TEMP_MOUNT/home/stormux/.baremetal" 2>/dev/null || true
|
||||
sudo chattr +i "$TEMP_MOUNT/home/stormux/.baremetal" 2>/dev/null || log "Warning: Could not set immutable attribute"
|
||||
|
||||
# Remove USB-specific markers
|
||||
sudo rm -f "$TEMP_MOUNT/home/stormux/.firstboot" 2>/dev/null || true
|
||||
|
||||
# Install GRUB
|
||||
if ! install_grub "$TEMP_MOUNT" "$TARGET_DEVICE" target_partitions; then
|
||||
error_exit "GRUB installation failed"
|
||||
fi
|
||||
|
||||
# Validate installation
|
||||
if ! validate_installation "$TEMP_MOUNT"; then
|
||||
error_exit "Installation validation failed"
|
||||
fi
|
||||
|
||||
# Clean up all mounts
|
||||
log "Unmounting filesystems..."
|
||||
cleanup_mounts "$TEMP_MOUNT"
|
||||
|
||||
# Restore speech
|
||||
restore_speech
|
||||
|
||||
# Success message
|
||||
log "========================================="
|
||||
log "Installation completed successfully!"
|
||||
log "========================================="
|
||||
echo
|
||||
echo "========================================="
|
||||
echo "Installation completed successfully!"
|
||||
echo "The USB system has been cloned to $TARGET_DEVICE"
|
||||
echo "You can now reboot and remove the USB drive."
|
||||
echo "The system will boot from the internal disk."
|
||||
echo
|
||||
echo "Log file: $LOGFILE"
|
||||
echo "========================================="
|
||||
echo
|
||||
echo "Press enter to continue..."
|
||||
read -r
|
||||
@@ -1,185 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# IP Address Information Tool for Stormux
|
||||
# Provides local and remote IP addresses with speech-friendly formatting
|
||||
|
||||
import sys
|
||||
import subprocess
|
||||
import re
|
||||
import socket
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import speechd
|
||||
|
||||
def init_speech():
|
||||
"""Initialize speech client"""
|
||||
try:
|
||||
client = speechd.SSIPClient('ip_info')
|
||||
client.set_priority(speechd.Priority.IMPORTANT)
|
||||
client.set_punctuation(speechd.PunctuationMode.SOME)
|
||||
return client
|
||||
except Exception as e:
|
||||
print(f"Could not initialize speech: {e}")
|
||||
return None
|
||||
|
||||
def speak(client, text):
|
||||
"""Speak text using speech client"""
|
||||
if client:
|
||||
try:
|
||||
client.speak(text)
|
||||
except Exception:
|
||||
print(text)
|
||||
else:
|
||||
print(text)
|
||||
|
||||
def format_ip_for_speech(ip_address):
|
||||
"""Format IP address for clear speech synthesis"""
|
||||
if not ip_address:
|
||||
return "No IP address found"
|
||||
|
||||
# Split IP into parts and replace dots with "dot"
|
||||
parts = ip_address.split('.')
|
||||
if len(parts) != 4:
|
||||
return ip_address # Return as-is if not a standard IPv4
|
||||
|
||||
# Join with " dot " and add spaces between digits for clarity
|
||||
formatted_parts = []
|
||||
for part in parts:
|
||||
# Add spaces between digits for better speech clarity
|
||||
spaced_digits = ' '.join(part)
|
||||
formatted_parts.append(spaced_digits)
|
||||
|
||||
return ' dot '.join(formatted_parts)
|
||||
|
||||
def get_local_ip():
|
||||
"""Get the local IP address using multiple methods"""
|
||||
|
||||
# Method 1: Try to connect to a remote host to determine local IP
|
||||
try:
|
||||
# Create a socket and connect to a remote address
|
||||
# This doesn't actually send data, just determines routing
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.connect(("8.8.8.8", 80))
|
||||
local_ip = sock.getsockname()[0]
|
||||
sock.close()
|
||||
|
||||
# Validate it's not a loopback address
|
||||
if not local_ip.startswith('127.'):
|
||||
return local_ip
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Method 2: Parse ip route command output
|
||||
try:
|
||||
result = subprocess.run(['ip', 'route', 'get', '8.8.8.8'],
|
||||
capture_output=True, text=True, check=True)
|
||||
|
||||
# Look for "src" in the output
|
||||
match = re.search(r'src\s+(\d+\.\d+\.\d+\.\d+)', result.stdout)
|
||||
if match:
|
||||
return match.group(1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Method 3: Parse ip addr show output for common network interfaces
|
||||
try:
|
||||
result = subprocess.run(['ip', 'addr', 'show'],
|
||||
capture_output=True, text=True, check=True)
|
||||
|
||||
# Look for inet addresses that are not loopback
|
||||
# Common patterns: 192.168.x.x, 10.x.x.x, 172.16-31.x.x
|
||||
pattern = r'inet\s+(\d+\.\d+\.\d+\.\d+)/\d+'
|
||||
matches = re.findall(pattern, result.stdout)
|
||||
|
||||
for ip in matches:
|
||||
# Skip loopback
|
||||
if ip.startswith('127.'):
|
||||
continue
|
||||
# Prefer common private network ranges
|
||||
if (ip.startswith('192.168.') or
|
||||
ip.startswith('10.') or
|
||||
re.match(r'^172\.(1[6-9]|2[0-9]|3[01])\.', ip)):
|
||||
return ip
|
||||
|
||||
# If no private IPs found, return the first non-loopback
|
||||
for ip in matches:
|
||||
if not ip.startswith('127.'):
|
||||
return ip
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Method 4: Fallback using hostname
|
||||
try:
|
||||
hostname = socket.gethostname()
|
||||
return socket.gethostbyname(hostname)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def get_remote_ip():
|
||||
"""Get the remote/public IP address"""
|
||||
services = [
|
||||
'https://icanhazip.com',
|
||||
'https://ipecho.net/plain',
|
||||
'https://ifconfig.me/ip',
|
||||
'https://api.ipify.org'
|
||||
]
|
||||
|
||||
for service in services:
|
||||
try:
|
||||
with urllib.request.urlopen(service, timeout=10) as response:
|
||||
ip = response.read().decode('utf-8').strip()
|
||||
# Validate it looks like an IP address
|
||||
if re.match(r'^\d+\.\d+\.\d+\.\d+$', ip):
|
||||
return ip
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 2 or sys.argv[1] not in ['local', 'remote']:
|
||||
print("Usage: ip_info.py [local|remote]")
|
||||
sys.exit(1)
|
||||
|
||||
mode = sys.argv[1]
|
||||
speech_client = init_speech()
|
||||
|
||||
if mode == 'local':
|
||||
print("Getting local IP address...")
|
||||
ip = get_local_ip()
|
||||
if ip:
|
||||
formatted_ip = format_ip_for_speech(ip)
|
||||
message = f"Local IP address: {formatted_ip}"
|
||||
speak(speech_client, message)
|
||||
print(f"Local IP: {ip}")
|
||||
else:
|
||||
message = "Could not determine local IP address"
|
||||
speak(speech_client, message)
|
||||
print(message)
|
||||
|
||||
elif mode == 'remote':
|
||||
print("Getting remote IP address...")
|
||||
ip = get_remote_ip()
|
||||
if ip:
|
||||
formatted_ip = format_ip_for_speech(ip)
|
||||
message = f"Remote IP address: {formatted_ip}"
|
||||
speak(speech_client, message)
|
||||
print(f"Remote IP: {ip}")
|
||||
else:
|
||||
message = "Could not determine remote IP address. Check internet connection."
|
||||
speak(speech_client, message)
|
||||
print(message)
|
||||
|
||||
# Clean up speech client
|
||||
if speech_client:
|
||||
try:
|
||||
speech_client.close()
|
||||
except:
|
||||
pass
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,436 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# Keymode - Standalone modal keyboard tool for window managers
|
||||
# Works on both X11 and Wayland
|
||||
# Written by Storm Dragon https://stormux.org
|
||||
#
|
||||
# Copyright (c) 2025 Storm Dragon
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to deal
|
||||
# in the Software without restriction, including without limitation the rights
|
||||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
# copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included in all
|
||||
# copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
# SOFTWARE.
|
||||
|
||||
|
||||
import gi
|
||||
gi.require_version('Gtk', '3.0')
|
||||
gi.require_version('Gdk', '3.0')
|
||||
from gi.repository import Gtk, Gdk, GLib
|
||||
|
||||
# Try to import GTK Layer Shell for Wayland compositor support
|
||||
try:
|
||||
gi.require_version('GtkLayerShell', '0.1')
|
||||
from gi.repository import GtkLayerShell
|
||||
HAS_LAYER_SHELL = True
|
||||
except (ValueError, ImportError):
|
||||
HAS_LAYER_SHELL = False
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
import argparse
|
||||
|
||||
# TOML loading - try stdlib tomllib (Python 3.11+), fallback to toml package
|
||||
try:
|
||||
import tomllib
|
||||
def load_toml(path):
|
||||
with open(path, 'rb') as f:
|
||||
return tomllib.load(f)
|
||||
except ImportError:
|
||||
try:
|
||||
import toml
|
||||
def load_toml(path):
|
||||
return toml.load(path)
|
||||
except ImportError:
|
||||
print("Error: No TOML parser available.", file=sys.stderr)
|
||||
print("Install: pip install toml OR upgrade to Python 3.11+", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def get_config_path():
|
||||
"""Get the path to the config file using XDG_CONFIG_HOME"""
|
||||
config_home = os.environ.get('XDG_CONFIG_HOME', os.path.expanduser('~/.config'))
|
||||
return Path(config_home) / 'stormux' / 'keymode.toml'
|
||||
|
||||
|
||||
def play_sound_async(command):
|
||||
"""Play a sound asynchronously without blocking (pattern from sound.py:24-36)"""
|
||||
if not command or not shutil.which('play'):
|
||||
return
|
||||
|
||||
try:
|
||||
subprocess.Popen(
|
||||
command,
|
||||
shell=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True
|
||||
)
|
||||
except Exception:
|
||||
# Silently ignore sound playback errors
|
||||
pass
|
||||
|
||||
|
||||
def play_sound_reversed(command):
|
||||
"""Play sound in reverse by appending 'reverse' to sox command (pattern from sound.py:79)"""
|
||||
if not command:
|
||||
return
|
||||
|
||||
reversed_command = command + " reverse"
|
||||
play_sound_async(reversed_command)
|
||||
|
||||
|
||||
def execute_command(command):
|
||||
"""Execute configured command asynchronously"""
|
||||
if not command:
|
||||
return
|
||||
|
||||
try:
|
||||
# Expand ~ and environment variables
|
||||
expanded_command = os.path.expanduser(command)
|
||||
expanded_command = os.path.expandvars(expanded_command)
|
||||
|
||||
# Execute in background
|
||||
subprocess.Popen(
|
||||
expanded_command,
|
||||
shell=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error executing command: {e}", file=sys.stderr)
|
||||
|
||||
|
||||
class KeyMode_Window(Gtk.Window):
|
||||
"""Main window for modal keyboard capture"""
|
||||
|
||||
def __init__(self, modeName, config):
|
||||
super().__init__(title=f"keymode-{modeName}")
|
||||
self.modeName = modeName
|
||||
self.config = config
|
||||
self.modeConfig = config['mode'][modeName]
|
||||
|
||||
# Initialize GTK Layer Shell if available (for Wayland)
|
||||
# This allows the window to receive focus even when other windows are fullscreen
|
||||
if HAS_LAYER_SHELL and GtkLayerShell.is_supported():
|
||||
GtkLayerShell.init_for_window(self)
|
||||
# Use overlay layer to ensure focus even with fullscreen windows
|
||||
GtkLayerShell.set_layer(self, GtkLayerShell.Layer.OVERLAY)
|
||||
# Request keyboard interactivity
|
||||
GtkLayerShell.set_keyboard_mode(self, GtkLayerShell.KeyboardMode.EXCLUSIVE)
|
||||
# Center on screen
|
||||
GtkLayerShell.set_anchor(self, GtkLayerShell.Edge.TOP, False)
|
||||
GtkLayerShell.set_anchor(self, GtkLayerShell.Edge.BOTTOM, False)
|
||||
GtkLayerShell.set_anchor(self, GtkLayerShell.Edge.LEFT, False)
|
||||
GtkLayerShell.set_anchor(self, GtkLayerShell.Edge.RIGHT, False)
|
||||
|
||||
# Window properties for X11 and Wayland compatibility
|
||||
self.set_default_size(200, 80)
|
||||
self.set_decorated(False)
|
||||
self.set_skip_taskbar_hint(True)
|
||||
self.set_skip_pager_hint(True)
|
||||
self.set_keep_above(True)
|
||||
|
||||
# Center window on screen (for X11 and non-layer-shell Wayland)
|
||||
self.set_position(Gtk.WindowPosition.CENTER)
|
||||
|
||||
# Optional: try to set semi-transparent background
|
||||
try:
|
||||
screen = self.get_screen()
|
||||
visual = screen.get_rgba_visual()
|
||||
if visual:
|
||||
self.set_visual(visual)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Create label showing mode name
|
||||
description = self.modeConfig.get('description', modeName)
|
||||
label = Gtk.Label(label=description)
|
||||
label.set_margin_top(20)
|
||||
label.set_margin_bottom(20)
|
||||
label.set_margin_start(20)
|
||||
label.set_margin_end(20)
|
||||
|
||||
# Set accessible name for screen readers
|
||||
try:
|
||||
accessible = label.get_accessible()
|
||||
if accessible:
|
||||
accessible.set_name(f"Mode: {description}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.add(label)
|
||||
|
||||
# Connect signals
|
||||
self.connect("realize", self.on_realize)
|
||||
self.connect("key-press-event", self.on_key_press)
|
||||
|
||||
# Timeout handling
|
||||
timeout = config.get('settings', {}).get('timeout_seconds', 0)
|
||||
if timeout > 0:
|
||||
GLib.timeout_add_seconds(timeout, self.on_timeout)
|
||||
|
||||
def on_realize(self, widget):
|
||||
"""Called when window is realized - grab focus and play sound"""
|
||||
# Grab keyboard focus
|
||||
self.get_window().focus(Gdk.CURRENT_TIME)
|
||||
|
||||
# Play entry sound
|
||||
sound = self.modeConfig.get('sound')
|
||||
if sound:
|
||||
play_sound_async(sound)
|
||||
|
||||
def on_timeout(self):
|
||||
"""Called when timeout expires"""
|
||||
# Play reversed sound
|
||||
sound = self.modeConfig.get('sound')
|
||||
if sound:
|
||||
play_sound_reversed(sound)
|
||||
|
||||
# Exit
|
||||
Gtk.main_quit()
|
||||
return False
|
||||
|
||||
def keyval_to_config_string(self, event):
|
||||
"""Convert Gdk.Event to config string like 'Control+c' or 'F1'"""
|
||||
modifiers = []
|
||||
|
||||
# Check for modifier keys
|
||||
if event.state & Gdk.ModifierType.CONTROL_MASK:
|
||||
modifiers.append("Control")
|
||||
if event.state & Gdk.ModifierType.MOD1_MASK: # Alt
|
||||
modifiers.append("Alt")
|
||||
if event.state & Gdk.ModifierType.SHIFT_MASK:
|
||||
modifiers.append("Shift")
|
||||
if event.state & Gdk.ModifierType.MOD4_MASK: # Super
|
||||
modifiers.append("Super")
|
||||
|
||||
# Get key name
|
||||
keyName = Gdk.keyval_name(event.keyval)
|
||||
|
||||
# Build config string
|
||||
if modifiers:
|
||||
return "+".join(modifiers + [keyName])
|
||||
return keyName
|
||||
|
||||
def on_key_press(self, widget, event):
|
||||
"""Handle keyboard input"""
|
||||
# Convert keyval to config string
|
||||
keyString = self.keyval_to_config_string(event)
|
||||
|
||||
# Check if Escape (exit without action)
|
||||
if event.keyval == Gdk.KEY_Escape:
|
||||
sound = self.modeConfig.get('sound')
|
||||
if sound:
|
||||
play_sound_reversed(sound)
|
||||
Gtk.main_quit()
|
||||
return True
|
||||
|
||||
# Look up in config
|
||||
keys = self.modeConfig.get('keys', {})
|
||||
if keyString in keys:
|
||||
command = keys[keyString]
|
||||
|
||||
# Play reversed sound
|
||||
sound = self.modeConfig.get('sound')
|
||||
if sound:
|
||||
play_sound_reversed(sound)
|
||||
|
||||
# Execute command
|
||||
execute_command(command)
|
||||
|
||||
# Exit
|
||||
Gtk.main_quit()
|
||||
return True
|
||||
|
||||
# Unknown key - ignore
|
||||
return True
|
||||
|
||||
|
||||
def generate_example_config(configPath):
|
||||
"""Generate example config file"""
|
||||
configPath.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
example = """# Keymode Configuration
|
||||
# Configuration for modal keyboard interaction tool
|
||||
|
||||
[settings]
|
||||
# Timeout in seconds (0 = no timeout, waits indefinitely)
|
||||
timeout_seconds = 0
|
||||
|
||||
# Example mode: ratpoison-style application launcher
|
||||
[mode.ratpoison]
|
||||
description = "Ratpoison Mode"
|
||||
# Sound played when entering mode (sox command)
|
||||
# Will be played in reverse when exiting
|
||||
sound = "play -qV0 \\"|sox -np synth .07 sq 400\\" \\"|sox -np synth .5 sq 800\\" fade h 0 .5 .5 norm -20"
|
||||
|
||||
[mode.ratpoison.keys]
|
||||
# Format: key = "command to execute"
|
||||
# Special keys use quotes: "F1", "F2", etc.
|
||||
# Modifiers: "Control+c", "Alt+f", "Shift+x", "Super+r"
|
||||
# Escape always exits without action (built-in)
|
||||
# Unbound keys are ignored - the mode waits for a valid key
|
||||
|
||||
c = "lxterminal"
|
||||
w = "brave"
|
||||
|
||||
# Window Manager Integration Examples:
|
||||
#
|
||||
# i3/Sway (~/.config/i3/config or ~/.config/sway/config):
|
||||
# bindsym Escape exec ~/.config/i3/scripts/keymode.py --mode ratpoison
|
||||
#
|
||||
# Openbox (~/.config/openbox/rc.xml):
|
||||
# <keybind key="Escape">
|
||||
# <action name="Execute">
|
||||
# <command>~/.config/i3/scripts/keymode.py --mode ratpoison</command>
|
||||
# </action>
|
||||
# </keybind>
|
||||
#
|
||||
# Fluxbox (~/.fluxbox/keys):
|
||||
# Escape :Exec ~/.config/i3/scripts/keymode.py --mode ratpoison
|
||||
"""
|
||||
|
||||
with open(configPath, 'w') as f:
|
||||
f.write(example)
|
||||
|
||||
print(f"Example config generated at: {configPath}")
|
||||
|
||||
|
||||
def validate_config(config, modeName):
|
||||
"""Validate configuration and provide helpful error messages"""
|
||||
if 'mode' not in config:
|
||||
print("Error: No modes defined in config.toml", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if modeName not in config['mode']:
|
||||
available = ', '.join(config['mode'].keys())
|
||||
print(f"Error: Mode '{modeName}' not found.", file=sys.stderr)
|
||||
print(f"Available modes: {available}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
modeConfig = config['mode'][modeName]
|
||||
if 'keys' not in modeConfig or not modeConfig['keys']:
|
||||
print(f"Error: Mode '{modeName}' has no keybindings defined", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def list_modes(config):
|
||||
"""List available modes from config"""
|
||||
if 'mode' not in config or not config['mode']:
|
||||
print("No modes defined in config")
|
||||
return
|
||||
|
||||
print("Available modes:")
|
||||
for modeName, modeConfig in config['mode'].items():
|
||||
description = modeConfig.get('description', 'No description')
|
||||
numKeys = len(modeConfig.get('keys', {}))
|
||||
print(f" {modeName}: {description} ({numKeys} keybindings)")
|
||||
|
||||
|
||||
def parse_args():
|
||||
"""Parse command-line arguments"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Modal keyboard input handler for window manager by Storm Dragon https://stormux.org",
|
||||
epilog="Example: keymode --mode ratpoison"
|
||||
)
|
||||
parser.add_argument(
|
||||
'--mode', '-m',
|
||||
help='Mode name from config.toml (e.g., ratpoison)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--config', '-c',
|
||||
help=f'Path to config file (default: {get_config_path()})'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--generate-config',
|
||||
action='store_true',
|
||||
help='Generate example config and exit'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--list-modes',
|
||||
action='store_true',
|
||||
help='List available modes from config'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--version', '-v',
|
||||
action='version',
|
||||
version='keymode 2025.12.13'
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point"""
|
||||
args = parse_args()
|
||||
|
||||
# Get config path
|
||||
configPath = Path(args.config) if args.config else get_config_path()
|
||||
|
||||
# Handle --generate-config
|
||||
if args.generate_config:
|
||||
generate_example_config(configPath)
|
||||
return 0
|
||||
|
||||
# Check if config exists
|
||||
if not configPath.exists():
|
||||
print(f"Error: Config file not found: {configPath}", file=sys.stderr)
|
||||
print(f"Run: {sys.argv[0]} --generate-config", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Load config
|
||||
try:
|
||||
config = load_toml(str(configPath))
|
||||
except Exception as e:
|
||||
print(f"Error loading config: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Handle --list-modes
|
||||
if args.list_modes:
|
||||
list_modes(config)
|
||||
return 0
|
||||
|
||||
# Require --mode for normal operation
|
||||
if not args.mode:
|
||||
print("Error: --mode is required", file=sys.stderr)
|
||||
print(f"Run: {sys.argv[0]} --list-modes to see available modes", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# Validate config
|
||||
validate_config(config, args.mode)
|
||||
|
||||
# Check if sox is available (warn but continue)
|
||||
if not shutil.which('play'):
|
||||
print("Warning: 'play' command not found. Audio feedback disabled.", file=sys.stderr)
|
||||
print("Install sox for sound effects: sudo apt install sox", file=sys.stderr)
|
||||
|
||||
# Create and show window
|
||||
window = KeyMode_Window(args.mode, config)
|
||||
window.show_all()
|
||||
|
||||
# Run GTK main loop
|
||||
Gtk.main()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -1,99 +0,0 @@
|
||||
#!/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
|
||||
|
||||
# Setup logging
|
||||
logDir="/home/stormux/Logs"
|
||||
logFile="${logDir}/system-updates.log"
|
||||
mkdir -p "${logDir}"
|
||||
echo "=== System Update Started: $(date) ===" | tee "${logFile}"
|
||||
|
||||
# Track errors
|
||||
errorCount=0
|
||||
errorMessages=()
|
||||
|
||||
# Clean up old packages keeping currently installed versions only
|
||||
pacman -Sc --noconfirm --quiet 2>&1 | tee -a "${logFile}"
|
||||
if [[ ${PIPESTATUS[0]} -ne 0 ]]; then
|
||||
((errorCount++))
|
||||
errorMessages+=("Package cleaning failed")
|
||||
fi
|
||||
|
||||
# Upgrade the system
|
||||
pacman -Syu --noconfirm --quiet 2>&1 | tee -a "${logFile}"
|
||||
if [[ ${PIPESTATUS[0]} -ne 0 ]]; then
|
||||
((errorCount++))
|
||||
errorMessages+=("Package update failed")
|
||||
fi
|
||||
|
||||
gitUrl="https://git.stormux.org/storm/gaming-image-files"
|
||||
gitPath="${gitUrl##*/}"
|
||||
pushd /tmp || exit
|
||||
git config --global credential.helper store
|
||||
git clone --quiet "${gitUrl}" 2>&1 | tee -a "${logFile}"
|
||||
if [[ ${PIPESTATUS[0]} -ne 0 ]]; then
|
||||
((errorCount++))
|
||||
errorMessages+=("Git clone failed")
|
||||
fi
|
||||
pushd "${gitPath}" || exit
|
||||
git checkout --quiet master 2>&1 | tee -a "${logFile}"
|
||||
if [[ ${PIPESTATUS[0]} -ne 0 ]]; then
|
||||
((errorCount++))
|
||||
errorMessages+=("Git checkout master failed")
|
||||
fi
|
||||
git lfs pull 2>&1 | tee -a "${logFile}"
|
||||
if [[ ${PIPESTATUS[0]} -ne 0 ]]; then
|
||||
((errorCount++))
|
||||
errorMessages+=("Git LFS pull failed")
|
||||
fi
|
||||
# Files and directories to ignore when copying
|
||||
ignoreFiles=(".git" "./image" ".git*" "/home/stormux/.w3m" "/home/stormux/.irssi")
|
||||
# Build find command with ignore patterns
|
||||
findArgs=()
|
||||
for ignore in "${ignoreFiles[@]}"; do
|
||||
if [[ "$ignore" == .* && "$ignore" != ./* ]]; then
|
||||
findArgs+=(-name "$ignore" -prune -o)
|
||||
else
|
||||
findArgs+=(-path "$ignore" -prune -o)
|
||||
fi
|
||||
done
|
||||
# Copy all files as root (preserves permissions properly)
|
||||
find . "${findArgs[@]}" -type f -exec bash -c 'for i ; do cp -a --preserve=all --parents "${i}" /;done' _ "{}" \; 2>&1 | tee -a "${logFile}"
|
||||
if [[ ${PIPESTATUS[0]} -ne 0 ]]; then
|
||||
((errorCount++))
|
||||
errorMessages+=("File copy failed")
|
||||
fi
|
||||
# Fix ownership of home directory files (exclude immutable .baremetal)
|
||||
find /home/stormux -path /home/stormux/.baremetal -prune -o -exec chown -h stormux:users '{}' \; 2>&1 | tee -a "${logFile}"
|
||||
if [[ ${PIPESTATUS[0]} -ne 0 ]]; then
|
||||
((errorCount++))
|
||||
errorMessages+=("Ownership fix failed")
|
||||
fi
|
||||
popd || exit
|
||||
rm -rf "${gitPath}"
|
||||
popd || exit
|
||||
|
||||
echo "=== System Update Completed: $(date) ===" | tee -a "${logFile}"
|
||||
echo | tee -a "${logFile}"
|
||||
|
||||
# Display summary
|
||||
if [[ $errorCount -eq 0 ]]; then
|
||||
echo "SUCCESS: All update operations completed successfully." | tee -a "${logFile}"
|
||||
else
|
||||
echo "ERRORS DETECTED: $errorCount error(s) occurred during update:" | tee -a "${logFile}"
|
||||
for error in "${errorMessages[@]}"; do
|
||||
echo " - $error" | tee -a "${logFile}"
|
||||
done
|
||||
fi
|
||||
echo | tee -a "${logFile}"
|
||||
|
||||
read -r -p "Press enter to continue."
|
||||
|
||||
exit 0
|
||||
@@ -1,621 +0,0 @@
|
||||
#!/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()
|
||||
@@ -1,138 +0,0 @@
|
||||
#!/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()
|
||||
@@ -1,27 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,507 +0,0 @@
|
||||
#!/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()
|
||||
@@ -1,96 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script to select an audio file and convert it using convert-to-video.sh
|
||||
# Accessibility notes: Uses yad with titles, text labels, and standard GTK keyboard navigation.
|
||||
|
||||
AUDIO_DIR="$HOME/Audio"
|
||||
CONVERTER="/usr/local/bin/convert-to-video.sh"
|
||||
|
||||
# Check if Audio directory exists
|
||||
if [[ ! -d "$AUDIO_DIR" ]]; then
|
||||
echo "The directory $AUDIO_DIR does not exist." |
|
||||
yad --text-info \
|
||||
--title="Error" \
|
||||
--show-cursor \
|
||||
--button="OK:0" \
|
||||
--center
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Find audio files (mp3, ogg, opus, wav, flac) case-insensitive
|
||||
# -maxdepth 1: Only look in the top level of the directory
|
||||
# -printf "%f\n": Print only the filename followed by a newline
|
||||
FILES=$(find "$AUDIO_DIR" -maxdepth 1 -type f \( -iname "*.mp3" -o -iname "*.ogg" -o -iname "*.opus" -o -iname "*.wav" -o -iname "*.flac" \) -printf "%f\n" | sort)
|
||||
|
||||
# Check if any files were found
|
||||
if [[ -z "$FILES" ]]; then
|
||||
echo "No audio files available in $AUDIO_DIR." |
|
||||
yad --text-info \
|
||||
--title="No Audio Files" \
|
||||
--show-cursor \
|
||||
--button="OK:0" \
|
||||
--center \
|
||||
--width=300
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Display selection dialog
|
||||
# --list: Create a list box
|
||||
# --column="File": Single column with header
|
||||
# --separator="": Return exact value without extra separators
|
||||
# --search-column=1: Allows typing to jump to files (accessibility win)
|
||||
SELECTED_FILE=$(echo "$FILES" | yad --list \
|
||||
--title="Select Audio File" \
|
||||
--text="Select an audio file to convert:" \
|
||||
--column="File" \
|
||||
--separator="" \
|
||||
--width=600 \
|
||||
--height=500 \
|
||||
--center \
|
||||
--search-column=1)
|
||||
|
||||
# Check exit status
|
||||
# 0 = OK/Enter
|
||||
# Any other value (1, 252) = Cancel or Close
|
||||
RET=$?
|
||||
|
||||
# If user canceled or selection is empty, exit silently
|
||||
if [[ $RET -ne 0 ]] || [[ -z "$SELECTED_FILE" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Construct full path
|
||||
INPUT_FILE="$AUDIO_DIR/$SELECTED_FILE"
|
||||
|
||||
# Run the conversion
|
||||
# We use a subshell to run the command and pipe output to yad
|
||||
(
|
||||
echo "# Converting: $SELECTED_FILE"
|
||||
# Run the converter script
|
||||
"$CONVERTER" "$INPUT_FILE"
|
||||
# Send 100% to yad to close the progress bar (due to --auto-close)
|
||||
echo "100"
|
||||
) | yad --progress \
|
||||
--title="Converting" \
|
||||
--text="Starting conversion for $SELECTED_FILE... This can take a long time." \
|
||||
--pulsate \
|
||||
--auto-close \
|
||||
--auto-kill \
|
||||
--center \
|
||||
--width=500 \
|
||||
--button="Cancel:1"
|
||||
|
||||
# Check if the process completed successfully (yad returns 0 on auto-close/success)
|
||||
PROGRESS_RET=$?
|
||||
|
||||
if [[ $PROGRESS_RET -eq 0 ]]; then
|
||||
echo "Transcode complete." |
|
||||
yad --text-info \
|
||||
--title="Success" \
|
||||
--show-cursor \
|
||||
--button="OK:0" \
|
||||
--center \
|
||||
--width=300
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -1,52 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Timezone configuration for Stormux Gaming Image
|
||||
# Uses the same interface as standard Stormux images
|
||||
|
||||
export DIALOGOPTS='--insecure --no-lines --visit-items'
|
||||
|
||||
set_timezone() {
|
||||
# Get the list of timezones
|
||||
mapfile -t regions < <(timedatectl --no-pager list-timezones | cut -d '/' -f1 | sort -u)
|
||||
|
||||
# Use the same text twice here and just hide the tag field.
|
||||
region=$(dialog --backtitle "Please select your Region" \
|
||||
--no-tags \
|
||||
--menu "Use up and down arrows or page-up and page-down to navigate the list, and press 'Enter' to make your selection." 0 0 0 \
|
||||
$(for i in ${regions[@]} ; do echo "$i";echo "$i";done) --stdout)
|
||||
|
||||
if [[ -z "$region" ]]; then
|
||||
echo "No region selected"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mapfile -t cities < <(timedatectl --no-pager list-timezones | grep "$region" | cut -d '/' -f2 | sort -u)
|
||||
|
||||
# Use the same text twice here and just hide the tag field.
|
||||
city=$(dialog --backtitle "Please select a city near you" \
|
||||
--no-tags \
|
||||
--menu "Use up and down arrow or page-up and page-down to navigate the list." 0 0 10 \
|
||||
$(for i in ${cities[@]} ; do echo "$i";echo "$i";done) --stdout)
|
||||
|
||||
if [[ -z "$city" ]]; then
|
||||
echo "No city selected"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Set the timezone
|
||||
if [[ -f /etc/localtime ]]; then
|
||||
rm /etc/localtime
|
||||
fi
|
||||
ln -sf /usr/share/zoneinfo/${region}/${city} /etc/localtime
|
||||
timedatectl set-ntp true
|
||||
|
||||
echo "Timezone set to ${region}/${city}"
|
||||
}
|
||||
|
||||
# Check if running as root
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "This script requires root privileges"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
set_timezone
|
||||
@@ -1,387 +0,0 @@
|
||||
#!/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)
|
||||
|
||||
# Read the current config file
|
||||
import re
|
||||
with open('/etc/speech-dispatcher/speechd.conf', 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
# Check if DefaultModule is already uncommented
|
||||
if re.search(r'^\s*DefaultModule\s+', content, re.MULTILINE):
|
||||
# Replace existing DefaultModule line
|
||||
new_content = re.sub(
|
||||
r'^(\s*)DefaultModule\s+\S+',
|
||||
f'\\1DefaultModule {module}',
|
||||
content,
|
||||
flags=re.MULTILINE
|
||||
)
|
||||
else:
|
||||
# Uncomment and set DefaultModule line
|
||||
new_content = re.sub(
|
||||
r'^(\s*)#\s*DefaultModule\s+\S*',
|
||||
f'\\1DefaultModule {module}',
|
||||
content,
|
||||
flags=re.MULTILINE
|
||||
)
|
||||
|
||||
# Write to a temporary file
|
||||
temp_file = "/tmp/speechd.conf.new"
|
||||
with open(temp_file, 'w') as f:
|
||||
f.write(new_content)
|
||||
|
||||
# Use sudo to move the file to the correct location
|
||||
subprocess.run(f"sudo mv {temp_file} /etc/speech-dispatcher/speechd.conf", shell=True, check=True)
|
||||
|
||||
# Restart speech-dispatcher more thoroughly
|
||||
subprocess.run("sudo systemctl restart speech-dispatcher", shell=True, check=False)
|
||||
# Also kill any remaining processes
|
||||
subprocess.run("sudo killall speech-dispatcher", shell=True, check=False)
|
||||
|
||||
# Re-initialize speech after changes
|
||||
time.sleep(2) # Give more time 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 set as the default voice 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()
|
||||
@@ -1,438 +0,0 @@
|
||||
#!/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
|
||||
|
||||
def update_speechd_config_content(content, rate, volume, pitch):
|
||||
"""Return speechd.conf content with updated default voice parameters."""
|
||||
newContent = content
|
||||
|
||||
replacements = (
|
||||
("DefaultRate", rate),
|
||||
("DefaultVolume", volume),
|
||||
("DefaultPitch", pitch),
|
||||
)
|
||||
|
||||
for settingName, settingValue in replacements:
|
||||
activePattern = rf'^(\s*){settingName}\s+(-?\d+)'
|
||||
commentedPattern = rf'^(\s*)#\s*{settingName}\s+(-?\d+)'
|
||||
replacement = rf'\1{settingName} {settingValue}'
|
||||
|
||||
if re.search(activePattern, newContent, re.MULTILINE):
|
||||
newContent = re.sub(
|
||||
activePattern,
|
||||
replacement,
|
||||
newContent,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
else:
|
||||
newContent = re.sub(
|
||||
commentedPattern,
|
||||
replacement,
|
||||
newContent,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
|
||||
return newContent
|
||||
|
||||
|
||||
def get_writable_config_targets(configTargets):
|
||||
"""Return existing config paths to update, preserving order."""
|
||||
return [configPath for configPath in configTargets if os.path.exists(configPath)]
|
||||
|
||||
|
||||
class SpeechRateMenu:
|
||||
def __init__(self, title="Speech Configuration"):
|
||||
self.title = title
|
||||
self.currentRate = 0 # Default rate
|
||||
self.currentVolume = 100 # Default volume
|
||||
self.currentPitch = 0 # Default pitch
|
||||
self.currentMode = 0 # 0=Rate, 1=Volume, 2=Pitch
|
||||
self.modes = ["Rate", "Volume", "Pitch"]
|
||||
self.stdscr = None
|
||||
self.cursesInitialized = False # Flag to track if curses has been initialized
|
||||
self.configFile = "/etc/speech-dispatcher/speechd.conf"
|
||||
self.configTargets = [
|
||||
self.configFile,
|
||||
os.path.expanduser("~/.fex-emu/RootFS/ArchLinux/etc/speech-dispatcher/speechd.conf"),
|
||||
]
|
||||
|
||||
# Load current settings from config FIRST
|
||||
self.load_current_settings()
|
||||
|
||||
# Initialize speech client AFTER loading the settings
|
||||
self.speechClient = None
|
||||
self.init_speech()
|
||||
|
||||
def init_speech(self):
|
||||
"""Initialize the speech client"""
|
||||
try:
|
||||
self.speechClient = speechd.SSIPClient("speech_config_menu")
|
||||
self.speechClient.set_priority(speechd.Priority.IMPORTANT)
|
||||
self.speechClient.set_punctuation(speechd.PunctuationMode.SOME)
|
||||
|
||||
# Apply the loaded settings to the speech client
|
||||
self.speechClient.set_rate(self.currentRate)
|
||||
self.speechClient.set_volume(self.currentVolume)
|
||||
self.speechClient.set_pitch(self.currentPitch)
|
||||
except Exception as e:
|
||||
# Fallback to None - the speak method will handle this
|
||||
pass
|
||||
|
||||
def load_current_settings(self):
|
||||
"""Load the current default settings from speechd.conf"""
|
||||
try:
|
||||
with open(self.configFile, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
# Load Rate
|
||||
activeMatch = re.search(r'^\s*DefaultRate\s+(-?\d+)', content, re.MULTILINE)
|
||||
if activeMatch:
|
||||
self.currentRate = int(activeMatch.group(1))
|
||||
else:
|
||||
commentedMatch = re.search(r'^\s*#\s*DefaultRate\s+(-?\d+)', content, re.MULTILINE)
|
||||
if commentedMatch:
|
||||
self.currentRate = int(commentedMatch.group(1))
|
||||
|
||||
# Load Volume
|
||||
activeMatch = re.search(r'^\s*DefaultVolume\s+(-?\d+)', content, re.MULTILINE)
|
||||
if activeMatch:
|
||||
self.currentVolume = int(activeMatch.group(1))
|
||||
else:
|
||||
commentedMatch = re.search(r'^\s*#\s*DefaultVolume\s+(-?\d+)', content, re.MULTILINE)
|
||||
if commentedMatch:
|
||||
self.currentVolume = int(commentedMatch.group(1))
|
||||
|
||||
# Load Pitch
|
||||
activeMatch = re.search(r'^\s*DefaultPitch\s+(-?\d+)', content, re.MULTILINE)
|
||||
if activeMatch:
|
||||
self.currentPitch = int(activeMatch.group(1))
|
||||
else:
|
||||
commentedMatch = re.search(r'^\s*#\s*DefaultPitch\s+(-?\d+)', content, re.MULTILINE)
|
||||
if commentedMatch:
|
||||
self.currentPitch = int(commentedMatch.group(1))
|
||||
|
||||
except Exception:
|
||||
# If loading fails, we'll use default values
|
||||
pass
|
||||
|
||||
def save_settings_to_config(self):
|
||||
"""Save the current settings to the speech-dispatcher config file"""
|
||||
try:
|
||||
for configPath in get_writable_config_targets(self.configTargets):
|
||||
with open(configPath, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
newContent = update_speechd_config_content(
|
||||
content,
|
||||
self.currentRate,
|
||||
self.currentVolume,
|
||||
self.currentPitch,
|
||||
)
|
||||
|
||||
tempFile = f"/tmp/{os.path.basename(configPath)}.new"
|
||||
with open(tempFile, 'w') as f:
|
||||
f.write(newContent)
|
||||
|
||||
subprocess.run(
|
||||
["sudo", "mv", tempFile, configPath],
|
||||
check=True,
|
||||
)
|
||||
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def get_current_value(self):
|
||||
"""Get the current value for the active mode"""
|
||||
if self.currentMode == 0: # Rate
|
||||
return self.currentRate
|
||||
elif self.currentMode == 1: # Volume
|
||||
return self.currentVolume
|
||||
else: # Pitch
|
||||
return self.currentPitch
|
||||
|
||||
def adjust_current_value(self, amount):
|
||||
"""Adjust the current value by the given amount"""
|
||||
if self.currentMode == 0: # Rate
|
||||
# Rate should be between -50 and 100
|
||||
newValue = max(-50, min(100, self.currentRate + amount))
|
||||
if newValue != self.currentRate:
|
||||
self.currentRate = newValue
|
||||
if self.speechClient:
|
||||
try:
|
||||
self.speechClient.set_rate(self.currentRate)
|
||||
self.speak(f"Speech rate {self.currentRate}")
|
||||
except Exception:
|
||||
pass
|
||||
elif self.currentMode == 1: # Volume
|
||||
# Volume should be between -100 and 100
|
||||
newValue = max(-100, min(100, self.currentVolume + amount))
|
||||
if newValue != self.currentVolume:
|
||||
self.currentVolume = newValue
|
||||
if self.speechClient:
|
||||
try:
|
||||
self.speechClient.set_volume(self.currentVolume)
|
||||
self.speak(f"Volume {self.currentVolume}")
|
||||
except Exception:
|
||||
pass
|
||||
else: # Pitch
|
||||
# Pitch should be between -100 and 100
|
||||
newValue = max(-100, min(100, self.currentPitch + amount))
|
||||
if newValue != self.currentPitch:
|
||||
self.currentPitch = newValue
|
||||
if self.speechClient:
|
||||
try:
|
||||
self.speechClient.set_pitch(self.currentPitch)
|
||||
self.speak(f"Pitch {self.currentPitch}")
|
||||
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 confirm_saved_settings(self):
|
||||
"""Restart speechd, announce success, and wait for confirmation."""
|
||||
subprocess.run(
|
||||
["sudo", "killall", "speech-dispatcher"],
|
||||
check=False,
|
||||
)
|
||||
time.sleep(1)
|
||||
self.init_speech()
|
||||
self.speak("Speech settings applied. Press Enter to continue.", interrupt=False)
|
||||
|
||||
while True:
|
||||
key = self.stdscr.getch()
|
||||
if key == curses.KEY_ENTER or key == 10 or key == 13:
|
||||
break
|
||||
|
||||
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 | Tab: Switch Mode | Enter: Save | Q/Esc: Quit"
|
||||
x = max(0, w // 2 - len(helpText) // 2)
|
||||
self.stdscr.addstr(3, x, helpText)
|
||||
|
||||
# Draw all current values
|
||||
currentMode = self.modes[self.currentMode]
|
||||
|
||||
# Rate display
|
||||
rateText = f"Rate: {self.currentRate}"
|
||||
attr = curses.A_REVERSE if self.currentMode == 0 else curses.A_NORMAL
|
||||
x = max(0, w // 2 - 30)
|
||||
self.stdscr.addstr(5, x, rateText, attr)
|
||||
|
||||
# Volume display
|
||||
volumeText = f"Volume: {self.currentVolume}"
|
||||
attr = curses.A_REVERSE if self.currentMode == 1 else curses.A_NORMAL
|
||||
x = max(0, w // 2 - 5)
|
||||
self.stdscr.addstr(5, x, volumeText, attr)
|
||||
|
||||
# Pitch display
|
||||
pitchText = f"Pitch: {self.currentPitch}"
|
||||
attr = curses.A_REVERSE if self.currentMode == 2 else curses.A_NORMAL
|
||||
x = max(0, w // 2 + 20)
|
||||
self.stdscr.addstr(5, x, pitchText, attr)
|
||||
|
||||
# Current mode indicator
|
||||
modeText = f"Current Mode: {currentMode}"
|
||||
x = max(0, w // 2 - len(modeText) // 2)
|
||||
self.stdscr.addstr(7, x, modeText, curses.A_BOLD)
|
||||
|
||||
# Draw visualization bar for current parameter
|
||||
barWidth = 50 # Width of the visualization bar
|
||||
barX = max(0, w // 2 - barWidth // 2)
|
||||
|
||||
# Get current value and range
|
||||
currentValue = self.get_current_value()
|
||||
if self.currentMode == 0: # Rate
|
||||
minVal, maxVal = -50, 100
|
||||
totalRange = 150
|
||||
normalizedValue = currentValue + 50
|
||||
else: # Volume or Pitch
|
||||
minVal, maxVal = -100, 100
|
||||
totalRange = 200
|
||||
normalizedValue = currentValue + 100
|
||||
|
||||
position = int((normalizedValue / totalRange) * barWidth)
|
||||
|
||||
# Draw the bar
|
||||
barY = 9
|
||||
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, str(minVal))
|
||||
maxLabel = str(maxVal)
|
||||
self.stdscr.addstr(barY + 3, barX + barWidth - len(maxLabel), maxLabel)
|
||||
|
||||
# Note about saving
|
||||
note = "Press Enter to save all settings 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
|
||||
currentMode = self.modes[self.currentMode]
|
||||
self.speak(f"Speech configuration menu. Currently adjusting {currentMode}. Rate {self.currentRate}, Volume {self.currentVolume}, Pitch {self.currentPitch}.")
|
||||
|
||||
# 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 current value by 10
|
||||
self.adjust_current_value(10)
|
||||
self.draw_menu()
|
||||
|
||||
elif key == curses.KEY_DOWN:
|
||||
# Decrease current value by 10
|
||||
self.adjust_current_value(-10)
|
||||
self.draw_menu()
|
||||
|
||||
elif key == ord('\t') or key == 9: # Tab key
|
||||
# Switch to next mode
|
||||
self.currentMode = (self.currentMode + 1) % len(self.modes)
|
||||
currentMode = self.modes[self.currentMode]
|
||||
currentValue = self.get_current_value()
|
||||
self.speak(f"Switching to {currentMode}. Current value: {currentValue}")
|
||||
self.draw_menu()
|
||||
|
||||
elif key == curses.KEY_ENTER or key == 10 or key == 13: # Enter key
|
||||
# Save all settings
|
||||
self.speak("Saving speech settings to system configuration.")
|
||||
success = self.save_settings_to_config()
|
||||
if success:
|
||||
self.confirm_saved_settings()
|
||||
else:
|
||||
self.speak("Failed to save speech settings. You may need root privileges.")
|
||||
|
||||
break # Exit the loop after saving
|
||||
|
||||
elif key == 27 or key == ord('q') or key == ord('Q'): # Esc or Q
|
||||
self.speak("Speech settings discarded.")
|
||||
time.sleep(3)
|
||||
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()
|
||||
@@ -1,257 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Stormux Installation Helper
|
||||
# Provides automatic installation with speech feedback and progress indicators
|
||||
|
||||
# Configuration
|
||||
GAMES_REGISTRY="/usr/share/stormux/downloadable_games.json"
|
||||
PACKAGES_REGISTRY="/usr/share/stormux/installable_packages.json"
|
||||
LOG_DIR="$HOME/Logs"
|
||||
LOG_FILE="$LOG_DIR/installer.log"
|
||||
INSTALL_BASE="$HOME/.local/games"
|
||||
NVDA_DLL_SOURCE="$HOME/.local/games/nvda"
|
||||
|
||||
# Initialize logging
|
||||
init_logging() {
|
||||
mkdir -p "$LOG_DIR"
|
||||
# Clear previous log and start fresh
|
||||
echo "=== Installation started at $(date) ===" > "$LOG_FILE"
|
||||
}
|
||||
|
||||
# Speech output
|
||||
speak() {
|
||||
local message="$1"
|
||||
echo "$message" >> "$LOG_FILE"
|
||||
spd-say "$message" 2>/dev/null || echo "$message"
|
||||
}
|
||||
|
||||
# Progress beep
|
||||
progress_beep() {
|
||||
# Simple beep using speaker-test or paplay
|
||||
if command -v paplay &> /dev/null && [[ -f /usr/share/sounds/freedesktop/stereo/message.oga ]]; then
|
||||
paplay /usr/share/sounds/freedesktop/stereo/message.oga 2>/dev/null &
|
||||
fi
|
||||
}
|
||||
|
||||
# Download with progress feedback
|
||||
download_with_progress() {
|
||||
local url="$1"
|
||||
local output="$2"
|
||||
local name="$3"
|
||||
|
||||
speak "Downloading $name"
|
||||
echo "Downloading from: $url" >> "$LOG_FILE"
|
||||
|
||||
# Start background process for progress beeps
|
||||
(
|
||||
while kill -0 $$ 2>/dev/null; do
|
||||
sleep 5
|
||||
progress_beep
|
||||
done
|
||||
) &
|
||||
local beep_pid=$!
|
||||
|
||||
# Download with curl
|
||||
if curl -L -f --progress-bar -o "$output" "$url" 2>> "$LOG_FILE"; then
|
||||
kill $beep_pid 2>/dev/null
|
||||
wait $beep_pid 2>/dev/null
|
||||
echo "Download successful" >> "$LOG_FILE"
|
||||
return 0
|
||||
else
|
||||
kill $beep_pid 2>/dev/null
|
||||
wait $beep_pid 2>/dev/null
|
||||
echo "Download failed" >> "$LOG_FILE"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Replace NVDA DLLs in game directory
|
||||
replace_nvda_dlls() {
|
||||
local game_dir="$1"
|
||||
|
||||
if [[ ! -d "$NVDA_DLL_SOURCE" ]]; then
|
||||
echo "Warning: NVDA DLL source directory not found" >> "$LOG_FILE"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Replace all NVDA DLLs
|
||||
{
|
||||
find "$game_dir" -type f -name "nvdaControllerClient32.dll" -exec cp -v "$NVDA_DLL_SOURCE/nvdaControllerClient32.dll" '{}' \;
|
||||
find "$game_dir" -type f -name "nvdaControllerClient64.dll" -exec cp -v "$NVDA_DLL_SOURCE/nvdaControllerClient64.dll" '{}' \;
|
||||
find "$game_dir" -type f -name "nvdaControllerClient.dll" -exec cp -v "$NVDA_DLL_SOURCE/nvdaControllerClient64.dll" '{}' \;
|
||||
} >> "$LOG_FILE" 2>&1
|
||||
}
|
||||
|
||||
# Extract and setup game from ZIP
|
||||
setup_game_zip() {
|
||||
local zipfile="$1"
|
||||
local game_dir="$2"
|
||||
local game_name="$3"
|
||||
|
||||
speak "Installing $game_name"
|
||||
echo "Extracting to: $game_dir" >> "$LOG_FILE"
|
||||
|
||||
# Remove old installation
|
||||
rm -rf "$game_dir"
|
||||
mkdir -p "$game_dir"
|
||||
|
||||
# Extract to temp location first to handle any ZIP structure
|
||||
local temp_extract="/tmp/stormux_install_$$"
|
||||
mkdir -p "$temp_extract"
|
||||
|
||||
if ! unzip -q "$zipfile" -d "$temp_extract" 2>> "$LOG_FILE"; then
|
||||
echo "Extraction failed" >> "$LOG_FILE"
|
||||
rm -rf "$temp_extract"
|
||||
return 2
|
||||
fi
|
||||
|
||||
# Check if everything extracted into a single subdirectory
|
||||
local extracted_items
|
||||
extracted_items=("$temp_extract"/*)
|
||||
|
||||
if [[ ${#extracted_items[@]} -eq 1 ]] && [[ -d "${extracted_items[0]}" ]]; then
|
||||
# Single directory - move its contents to game_dir
|
||||
echo "Single directory extracted, moving contents" >> "$LOG_FILE"
|
||||
mv "${extracted_items[0]}"/* "$game_dir/" 2>> "$LOG_FILE"
|
||||
else
|
||||
# Multiple items or single file - move everything
|
||||
echo "Multiple items extracted, moving all" >> "$LOG_FILE"
|
||||
mv "$temp_extract"/* "$game_dir/" 2>> "$LOG_FILE"
|
||||
fi
|
||||
|
||||
rm -rf "$temp_extract"
|
||||
|
||||
# Replace NVDA DLLs
|
||||
replace_nvda_dlls "$game_dir"
|
||||
|
||||
# Clean up temp file
|
||||
rm -f "$zipfile"
|
||||
|
||||
echo "Installation complete" >> "$LOG_FILE"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Install system package via yay
|
||||
install_system_package() {
|
||||
local package="$1"
|
||||
local name="$2"
|
||||
|
||||
speak "Installing $name"
|
||||
echo "Installing package: $package" >> "$LOG_FILE"
|
||||
|
||||
if yay -Sy --noconfirm "$package" >> "$LOG_FILE" 2>&1; then
|
||||
echo "Package installation successful" >> "$LOG_FILE"
|
||||
return 0
|
||||
else
|
||||
echo "Package installation failed" >> "$LOG_FILE"
|
||||
speak "Installation of $name failed. Check logs."
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Check if game is installed
|
||||
is_game_installed() {
|
||||
local game_dir="$1"
|
||||
local executable="$2"
|
||||
|
||||
[[ -e "$game_dir/$executable" ]]
|
||||
}
|
||||
|
||||
# Check if package is installed
|
||||
is_package_installed() {
|
||||
local verify_command="$1"
|
||||
eval "$verify_command" &>/dev/null
|
||||
}
|
||||
|
||||
# Auto-install: Main function
|
||||
# Usage: auto_install <item_id>
|
||||
# Returns: 0 if ready to launch, non-zero on failure
|
||||
auto_install() {
|
||||
local item_id="$1"
|
||||
|
||||
init_logging
|
||||
|
||||
# Try to find in games registry
|
||||
if [[ -f "$GAMES_REGISTRY" ]]; then
|
||||
echo "Reading games registry: $GAMES_REGISTRY" >> "$LOG_FILE"
|
||||
local game_info
|
||||
game_info=$(jq -r ".downloadable_games[\"$item_id\"] // empty" "$GAMES_REGISTRY" 2>> "$LOG_FILE")
|
||||
echo "Game info result: ${game_info:-empty}" >> "$LOG_FILE"
|
||||
|
||||
if [[ -n "$game_info" ]]; then
|
||||
local name url directory executable game_dir
|
||||
name=$(echo "$game_info" | jq -r '.name')
|
||||
url=$(echo "$game_info" | jq -r '.url')
|
||||
directory=$(echo "$game_info" | jq -r '.directory')
|
||||
executable=$(echo "$game_info" | jq -r '.executable')
|
||||
game_dir="$INSTALL_BASE/$directory"
|
||||
|
||||
# Check if already installed
|
||||
if is_game_installed "$game_dir" "$executable"; then
|
||||
echo "Game already installed: $name" >> "$LOG_FILE"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Download and install
|
||||
local temp_zip="/tmp/${directory}_download.zip"
|
||||
if download_with_progress "$url" "$temp_zip" "$name"; then
|
||||
if setup_game_zip "$temp_zip" "$game_dir" "$name"; then
|
||||
# Verify installation
|
||||
if is_game_installed "$game_dir" "$executable"; then
|
||||
speak "Installation complete"
|
||||
return 0
|
||||
else
|
||||
speak "Installation failed. Executable not found."
|
||||
echo "Error: Executable not found after installation: $executable" >> "$LOG_FILE"
|
||||
return 3
|
||||
fi
|
||||
else
|
||||
speak "Installation failed. Could not extract game."
|
||||
return 2
|
||||
fi
|
||||
else
|
||||
speak "Download failed. Check your internet connection."
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Try to find in packages registry
|
||||
if [[ -f "$PACKAGES_REGISTRY" ]]; then
|
||||
local package_info
|
||||
package_info=$(jq -r ".installable_packages[\"$item_id\"] // empty" "$PACKAGES_REGISTRY" 2>/dev/null)
|
||||
|
||||
if [[ -n "$package_info" ]]; then
|
||||
local name package verify_command
|
||||
name=$(echo "$package_info" | jq -r '.name')
|
||||
package=$(echo "$package_info" | jq -r '.package')
|
||||
verify_command=$(echo "$package_info" | jq -r '.verify_command')
|
||||
|
||||
# Check if already installed
|
||||
if is_package_installed "$verify_command"; then
|
||||
echo "Package already installed: $name" >> "$LOG_FILE"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Install package
|
||||
if install_system_package "$package" "$name"; then
|
||||
speak "Installation complete"
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Item not found in any registry
|
||||
if [[ ! -f "$GAMES_REGISTRY" ]] && [[ ! -f "$PACKAGES_REGISTRY" ]]; then
|
||||
echo "Error: Registry files not found!" >> "$LOG_FILE"
|
||||
echo " Expected: $GAMES_REGISTRY" >> "$LOG_FILE"
|
||||
echo " Expected: $PACKAGES_REGISTRY" >> "$LOG_FILE"
|
||||
speak "Error: Installation registry files not found. This may be a development system."
|
||||
else
|
||||
echo "Error: Item not found in registries: $item_id" >> "$LOG_FILE"
|
||||
speak "Error: $item_id not found in installation registry"
|
||||
fi
|
||||
return 4
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
date_time=$(curl -s http://worldtimeapi.org/api/ip | grep -oP '(?<="datetime":")[^"]*')
|
||||
date -s "$date_time"
|
||||
Reference in New Issue
Block a user