#!/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()