From 7967c636841085e787b350c71be6e614435e2953 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Fri, 27 Feb 2026 11:27:30 -0500 Subject: [PATCH] Help is now navigable using the arrow keys instead of being one spoken continuous text. Keybinding moved from h to ? or f1. Braille is now handled through Cthulhu or Orca depending on the detected screen reader. This hands off Braille to an already implemented robust system and is less work for me. --- README.md | 15 +- bookstorm.py | 396 ++++++++++++++++------------- requirements.txt | 8 +- src/braille_menu.py | 285 --------------------- src/braille_output.py | 352 -------------------------- src/config_manager.py | 62 +---- src/help_menu.py | 77 ++++++ src/options_menu.py | 87 +++---- src/screen_reader_engine.py | 478 ++++++++++++++++++++++++++++++++++++ src/wav_exporter.py | 2 +- 10 files changed, 821 insertions(+), 941 deletions(-) delete mode 100644 src/braille_menu.py delete mode 100644 src/braille_output.py create mode 100644 src/help_menu.py create mode 100644 src/screen_reader_engine.py diff --git a/README.md b/README.md index eb0d475..73b3a9d 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ BookStorm is a fully accessible book reader for electronic books and audiobooks, ## Features - **Multiple Book Formats**: DAISY 2.02, DAISY 3, EPUB, PDF, TXT, and audio formats (M4B, M4A, MP3) -- **Text-to-Speech**: High-quality narration using piper-tts or system voices via speech-dispatcher +- **Text-to-Speech**: High-quality narration using piper-tts, system voices via speech-dispatcher, or active screen reader output - **Audio Book Playback**: Native playback with chapter navigation and bookmarks - **Audiobookshelf Integration**: Browse, stream, and download audiobooks from your Audiobookshelf server - **Full Keyboard Control**: No mouse required - navigate with simple keyboard shortcuts @@ -15,7 +15,7 @@ BookStorm is a fully accessible book reader for electronic books and audiobooks, - **Library Browser**: Browse your book collection with configurable library directory - **Recent Books**: Quick access to your 10 most recently read books - **WAV Export**: Convert text books to audio files split by chapter -- **Screen Reader Friendly**: All feedback provided via speech-dispatcher for full accessibility +- **Screen Reader Friendly**: Full keyboard control with spoken feedback and optional screen-reader reading mode ## Installation @@ -47,6 +47,8 @@ sudo apt install --no-install-recommends ffmpeg mpv python3 python3-pip python3- └── en_US-hfc_female-medium.onnx.json ``` +If you want screen-reader reading mode, install `dasbus` (`python-dasbus` system package on Arch, or `pip install dasbus`). + ## Usage ### Interactive Reading @@ -94,7 +96,7 @@ python bookstorm.py mybook.epub --wav --output-dir ./audiobooks - **o** - Options menu (TTS engine, voice selection, display settings) - **t** - Time remaining (if sleep timer active) -- **h** - Help (displays keyboard shortcuts) +- **F1** or **?** - Help (displays keyboard shortcuts) - **q or ESC** - Quit / Sleep timer menu ### Book Browser Controls @@ -145,7 +147,7 @@ Settings are stored in `~/.config/stormux/bookstorm/settings.ini` and can be mod [TTS] voice_model = /usr/share/piper-voices/en/en_US/hfc_male/medium/en_US-hfc_male-medium.onnx voice_dir = /usr/share/piper-voices/en/en_US -reader_engine = piper # or 'speechd' +reader_engine = piper # 'piper', 'speechd', or 'screenreader' speechd_voice = # speech-dispatcher voice name speechd_output_module = # e.g., espeak-ng speech_rate = 0 # -100 to 100 @@ -254,16 +256,17 @@ Install piper-tts and download voice models to `/usr/share/piper-voices/en/en_US ```bash # Install missing Python packages -pip install pygame beautifulsoup4 lxml python-speechd mutagen +pip install pygame beautifulsoup4 lxml python-speechd mutagen dasbus ``` ## Architecture -BookStorm uses a dual TTS system for text books: +BookStorm uses multiple reading engines for text books: - **UI Feedback**: speech-dispatcher (instant feedback for all actions) - **Book Reading (Piper-TTS mode)**: piper-tts → WAV → pygame.mixer (high quality) - **Book Reading (Speech-Dispatcher mode)**: Separate speech-dispatcher session (faster, system voices) +- **Book Reading (Screen Reader mode)**: Orca/Cthulhu `PresentMessage` via D-Bus (speech + braille from your active screen reader) For issues and feature requests, please visit https://git.stormux.org/storm/bookstorm diff --git a/bookstorm.py b/bookstorm.py index f0369a3..a3c71be 100755 --- a/bookstorm.py +++ b/bookstorm.py @@ -45,8 +45,10 @@ from src.voice_selector import VoiceSelector from src.book_selector import BookSelector from src.mpv_player import MpvPlayer from src.speech_engine import SpeechEngine +from src.screen_reader_engine import ScreenReaderEngine from src.options_menu import OptionsMenu from src.sleep_timer_menu import SleepTimerMenu +from src.help_menu import HelpMenu from src.recent_books_menu import RecentBooksMenu from src.audiobookshelf_client import AudiobookshelfClient from src.audiobookshelf_menu import AudiobookshelfMenu @@ -54,8 +56,6 @@ from src.server_link_manager import ServerLinkManager from src.audiobookshelf_sync import AudiobookshelfSync from src.bookmarks_menu import BookmarksMenu from src.wav_exporter import WavExporter -from src.braille_output import BrailleOutput -from src.braille_menu import BrailleMenu from src.ui import get_input @@ -90,20 +90,6 @@ class BookReader: speechRate = self.config.get_speech_rate() self.speechEngine.set_rate(speechRate) - # Initialize Braille output (before options menu) - brailleEnabled = self.config.get_braille_enabled() - brailleTable = self.config.get_braille_translation_table() - brailleSyncTts = self.config.get_braille_sync_with_tts() - brailleShowStatus = self.config.get_braille_show_status() - brailleMuteVoice = self.config.get_braille_mute_voice() - self.brailleOutput = BrailleOutput( - enabled=brailleEnabled, - translationTable=brailleTable, - syncWithTts=brailleSyncTts, - showStatus=brailleShowStatus, - muteVoice=brailleMuteVoice - ) - # Initialize options menu voiceSelector = VoiceSelector(self.config.get_voice_dir()) # Create callback reference for TTS engine reloading @@ -113,8 +99,7 @@ class BookReader: self.speechEngine, voiceSelector, self.audioPlayer, - ttsReloadCallback=reloadCallback, - brailleOutput=self.brailleOutput + ttsReloadCallback=reloadCallback ) # Initialize book selector @@ -130,6 +115,9 @@ class BookReader: # Initialize sleep timer menu self.sleepTimerMenu = SleepTimerMenu(self.speechEngine) + # Initialize help menu + self.helpMenu = HelpMenu(self.speechEngine) + # Initialize recent books menu self.recentBooksMenu = RecentBooksMenu(self.bookmarkManager, self.speechEngine) @@ -165,6 +153,30 @@ class BookReader: # Apply speech rate to reading engine self.readingEngine.set_rate(speechRate) + elif readerEngine == 'screenreader': + # Use active desktop screen reader via D-Bus + self.ttsEngine = None + self.readingEngine = ScreenReaderEngine() + self.readingEngine.set_rate(speechRate) + + if not self.readingEngine.is_available(): + message = "Warning: No supported screen reader detected. Falling back to speech-dispatcher." + print(message) + self.speechEngine.speak(message) + + # Switch to speech-dispatcher mode + self.readingEngine = SpeechEngine() + + savedModule = self.config.get_speechd_output_module() + if savedModule: + self.readingEngine.set_output_module(savedModule) + + savedVoice = self.config.get_speechd_voice() + if savedVoice: + self.readingEngine.set_voice(savedVoice) + + self.readingEngine.set_rate(speechRate) + self.config.set_reader_engine('speechd') else: # Use piper-tts (check if available first) if not TtsEngine.is_available(): @@ -324,20 +336,14 @@ class BookReader: print(f"[Paragraph {self.currentParagraph + 1}/{chapter.get_total_paragraphs()}]") print(f"\n{paragraph}\n") - # Show on Braille display - if self.brailleOutput.enabled: - self.brailleOutput.show_paragraph(paragraph) - - # Generate and play audio (unless muted for Braille-only mode) - if not self.brailleOutput.muteVoice: - try: - wavData = self.ttsEngine.text_to_wav_data(paragraph) - if wavData: - completed = self.audioPlayer.play_wav_data(wavData, blocking=True) - return completed - except Exception as e: - print(f"Error during playback: {e}") - return False + try: + wavData = self.ttsEngine.text_to_wav_data(paragraph) + if wavData: + completed = self.audioPlayer.play_wav_data(wavData, blocking=True) + return completed + except Exception as e: + print(f"Error during playback: {e}") + return False return True @@ -709,6 +715,33 @@ class BookReader: message = "Speech-dispatcher settings reloaded successfully" print(message) self.speechEngine.speak(message) + elif readerEngine == 'screenreader': + # Route reading through active screen reader via D-Bus + self.ttsEngine = None + self.readingEngine = ScreenReaderEngine() + speechRate = self.config.get_speech_rate() + self.readingEngine.set_rate(speechRate) + + if not self.readingEngine.is_available(): + message = "Warning: No supported screen reader detected. Falling back to speech-dispatcher." + print(message) + self.speechEngine.speak(message) + + self.readingEngine = SpeechEngine() + savedModule = self.config.get_speechd_output_module() + if savedModule: + self.readingEngine.set_output_module(savedModule) + + savedVoice = self.config.get_speechd_voice() + if savedVoice: + self.readingEngine.set_voice(savedVoice) + + self.readingEngine.set_rate(speechRate) + self.config.set_reader_engine('speechd') + else: + message = f"Screen reader engine active: {self.readingEngine.get_active_reader_name()}" + print(message) + self.speechEngine.speak(message) else: # Reload piper-tts with new voice (check if available first) if not TtsEngine.is_available(): @@ -772,7 +805,7 @@ class BookReader: # Determine book type for message isAudioBook = hasattr(self.book, 'isAudioBook') and self.book.isAudioBook bookType = "Audio book" if isAudioBook else "Book" - self.speechEngine.speak(f"{bookType} loaded, press h for help.") + self.speechEngine.speak(f"{bookType} loaded, press F1 or question mark for help.") else: self.displayText = "No book loaded" self.statusText = "Press A for Audiobookshelf, B for local books, R for recent books" @@ -870,11 +903,12 @@ class BookReader: self._handle_pygame_key(event) elif event.type == SPEECH_FINISHED_EVENT: - # Speech-dispatcher paragraph finished, advance to next + # Callback-driven paragraph finished, advance to next # Don't auto-advance if in any menu inAnyMenu = (self.optionsMenu.is_in_menu() or self.bookSelector.is_in_browser() or self.sleepTimerMenu.is_in_menu() or + self.helpMenu.is_in_menu() or self.recentBooksMenu.is_in_menu() or (self.absMenu and self.absMenu.is_in_menu())) @@ -887,12 +921,6 @@ class BookReader: # Start next paragraph self._start_paragraph_playback() - # Check for Braille display key presses (panning, routing, etc.) - if self.brailleOutput.enabled: - brailleKey = self.brailleOutput.read_key() - if brailleKey: - self._handle_braille_key(brailleKey) - # Explicitly delete event objects to help GC del events @@ -925,13 +953,14 @@ class BookReader: self.isPlaying = False # Check if we need to advance to next paragraph/chapter - # Speech-dispatcher uses callbacks for auto-advance + # Callback-driven engines use SPEECH_FINISHED_EVENT for auto-advance isAudioBook = self.book and hasattr(self.book, 'isAudioBook') and self.book.isAudioBook readerEngine = self.config.get_reader_engine() # Don't auto-advance if in any menu inAnyMenu = (self.optionsMenu.is_in_menu() or self.bookSelector.is_in_browser() or self.sleepTimerMenu.is_in_menu() or + self.helpMenu.is_in_menu() or self.recentBooksMenu.is_in_menu() or (self.absMenu and self.absMenu.is_in_menu())) @@ -1070,8 +1099,9 @@ class BookReader: # Stop playback readerEngine = self.config.get_reader_engine() - if readerEngine == 'speechd': - self.readingEngine.cancel_reading() + if readerEngine in ['speechd', 'screenreader']: + if self.readingEngine: + self.readingEngine.cancel_reading() else: # Stop audio player (handles both TTS and audio books) self.audioPlayer.stop() @@ -1081,7 +1111,7 @@ class BookReader: # Clean up speech engines if self.speechEngine: self.speechEngine.close() - if readerEngine == 'speechd' and self.readingEngine: + if readerEngine in ['speechd', 'screenreader'] and self.readingEngine: self.readingEngine.close() # Clean up audio player @@ -1095,45 +1125,6 @@ class BookReader: self.cachedSurfaces.clear() pygame.quit() - def _handle_braille_key(self, keyCode): - """ - Handle key presses from Braille display. - - Args: - keyCode: Key code from brltty - """ - # BrlAPI key codes (common ones) - # Forward panning: typically CMD_FWINRT or specific device codes - # Backward panning: typically CMD_FWINLT - # The exact codes depend on the device, so we check the command type - - try: - # Convert key code to command - # BrlAPI provides key codes as integers - # Common commands (from brltty documentation): - # FWINRT (forward) and FWINLT (backward) for panning - - # Check if it's a panning command - # The key format varies by device, but we can check ranges - # Typically: 0x04000000 range for panning commands - - # Try to parse as BrlAPI command - command = keyCode & 0x00FFFFFF # Extract command part - - # Common commands (approximate values, device-specific) - CMD_FWINRT = 0x0001 # Pan forward/right - CMD_FWINLT = 0x0002 # Pan backward/left - - if command == CMD_FWINRT or (keyCode & 0xFF) == 0x01: - # Forward panning - self.brailleOutput.pan_forward() - elif command == CMD_FWINLT or (keyCode & 0xFF) == 0x02: - # Backward panning - self.brailleOutput.pan_backward() - - except: - pass - def _handle_pygame_key(self, event): """Handle pygame key event""" # Check if in Audiobookshelf menu @@ -1161,6 +1152,11 @@ class BookReader: self._handle_sleep_timer_key(event) return + # Check if in help menu + if self.helpMenu.is_in_menu(): + self._handle_help_menu_key(event) + return + # Check if in options menu if self.optionsMenu.is_in_menu(): self._handle_menu_key(event) @@ -1201,8 +1197,8 @@ class BookReader: self.audioPlayer.pause_audio_file() if self.isStreaming and self.absSync: self.absSync.mark_progress_checkpoint(self._get_current_audio_position()) - elif readerEngine == 'speechd': - # Handle speech-dispatcher pause/resume + elif readerEngine in ['speechd', 'screenreader']: + # Handle callback-driven text reading pause/resume if self.readingEngine.is_reading_paused(): self.readingEngine.resume_reading() else: @@ -1293,7 +1289,7 @@ class BookReader: # Pause playback while saving wasPaused = False - if readerEngine == 'speechd': + if readerEngine in ['speechd', 'screenreader']: wasPaused = self.readingEngine.is_reading_paused() if not wasPaused and self.readingEngine.is_reading_active(): self.readingEngine.pause_reading() @@ -1310,7 +1306,7 @@ class BookReader: # Resume playback if not wasPaused and self.isPlaying: - if readerEngine == 'speechd': + if readerEngine in ['speechd', 'screenreader']: self.readingEngine.resume_reading() else: self.audioPlayer.resume() @@ -1323,7 +1319,7 @@ class BookReader: self.config.set_speech_rate(newRate) self.speechEngine.set_rate(newRate) # Apply to reading engine as well - if readerEngine == 'speechd': + if readerEngine in ['speechd', 'screenreader']: self.readingEngine.set_rate(newRate) # If currently reading, restart paragraph to apply new rate immediately if self.isPlaying and self.readingEngine.is_reading_active(): @@ -1338,7 +1334,7 @@ class BookReader: self.config.set_speech_rate(newRate) self.speechEngine.set_rate(newRate) # Apply to reading engine as well - if readerEngine == 'speechd': + if readerEngine in ['speechd', 'screenreader']: self.readingEngine.set_rate(newRate) # If currently reading, restart paragraph to apply new rate immediately if self.isPlaying and self.readingEngine.is_reading_active(): @@ -1372,18 +1368,12 @@ class BookReader: # Open options menu self.optionsMenu.enter_menu() - elif event.key == pygame.K_h: - # Help - if self.book and hasattr(self.book, 'isAudioBook') and self.book.isAudioBook: - # Audio book help - self.speechEngine.speak("SPACE: play pause. n: next chapter. p: previous chapter. LEFT RIGHT arrows: seek 5 seconds. UP DOWN arrows: seek 1 minute. SHIFT LEFT RIGHT: seek 30 seconds. SHIFT UP DOWN: seek 5 minutes. s: save bookmark. k: bookmarks menu. r: recent books. b: browse books. a: audiobookshelf. o: options menu. i: current info. Right bracket: increase playback speed. Left bracket: decrease playback speed. Backspace: reset playback speed. 0: increase volume. 9: decrease volume. SHIFT HOME: clear bookmark and jump to beginning. CONTROL: stop speech. t: time remaining. h: help. q: quit or sleep timer") - else: - # Text book help - self.speechEngine.speak("SPACE: play pause. n: next paragraph. p: previous paragraph. Shift N: next chapter. Shift P: previous chapter. LEFT RIGHT arrows: previous next paragraph. SHIFT LEFT RIGHT: previous next chapter. UP DOWN arrows: skip 5 paragraphs. SHIFT UP DOWN: first last paragraph. s: save bookmark. k: bookmarks menu. r: recent books. b: browse books. a: audiobookshelf. o: options menu. i: current info. Page Up Down: adjust speech rate. Right bracket: increase playback speed. Left bracket: decrease playback speed. Backspace: reset playback speed. SHIFT HOME: clear bookmark and jump to beginning. CONTROL: stop speech. t: time remaining. h: help. q: quit or sleep timer") + elif self._is_help_key(event): + self.helpMenu.enter_menu(self._get_help_lines()) elif event.key == pygame.K_i: if not self.book: - self.speechEngine.speak("No book loaded. Press H for help.") + self.speechEngine.speak("No book loaded. Press F1 or question mark for help.") return # Speak current position info @@ -1683,6 +1673,83 @@ class BookReader: else: self.speechEngine.speak("End of chapter") + def _is_help_key(self, event): + """Return True when the key event should open or close help.""" + if event.key == pygame.K_F1: + return True + return getattr(event, 'unicode', '') == '?' + + def _get_help_lines(self): + """Return help lines for the current reading mode.""" + isAudioBook = bool(self.book and hasattr(self.book, 'isAudioBook') and self.book.isAudioBook) + + if isAudioBook: + return [ + "Space: play or pause.", + "N: next chapter.", + "P: previous chapter.", + "Left or right arrow: seek 5 seconds.", + "Shift left or right arrow: seek 30 seconds.", + "Up or down arrow: seek 1 minute.", + "Shift up or down arrow: seek 5 minutes.", + "S: save bookmark.", + "K: bookmarks menu.", + "R: recent books.", + "B: browse local books.", + "A: Audiobookshelf browser.", + "O: options menu.", + "I: current info.", + "Right bracket: increase playback speed.", + "Left bracket: decrease playback speed.", + "Backspace: reset playback speed.", + "0: increase volume.", + "9: decrease volume.", + "Shift home: clear bookmark and restart at beginning.", + "Control: stop speech.", + "T: time remaining.", + "F1 or question mark: help.", + "Q or escape: quit or sleep timer." + ] + + return [ + "Space: play or pause.", + "N: next paragraph.", + "P: previous paragraph.", + "Shift N: next chapter.", + "Shift P: previous chapter.", + "Left or right arrow: previous or next paragraph.", + "Shift left or right arrow: previous or next chapter.", + "Up or down arrow: skip 5 paragraphs.", + "Shift up: first paragraph.", + "Shift down: last paragraph.", + "S: save bookmark.", + "K: bookmarks menu.", + "R: recent books.", + "B: browse local books.", + "A: Audiobookshelf browser.", + "O: options menu.", + "I: current info.", + "Page up or page down: adjust speech rate.", + "Right bracket: increase playback speed.", + "Left bracket: decrease playback speed.", + "Backspace: reset playback speed.", + "Shift home: clear bookmark and restart at beginning.", + "Control: stop speech.", + "T: time remaining.", + "F1 or question mark: help.", + "Q or escape: quit or sleep timer." + ] + + def _handle_help_menu_key(self, event): + """Handle key events when in help menu.""" + if event.key == pygame.K_UP: + self.helpMenu.navigate_menu('up') + elif event.key == pygame.K_DOWN: + self.helpMenu.navigate_menu('down') + elif event.key == pygame.K_ESCAPE or event.key == pygame.K_RETURN or self._is_help_key(event): + self.helpMenu.exit_menu() + self.speechEngine.speak("Help closed.") + def _handle_recent_books_key(self, event): """Handle key events when in recent books menu""" if event.key == pygame.K_UP: @@ -2511,7 +2578,7 @@ class BookReader: self.speechEngine.speak(message) def _stop_playback(self): - """Stop current playback (audio books, piper-tts, and speech-dispatcher)""" + """Stop current playback (audio books, piper-tts, and callback-driven engines).""" # Handle case where no book is loaded if not self.book: return @@ -2527,16 +2594,15 @@ class BookReader: self.audioPlayer.stop_audio_file() else: self.audioPlayer.stop() - elif readerEngine == 'speechd': - # Cancel speech-dispatcher reading + elif readerEngine in ['speechd', 'screenreader']: + # Cancel callback-driven text reading self.readingEngine.cancel_reading() def _restart_current_paragraph(self): """ - Restart current paragraph playback (for speech-dispatcher rate changes) + Restart current paragraph playback after speech-rate changes. - This is needed because speech-dispatcher only applies rate changes - to the next speech utterance, not to currently playing speech. + Callback-driven engines apply rate changes only to the next utterance. """ # Cancel current speech self.readingEngine.cancel_reading() @@ -2569,89 +2635,76 @@ class BookReader: # Use configured reader engine readerEngine = self.config.get_reader_engine() - if readerEngine == 'speechd': - # Use speech-dispatcher for reading with callback + if readerEngine in ['speechd', 'screenreader']: + # Use callback-driven reading engines def on_speech_finished(finishType): """ - Callback when speech-dispatcher finishes speaking. - Must not call speechd commands directly (causes deadlock). - Post pygame event instead. + Callback when a paragraph finishes speaking. + Post pygame event instead of mutating state in callback thread. """ if finishType == 'COMPLETED' and self.isPlaying: # Post pygame event to handle in main loop pygame.event.post(pygame.event.Event(SPEECH_FINISHED_EVENT)) - # Show on Braille display (unless muted for Braille-only mode) - if self.brailleOutput.enabled: - self.brailleOutput.show_paragraph(paragraph) - - # Speak (unless muted for Braille-only mode) - if not self.brailleOutput.muteVoice: - self.readingEngine.speak_reading(paragraph, callback=on_speech_finished) + self.readingEngine.speak_reading(paragraph, callback=on_speech_finished) else: # Use piper-tts for reading with buffering - # Show on Braille display - if self.brailleOutput.enabled: - self.brailleOutput.show_paragraph(paragraph) - - # Only generate/play audio if voice not muted - if not self.brailleOutput.muteVoice: - wavData = None - try: - # Check if we have buffered audio ready for THIS paragraph - with self.bufferLock: - if (self.bufferedAudio is not None and - self.bufferedChapter == self.currentChapter and - self.bufferedParagraph == self.currentParagraph): - # Use pre-generated audio (matches current position) - wavData = self.bufferedAudio + wavData = None + try: + # Check if we have buffered audio ready for THIS paragraph + with self.bufferLock: + if (self.bufferedAudio is not None and + self.bufferedChapter == self.currentChapter and + self.bufferedParagraph == self.currentParagraph): + # Use pre-generated audio (matches current position) + wavData = self.bufferedAudio + self.bufferedAudio = None + self.bufferedChapter = -1 + self.bufferedParagraph = -1 + else: + # Discard stale buffer if present (wrong paragraph) + if self.bufferedAudio is not None: + print(f"Discarding stale buffer (expected ch{self.currentChapter}p{self.currentParagraph}, got ch{self.bufferedChapter}p{self.bufferedParagraph})") + del self.bufferedAudio self.bufferedAudio = None self.bufferedChapter = -1 self.bufferedParagraph = -1 - else: - # Discard stale buffer if present (wrong paragraph) - if self.bufferedAudio is not None: - print(f"Discarding stale buffer (expected ch{self.currentChapter}p{self.currentParagraph}, got ch{self.bufferedChapter}p{self.bufferedParagraph})") - del self.bufferedAudio - self.bufferedAudio = None - self.bufferedChapter = -1 - self.bufferedParagraph = -1 - # Force GC to reclaim buffer memory immediately - gc.collect() - # Generate audio now (first paragraph or after navigation) - wavData = self.ttsEngine.text_to_wav_data(paragraph) + # Force GC to reclaim buffer memory immediately + gc.collect() + # Generate audio now (first paragraph or after navigation) + wavData = self.ttsEngine.text_to_wav_data(paragraph) - if wavData: - # Stop any existing audio file (playing, paused, or idle) - # This ensures temp files are cleaned up immediately - if self.audioPlayer.is_audio_file_loaded(): - self.audioPlayer.stop_audio_file() + if wavData: + # Stop any existing audio file (playing, paused, or idle) + # This ensures temp files are cleaned up immediately + if self.audioPlayer.is_audio_file_loaded(): + self.audioPlayer.stop_audio_file() - # Get current playback speed from config - playbackSpeed = self.config.get_playback_speed() + # Get current playback speed from config + playbackSpeed = self.config.get_playback_speed() - # Play WAV data through MpvPlayer (which supports pause/resume) - if self.audioPlayer.play_wav_data(wavData, playbackSpeed=playbackSpeed): - # Start buffering next paragraph in background - self._buffer_next_paragraph() - else: - print("Error: Failed to start TTS playback") - self.isPlaying = False - - # Explicitly delete wavData after playback starts to free memory - del wavData - wavData = None + # Play WAV data through MpvPlayer (which supports pause/resume) + if self.audioPlayer.play_wav_data(wavData, playbackSpeed=playbackSpeed): + # Start buffering next paragraph in background + self._buffer_next_paragraph() else: - print("Warning: No audio data generated") - except Exception as e: - print(f"Error during playback: {e}") - # Stop playback on error to prevent infinite error loop - self.isPlaying = False - raise - finally: - # The variable is already set to None in all relevant paths - pass + print("Error: Failed to start TTS playback") + self.isPlaying = False + + # Explicitly delete wavData after playback starts to free memory + del wavData + wavData = None + else: + print("Warning: No audio data generated") + except Exception as e: + print(f"Error during playback: {e}") + # Stop playback on error to prevent infinite error loop + self.isPlaying = False + raise + finally: + # The variable is already set to None in all relevant paths + pass def _start_audio_chapter_playback(self, chapter): """Start playing audio book chapter""" @@ -2856,9 +2909,6 @@ class BookReader: self.readingEngine.cleanup() if self.parser: self.parser.cleanup() - # Cleanup Braille display - if self.brailleOutput: - self.brailleOutput.close() def main(): diff --git a/requirements.txt b/requirements.txt index e51fb62..dd0b3a6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,14 +8,8 @@ mpv requests>=2.25.0 setproctitle>=1.3.0 -# Braille display support (optional) -# Note: These are system packages, not pip packages -# Install with: sudo pacman -S brltty liblouis python-brlapi python-louis (Arch) -# or: sudo apt install brltty liblouis-bin python3-brlapi python3-louis (Debian/Ubuntu) - # Optional dependencies # piper-tts: Install separately with voice models to /usr/share/piper-voices/ # ffmpeg: Install via system package manager for M4B/M4A support # calibre: Install via system package manager for MOBI support (provides ebook-convert) -# brltty: System daemon for Braille display hardware -# liblouis: Braille translation library +# dasbus: Optional screen reader integration (Orca/Cthulhu over D-Bus) diff --git a/src/braille_menu.py b/src/braille_menu.py deleted file mode 100644 index 7d4cce2..0000000 --- a/src/braille_menu.py +++ /dev/null @@ -1,285 +0,0 @@ -""" -Braille settings menu for BookStorm. - -Provides a submenu for configuring Braille display settings including: -- Enable/disable Braille output -- Translation table selection (Grade 1, Grade 2, UEB) -- Sync mode (sync with TTS or manual) -- Status message display toggle -""" - -import pygame -import logging - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - - -class BrailleMenu: - """Interactive menu for Braille display settings.""" - - def __init__(self, screen, speechEngine, brailleOutput, configManager): - """ - Initialize Braille menu. - - Args: - screen: Pygame display surface - speechEngine: Speech engine for menu feedback - brailleOutput: BrailleOutput instance to configure - configManager: ConfigManager for saving settings - """ - self.screen = screen - self.speechEngine = speechEngine - self.brailleOutput = brailleOutput - self.configManager = configManager - self.menuItems = [] - self.currentIndex = 0 - self.running = False - - # Build menu items - self._build_menu() - - def _build_menu(self): - """Build menu items based on current settings.""" - self.menuItems = [ - { - "label": f"Braille Output: {'Enabled' if self.brailleOutput.enabled else 'Disabled'}", - "action": self._toggle_enabled - }, - { - "label": f"Translation Table: {self._get_table_display_name()}", - "action": self._cycle_table - }, - { - "label": f"Sync with TTS: {'Yes' if self.brailleOutput.syncWithTts else 'No'}", - "action": self._toggle_sync - }, - { - "label": f"Show Status Messages: {'Yes' if self.brailleOutput.showStatus else 'No'}", - "action": self._toggle_status - }, - { - "label": f"Mute Voice (Braille Only): {'Yes' if self.brailleOutput.muteVoice else 'No'}", - "action": self._toggle_mute_voice - }, - { - "label": "Back to Options", - "action": self._exit_menu - } - ] - - def _get_table_display_name(self): - """Get display name for current translation table.""" - tableNames = { - "grade1": "Grade 1", - "grade2": "Grade 2", - "ueb": "UEB" - } - return tableNames.get(self.brailleOutput.translationTable, "Unknown") - - def _toggle_enabled(self): - """Toggle Braille output enabled/disabled.""" - # Note: Toggling requires re-initialization - newState = not self.brailleOutput.enabled - - if newState and not self.brailleOutput.brl: - # Try to initialize - self.brailleOutput.enabled = True - self.brailleOutput._initialize_braille() - - if not self.brailleOutput.brl: - self.speechEngine.speak("Braille initialization failed. Check brltty daemon.") - self.brailleOutput.enabled = False - else: - self.speechEngine.speak("Braille output enabled") - elif not newState: - # Disable - self.brailleOutput.close() - self.speechEngine.speak("Braille output disabled") - - # Save to config - save ALL settings when enabling to ensure config is complete - self.configManager.set("Braille", "enabled", str(self.brailleOutput.enabled)) - if self.brailleOutput.enabled: - # Save all settings to ensure config is complete - self.configManager.set("Braille", "translation_table", self.brailleOutput.translationTable) - self.configManager.set("Braille", "sync_with_tts", str(self.brailleOutput.syncWithTts)) - self.configManager.set("Braille", "show_status", str(self.brailleOutput.showStatus)) - self.configManager.set("Braille", "mute_voice", str(self.brailleOutput.muteVoice)) - self.configManager.save_settings() - - # Rebuild menu to update labels - self._build_menu() - - def _cycle_table(self): - """Cycle through translation tables.""" - tables = ["grade1", "grade2", "ueb"] - currentIdx = tables.index(self.brailleOutput.translationTable) - nextIdx = (currentIdx + 1) % len(tables) - newTable = tables[nextIdx] - - self.brailleOutput.set_table(newTable) - self.speechEngine.speak(f"Translation table: {self._get_table_display_name()}") - - # Save to config - self.configManager.set("Braille", "translation_table", newTable) - self.configManager.save_settings() - - # Rebuild menu - self._build_menu() - - def _toggle_sync(self): - """Toggle sync with TTS mode.""" - newSync = not self.brailleOutput.syncWithTts - self.brailleOutput.set_sync_mode(newSync) - - self.speechEngine.speak(f"Sync with TTS: {'enabled' if newSync else 'disabled'}") - - # Save to config - self.configManager.set("Braille", "sync_with_tts", str(newSync)) - self.configManager.save_settings() - - # Rebuild menu - self._build_menu() - - def _toggle_status(self): - """Toggle status message display.""" - newStatus = not self.brailleOutput.showStatus - self.brailleOutput.set_show_status(newStatus) - - self.speechEngine.speak(f"Status messages: {'enabled' if newStatus else 'disabled'}") - - # Save to config - self.configManager.set("Braille", "show_status", str(newStatus)) - self.configManager.save_settings() - - # Rebuild menu - self._build_menu() - - def _toggle_mute_voice(self): - """Toggle mute voice (Braille-only mode).""" - newMute = not self.brailleOutput.muteVoice - self.brailleOutput.set_mute_voice(newMute) - - self.speechEngine.speak(f"Braille-only mode: {'enabled' if newMute else 'disabled'}") - - # Save to config - self.configManager.set("Braille", "mute_voice", str(newMute)) - self.configManager.save_settings() - - # Rebuild menu - self._build_menu() - - def _exit_menu(self): - """Exit the Braille settings menu.""" - self.running = False - - def show(self): - """Display and run the Braille settings menu.""" - self.running = True - self.currentIndex = 0 - - # Announce menu - self.speechEngine.speak("Braille Settings") - - # Show first item - if self.menuItems: - self._speak_current_item() - - # Event loop - clock = pygame.time.Clock() - while self.running: - for event in pygame.event.get(): - if event.type == pygame.QUIT: - self.running = False - return False # Signal app should quit - - elif event.type == pygame.KEYDOWN: - if event.key == pygame.K_ESCAPE: - self._exit_menu() - - elif event.key == pygame.K_UP: - self._navigate_up() - - elif event.key == pygame.K_DOWN: - self._navigate_down() - - elif event.key == pygame.K_RETURN or event.key == pygame.K_SPACE: - self._activate_current() - - # Render menu - self._render() - clock.tick(30) - - return True # Menu exited normally - - def _navigate_up(self): - """Navigate to previous menu item.""" - if self.menuItems: - self.currentIndex = (self.currentIndex - 1) % len(self.menuItems) - self._speak_current_item() - - def _navigate_down(self): - """Navigate to next menu item.""" - if self.menuItems: - self.currentIndex = (self.currentIndex + 1) % len(self.menuItems) - self._speak_current_item() - - def _activate_current(self): - """Activate the current menu item.""" - if self.menuItems and 0 <= self.currentIndex < len(self.menuItems): - item = self.menuItems[self.currentIndex] - if "action" in item: - item["action"]() - - def _speak_current_item(self): - """Speak and display the current menu item.""" - if self.menuItems and 0 <= self.currentIndex < len(self.menuItems): - item = self.menuItems[self.currentIndex] - self.speechEngine.speak(item["label"]) - - # Show on Braille display - if self.brailleOutput.enabled: - self.brailleOutput.show_menu_item( - item["label"], - self.currentIndex + 1, - len(self.menuItems) - ) - - def _render(self): - """Render the menu on screen.""" - # Clear screen - self.screen.fill((0, 0, 0)) - - # Get screen dimensions - width, height = self.screen.get_size() - - # Font setup - try: - menuFont = pygame.font.Font(None, 36) - titleFont = pygame.font.Font(None, 48) - except: - menuFont = pygame.font.SysFont('dejavu', 36) - titleFont = pygame.font.SysFont('dejavu', 48) - - # Draw title - titleSurface = titleFont.render("Braille Settings", True, (255, 255, 255)) - titleRect = titleSurface.get_rect(center=(width // 2, 50)) - self.screen.blit(titleSurface, titleRect) - - # Draw menu items - yOffset = 150 - for i, item in enumerate(self.menuItems): - color = (255, 255, 0) if i == self.currentIndex else (255, 255, 255) - itemSurface = menuFont.render(item["label"], True, color) - itemRect = itemSurface.get_rect(center=(width // 2, yOffset)) - self.screen.blit(itemSurface, itemRect) - yOffset += 80 - - # Draw instructions - instructionsFont = pygame.font.Font(None, 24) - instructions = "UP/DOWN: Navigate | ENTER: Select | ESC: Back" - instrSurface = instructionsFont.render(instructions, True, (128, 128, 128)) - instrRect = instrSurface.get_rect(center=(width // 2, height - 30)) - self.screen.blit(instrSurface, instrRect) - - pygame.display.flip() diff --git a/src/braille_output.py b/src/braille_output.py deleted file mode 100644 index d399f0c..0000000 --- a/src/braille_output.py +++ /dev/null @@ -1,352 +0,0 @@ -""" -Braille display output using brltty and liblouis. - -This module provides Braille display support for BookStorm, allowing users -to read book content on refreshable Braille displays. It uses: -- brltty (via BrlAPI): Hardware communication layer -- liblouis: Text-to-Braille translation - -Supports Grade 1, Grade 2, and UEB Braille tables. -""" - -import logging - -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) - - -class BrailleOutput: - """Manages Braille display output via brltty and liblouis.""" - - # Translation table mappings - TABLES = { - "grade1": "en-us-g1.ctb", - "grade2": "en-us-g2.ctb", - "ueb": "en-ueb-g2.ctb" - } - - def __init__(self, enabled=False, translationTable="grade2", syncWithTts=True, showStatus=True, muteVoice=False): - """ - Initialize Braille output. - - Args: - enabled: Whether Braille output is enabled - translationTable: Translation table name (grade1/grade2/ueb) - syncWithTts: Whether to sync Braille with TTS playback - showStatus: Whether to show status messages on Braille display - muteVoice: Whether to mute TTS when Braille is active (Braille-only mode) - """ - self.enabled = enabled - self.translationTable = translationTable - self.syncWithTts = syncWithTts - self.showStatus = showStatus - self.muteVoice = muteVoice - self.brl = None - self.displaySize = (0, 0) - self.currentText = "" - self.panOffset = 0 - - # Import libraries only if enabled - if self.enabled: - self._initialize_braille() - - def _initialize_braille(self): - """Initialize brltty connection and liblouis.""" - try: - import brlapi - import louis - self.brlapi = brlapi - self.louis = louis - - print("DEBUG: Attempting to connect to brltty...") - logger.info("Attempting to connect to brltty daemon") - - # Connect to brltty daemon - self.brl = brlapi.Connection() - self.displaySize = self.brl.displaySize - - print(f"DEBUG: Braille display connected! Size: {self.displaySize[0]} x {self.displaySize[1]} cells") - print(f"DEBUG: Driver: {self.brl.driverName}") - logger.info(f"Braille display connected: {self.displaySize[0]} x {self.displaySize[1]} cells") - - # Enter TTY mode - this takes exclusive control from screen reader - print("DEBUG: Entering TTY mode (taking control from screen reader)...") - self.brl.enterTtyMode() - print("DEBUG: TTY mode entered successfully") - logger.info("Entered TTY mode - have exclusive control of Braille display") - - # Verify translation table exists - tablePath = self.TABLES.get(self.translationTable, self.TABLES["grade2"]) - try: - # Test translation - testResult = self.louis.translateString([tablePath], "test") - print(f"DEBUG: Braille translation working. Table: {tablePath}") - logger.info(f"Using Braille table: {tablePath}") - except Exception as e: - print(f"DEBUG: Translation table error: {e}, falling back to grade2") - logger.warning(f"Translation table error: {e}, falling back to grade2") - self.translationTable = "grade2" - - except ImportError as e: - print(f"DEBUG: ImportError - BrlAPI or liblouis not installed: {e}") - logger.error("BrlAPI or liblouis not installed. Install python3-brlapi and python3-louis") - self.enabled = False - self.brl = None - except Exception as e: - print(f"DEBUG: Could not connect to brltty: {e}") - print("DEBUG: Is brltty daemon running? Check with: systemctl status brltty") - logger.warning(f"Could not connect to brltty: {e}") - self.enabled = False - self.brl = None - - def show_text(self, text, cursorPos=0): - """ - Display text on Braille display. - - Args: - text: Text to display (will be translated to Braille) - cursorPos: Cursor position (0-based) - """ - if not self.enabled or not self.brl: - return - - try: - # Store current text for panning - self.currentText = text - self.panOffset = 0 - - # Translate to Braille - tablePath = self.TABLES.get(self.translationTable, self.TABLES["grade2"]) - brailleText = self.louis.translateString([tablePath], text) - - # Handle text longer than display width - displayWidth = self.displaySize[0] - if len(brailleText) > displayWidth: - # Show first portion - brailleText = brailleText[:displayWidth] - - # Send to display - self.brl.writeText(brailleText, cursorPos) - - except Exception as e: - logger.error(f"Error displaying Braille text: {e}") - - def show_paragraph(self, paragraph, offset=0): - """ - Display a paragraph with panning support. - - Args: - paragraph: Full paragraph text - offset: Character offset for panning - """ - if not self.enabled: - print("DEBUG: Braille output disabled, skipping") - return - - if not self.brl: - print("DEBUG: No Braille connection, skipping") - return - - try: - # Store for manual panning - self.currentText = paragraph - self.panOffset = offset - - print(f"DEBUG: Translating paragraph (length {len(paragraph)}) to Braille...") - - # Translate entire paragraph - tablePath = self.TABLES.get(self.translationTable, self.TABLES["grade2"]) - brailleText = self.louis.translateString([tablePath], paragraph) - - print(f"DEBUG: Braille text length: {len(brailleText)}, Display width: {self.displaySize[0]}") - - # Extract window based on offset - displayWidth = self.displaySize[0] - window = brailleText[offset:offset + displayWidth] - - # Pad if necessary - if len(window) < displayWidth: - window = window + " " * (displayWidth - len(window)) - - print(f"DEBUG: Sending {len(window)} cells to Braille display...") - self.brl.writeText(window) - print("DEBUG: Braille text sent successfully") - - except Exception as e: - print(f"DEBUG: Error displaying paragraph: {e}") - logger.error(f"Error displaying paragraph: {e}") - - def show_status(self, message): - """ - Display a status message on Braille display. - - Args: - message: Status message to display - """ - if not self.enabled or not self.brl or not self.showStatus: - return - - try: - # Translate and display - tablePath = self.TABLES.get(self.translationTable, self.TABLES["grade2"]) - brailleText = self.louis.translateString([tablePath], message) - - # Truncate if too long - displayWidth = self.displaySize[0] - if len(brailleText) > displayWidth: - brailleText = brailleText[:displayWidth] - - self.brl.writeText(brailleText) - - except Exception as e: - logger.error(f"Error displaying status: {e}") - - def show_menu_item(self, itemText, position=None, total=None): - """ - Display a menu item on Braille display. - - Args: - itemText: Menu item text - position: Current position (1-based), optional - total: Total items, optional - """ - if not self.enabled or not self.brl: - return - - try: - # Format with position if provided - if position is not None and total is not None: - displayText = f"{position}/{total}: {itemText}" - else: - displayText = itemText - - self.show_text(displayText) - - except Exception as e: - logger.error(f"Error displaying menu item: {e}") - - def pan_forward(self): - """Pan Braille display forward (right).""" - if not self.enabled or not self.brl or not self.currentText: - return - - # Translate full text - tablePath = self.TABLES.get(self.translationTable, self.TABLES["grade2"]) - brailleText = self.louis.translateString([tablePath], self.currentText) - - displayWidth = self.displaySize[0] - maxOffset = max(0, len(brailleText) - displayWidth) - - # Advance by display width - self.panOffset = min(self.panOffset + displayWidth, maxOffset) - - # Show new window - self.show_paragraph(self.currentText, self.panOffset) - - def pan_backward(self): - """Pan Braille display backward (left).""" - if not self.enabled or not self.brl or not self.currentText: - return - - displayWidth = self.displaySize[0] - - # Go back by display width - self.panOffset = max(0, self.panOffset - displayWidth) - - # Show new window - self.show_paragraph(self.currentText, self.panOffset) - - def read_key(self, timeout=0): - """ - Read a key press from the Braille display. - - Args: - timeout: Timeout in milliseconds (0 = non-blocking) - - Returns: - Key code or None if no key pressed - """ - if not self.enabled or not self.brl: - return None - - try: - # Read key with timeout (non-blocking if timeout=0) - keyCode = self.brl.readKey(wait=False) - return keyCode - except Exception as e: - # No key pressed or error - return None - - def clear(self): - """Clear the Braille display.""" - if not self.enabled or not self.brl: - return - - try: - self.brl.writeText("") - except Exception as e: - logger.error(f"Error clearing display: {e}") - - def set_table(self, tableName): - """ - Change the Braille translation table. - - Args: - tableName: Table name (grade1/grade2/ueb) - """ - if tableName in self.TABLES: - self.translationTable = tableName - logger.info(f"Switched to Braille table: {tableName}") - else: - logger.warning(f"Unknown table: {tableName}, keeping {self.translationTable}") - - def set_sync_mode(self, syncWithTts): - """ - Set sync mode. - - Args: - syncWithTts: True to sync with TTS, False for manual only - """ - self.syncWithTts = syncWithTts - logger.info(f"Braille sync mode: {'TTS sync' if syncWithTts else 'manual'}") - - def set_show_status(self, showStatus): - """ - Set whether to show status messages. - - Args: - showStatus: True to show status messages - """ - self.showStatus = showStatus - logger.info(f"Braille status messages: {'enabled' if showStatus else 'disabled'}") - - def set_mute_voice(self, muteVoice): - """ - Set whether to mute TTS when Braille is active. - - Args: - muteVoice: True to mute TTS (Braille-only mode) - """ - self.muteVoice = muteVoice - logger.info(f"Braille-only mode: {'enabled' if muteVoice else 'disabled (dual output)'}") - - def close(self): - """Close brltty connection and release display.""" - if self.brl: - try: - print("DEBUG: Leaving TTY mode and closing Braille connection...") - # Leave TTY mode first to release control back to screen reader - self.brl.leaveTtyMode() - self.brl.closeConnection() - print("DEBUG: Braille display connection closed") - logger.info("Braille display connection closed") - except Exception as e: - print(f"DEBUG: Error closing Braille connection: {e}") - logger.error(f"Error closing Braille connection: {e}") - finally: - self.brl = None - self.enabled = False - - def __del__(self): - """Ensure connection is closed on cleanup.""" - self.close() diff --git a/src/config_manager.py b/src/config_manager.py index ea47989..8abe5d8 100644 --- a/src/config_manager.py +++ b/src/config_manager.py @@ -77,14 +77,6 @@ class ConfigManager: 'stream_cache_limit': '500' } - self.config['Braille'] = { - 'enabled': 'false', - 'translation_table': 'grade2', - 'sync_with_tts': 'true', - 'show_status': 'true', - 'mute_voice': 'false' - } - self.save() def get(self, section, key, fallback=None): @@ -186,12 +178,12 @@ class ConfigManager: return self.get_bool('Reading', 'auto_save_bookmark', True) def get_reader_engine(self): - """Get reader engine (piper or speechd)""" + """Get reader engine (piper, speechd, or screenreader)""" return self.get('TTS', 'reader_engine', 'piper') def set_reader_engine(self, engine): - """Set reader engine (piper or speechd)""" - if engine in ['piper', 'speechd']: + """Set reader engine (piper, speechd, or screenreader)""" + if engine in ['piper', 'speechd', 'screenreader']: self.set('TTS', 'reader_engine', engine) self.save() @@ -352,54 +344,6 @@ class ConfigManager: self.set('Audio', 'volume', str(volume)) self.save() - # Braille settings - - def get_braille_enabled(self): - """Get Braille output enabled setting""" - return self.get_bool('Braille', 'enabled', False) - - def set_braille_enabled(self, enabled): - """Set Braille output enabled setting""" - self.set('Braille', 'enabled', str(enabled).lower()) - self.save() - - def get_braille_translation_table(self): - """Get Braille translation table (grade1/grade2/ueb)""" - return self.get('Braille', 'translation_table', 'grade2') - - def set_braille_translation_table(self, table): - """Set Braille translation table""" - if table in ['grade1', 'grade2', 'ueb']: - self.set('Braille', 'translation_table', table) - self.save() - - def get_braille_sync_with_tts(self): - """Get Braille sync with TTS setting""" - return self.get_bool('Braille', 'sync_with_tts', True) - - def set_braille_sync_with_tts(self, enabled): - """Set Braille sync with TTS setting""" - self.set('Braille', 'sync_with_tts', str(enabled).lower()) - self.save() - - def get_braille_show_status(self): - """Get Braille show status messages setting""" - return self.get_bool('Braille', 'show_status', True) - - def set_braille_show_status(self, enabled): - """Set Braille show status messages setting""" - self.set('Braille', 'show_status', str(enabled).lower()) - self.save() - - def get_braille_mute_voice(self): - """Get Braille mute voice (Braille-only mode) setting""" - return self.get_bool('Braille', 'mute_voice', False) - - def set_braille_mute_voice(self, enabled): - """Set Braille mute voice setting""" - self.set('Braille', 'mute_voice', str(enabled).lower()) - self.save() - def save_settings(self): """Alias for save() - for backward compatibility""" self.save() diff --git a/src/help_menu.py b/src/help_menu.py new file mode 100644 index 0000000..0d278d8 --- /dev/null +++ b/src/help_menu.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Help Menu + +Provides navigable help text with line-by-line speech output. +""" + + +class HelpMenu: + """Interactive help menu.""" + + def __init__(self, speechEngine): + """ + Initialize help menu. + + Args: + speechEngine: SpeechEngine instance for UI feedback + """ + self.speechEngine = speechEngine + self.inMenu = False + self.helpLines = [] + self.currentIndex = -1 + + def enter_menu(self, helpLines): + """ + Open help menu with provided lines. + + Args: + helpLines: List of help lines to navigate + """ + self.helpLines = helpLines if isinstance(helpLines, list) else [] + self.currentIndex = -1 + self.inMenu = True + + if not self.helpLines: + self.speechEngine.speak("Help opened. No help text available.") + return + + self.speechEngine.speak("Help opened. Press up or down arrows to navigate.") + + def exit_menu(self): + """Close help menu.""" + self.inMenu = False + + def is_in_menu(self): + """Return True when help menu is active.""" + return self.inMenu + + def navigate_menu(self, direction): + """ + Navigate help text and speak one line. + + Args: + direction: 'up' or 'down' + """ + if not self.helpLines: + self.speechEngine.speak("No help text available.") + return + + if direction == 'down': + nextIndex = self.currentIndex + 1 + if nextIndex >= len(self.helpLines): + self.speechEngine.speak("End of help.") + return + + self.currentIndex = nextIndex + self.speechEngine.speak(self.helpLines[self.currentIndex]) + return + + if direction == 'up': + if self.currentIndex <= 0: + self.speechEngine.speak("Beginning of help.") + return + + self.currentIndex -= 1 + self.speechEngine.speak(self.helpLines[self.currentIndex]) diff --git a/src/options_menu.py b/src/options_menu.py index c9c6fc8..1e6b189 100644 --- a/src/options_menu.py +++ b/src/options_menu.py @@ -14,7 +14,7 @@ from src.tts_engine import TtsEngine class OptionsMenu: """Options menu for configuring BookStorm settings""" - def __init__(self, config, speechEngine, voiceSelector, audioPlayer, ttsReloadCallback=None, brailleOutput=None): + def __init__(self, config, speechEngine, voiceSelector, audioPlayer, ttsReloadCallback=None): """ Initialize options menu @@ -24,14 +24,12 @@ class OptionsMenu: voiceSelector: VoiceSelector instance audioPlayer: MpvPlayer instance ttsReloadCallback: Optional callback to reload TTS engine - brailleOutput: Optional BrailleOutput instance """ self.config = config self.speechEngine = speechEngine self.voiceSelector = voiceSelector self.audioPlayer = audioPlayer self.ttsReloadCallback = ttsReloadCallback - self.brailleOutput = brailleOutput self.currentSelection = 0 self.inMenu = False @@ -43,7 +41,12 @@ class OptionsMenu: Menu items as list of dicts """ readerEngine = self.config.get_reader_engine() - readerEngineText = "Piper-TTS" if readerEngine == "piper" else "Speech-Dispatcher" + engineLabels = { + 'piper': 'Piper-TTS', + 'speechd': 'Speech-Dispatcher', + 'screenreader': 'Screen Reader' + } + readerEngineText = engineLabels.get(readerEngine, 'Piper-TTS') menuItems = [ { @@ -80,12 +83,6 @@ class OptionsMenu: 'label': "Speech Rate Settings", 'action': 'speech_rate' }, - # TEMPORARILY DISABLED: Braille support disabled until BrlTTY issues resolved - # To re-enable: Uncomment the following block - # { - # 'label': "Braille Settings", - # 'action': 'braille_settings' - # }, { 'label': absLabel, 'action': 'audiobookshelf_setup' @@ -140,8 +137,6 @@ class OptionsMenu: return self._toggle_show_text() elif action == 'speech_rate': return self._speech_rate_info() - elif action == 'braille_settings': - return self._braille_settings() elif action == 'audiobookshelf_setup': return self._audiobookshelf_setup() elif action == 'back': @@ -151,19 +146,23 @@ class OptionsMenu: return True def _toggle_reader_engine(self): - """Toggle between piper-tts and speech-dispatcher""" + """Cycle reader engine: piper-tts, speech-dispatcher, screen reader.""" currentEngine = self.config.get_reader_engine() + engineOrder = ['piper', 'speechd', 'screenreader'] + engineLabels = { + 'piper': 'Piper-TTS', + 'speechd': 'Speech-Dispatcher', + 'screenreader': 'Screen Reader' + } - if currentEngine == 'piper': - newEngine = 'speechd' - oldEngine = 'piper' - self.config.set_reader_engine('speechd') - message = "Reader engine: Speech-Dispatcher." - else: - newEngine = 'piper' - oldEngine = 'speechd' - self.config.set_reader_engine('piper') - message = "Reader engine: Piper-TTS." + if currentEngine not in engineOrder: + currentEngine = 'piper' + + oldEngine = currentEngine + currentIndex = engineOrder.index(currentEngine) + newEngine = engineOrder[(currentIndex + 1) % len(engineOrder)] + self.config.set_reader_engine(newEngine) + message = f"Reader engine: {engineLabels.get(newEngine, 'Piper-TTS')}." # Reload TTS engine if callback available needsRestart = False @@ -187,6 +186,9 @@ class OptionsMenu: # Speak first option self.speechEngine.speak("Restart now") else: + finalEngine = self.config.get_reader_engine() + if finalEngine in engineLabels: + message = f"Reader engine: {engineLabels[finalEngine]}." self.speechEngine.speak(message) print(message) @@ -198,9 +200,12 @@ class OptionsMenu: if readerEngine == 'piper': return self._select_piper_voice() - else: + if readerEngine == 'speechd': return self._select_speechd_voice() + self.speechEngine.speak("Voice selection is managed by your active screen reader.") + return True + def _select_piper_voice(self): """Select piper-tts voice""" self.speechEngine.speak("Selecting piper voice. Use arrow keys to browse, enter to select, escape to cancel.") @@ -503,40 +508,6 @@ class OptionsMenu: if menuItems and self.currentSelection < len(menuItems): self.speechEngine.speak(menuItems[self.currentSelection]['label']) - def _braille_settings(self): - """Open Braille settings submenu""" - if not self.brailleOutput: - self.speechEngine.speak("Braille output not initialized") - return True - - # Import here to avoid circular dependency - from src.braille_menu import BrailleMenu - import pygame - - # Create Braille menu - # Get pygame screen if available - screen = pygame.display.get_surface() - if not screen: - # Create minimal display for menu - screen = pygame.display.set_mode((1600, 900)) - - brailleMenu = BrailleMenu(screen, self.speechEngine, self.brailleOutput, self.config) - - # Show Braille menu (blocks until user exits) - continueRunning = brailleMenu.show() - - # Return to options menu - if continueRunning: - self.speechEngine.speak("Back to options menu") - # Speak current menu item - menuItems = self.show_main_menu() - if menuItems and self.currentSelection < len(menuItems): - self.speechEngine.speak(menuItems[self.currentSelection]['label']) - return True - else: - # User closed the application - return False - def _audiobookshelf_setup(self): """Setup Audiobookshelf server connection""" from src.ui import get_input diff --git a/src/screen_reader_engine.py b/src/screen_reader_engine.py new file mode 100644 index 0000000..796e275 --- /dev/null +++ b/src/screen_reader_engine.py @@ -0,0 +1,478 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Screen Reader Engine + +Routes reading output through an active screen reader (Orca or Cthulhu) +using python-dasbus. +""" + +import os +import subprocess +import threading +import time + +try: + from dasbus.connection import SessionMessageBus + HAS_DASBUS = True +except ImportError: + HAS_DASBUS = False + +from .text_validator import is_valid_text + + +class ScreenReaderRemoteController: + """D-Bus helper for a single screen reader service.""" + + def __init__(self, serviceName, mainPath, processName, displayName): + self.serviceName = serviceName + self.mainPath = mainPath + self.processName = processName + self.displayName = displayName + self.proxy = None + self.speechProxy = None + self.available = self._test_availability() + + def _call_with_timeout(self, func, timeoutSeconds=2): + """Run a call with timeout to avoid blocking the app.""" + result = [None] + exception = [None] + + def wrapper(): + try: + result[0] = func() + except Exception as error: + exception[0] = error + + workerThread = threading.Thread(target=wrapper, daemon=True) + workerThread.start() + workerThread.join(timeout=timeoutSeconds) + + if workerThread.is_alive(): + return None + if exception[0]: + raise exception[0] + return result[0] + + def _get_process_bus_address(self): + """Read DBUS_SESSION_BUS_ADDRESS from the target process environment.""" + try: + result = subprocess.run( + ["pgrep", "-x", self.processName], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=1 + ) + if result.returncode != 0: + return None + + processIds = [pid.strip() for pid in result.stdout.splitlines() if pid.strip()] + for processId in processIds: + try: + with open(f"/proc/{processId}/environ", "rb") as envFile: + rawData = envFile.read() + decodedData = rawData.decode("utf-8", errors="ignore") + for environmentVariable in decodedData.split("\0"): + if environmentVariable.startswith("DBUS_SESSION_BUS_ADDRESS="): + return environmentVariable.split("=", 1)[1] + except Exception: + continue + except Exception: + pass + + return None + + def _test_availability(self): + """Check if the remote D-Bus interface is reachable.""" + if not HAS_DASBUS: + return False + + def test_connection(): + try: + bus = SessionMessageBus() + self.proxy = bus.get_proxy(self.serviceName, self.mainPath) + self.proxy.ListCommands() + self.speechProxy = bus.get_proxy( + self.serviceName, + f"{self.mainPath}/SpeechAndVerbosityManager" + ) + return True + except Exception: + busAddress = self._get_process_bus_address() + if not busAddress: + return False + + oldAddress = os.environ.get("DBUS_SESSION_BUS_ADDRESS") + try: + os.environ["DBUS_SESSION_BUS_ADDRESS"] = busAddress + bus = SessionMessageBus() + self.proxy = bus.get_proxy(self.serviceName, self.mainPath) + self.proxy.ListCommands() + self.speechProxy = bus.get_proxy( + self.serviceName, + f"{self.mainPath}/SpeechAndVerbosityManager" + ) + return True + finally: + if oldAddress is not None: + os.environ["DBUS_SESSION_BUS_ADDRESS"] = oldAddress + elif "DBUS_SESSION_BUS_ADDRESS" in os.environ: + del os.environ["DBUS_SESSION_BUS_ADDRESS"] + + try: + return bool(self._call_with_timeout(test_connection, timeoutSeconds=2)) + except Exception: + return False + + def present_message(self, message): + """Send a message to the screen reader for speech/braille presentation.""" + if not self.available or not self.proxy: + return False + + try: + result = self._call_with_timeout(lambda: self.proxy.PresentMessage(str(message)), timeoutSeconds=2) + if result is None: + return False + return bool(result) if isinstance(result, bool) else True + except Exception: + return False + + def interrupt_speech(self): + """Interrupt current speech output.""" + if not self.available: + return False + + try: + if self.speechProxy: + result = self._call_with_timeout( + lambda: self.speechProxy.ExecuteCommand("InterruptSpeech", False), + timeoutSeconds=2 + ) + if result is None: + return False + return bool(result) if isinstance(result, bool) else True + + if self.proxy: + result = self._call_with_timeout(lambda: self.proxy.ExecuteCommand("InterruptSpeech", False), timeoutSeconds=2) + if result is None: + return False + return bool(result) if isinstance(result, bool) else True + except Exception: + pass + + return False + + +class ScreenReaderEngine: + """Book reading engine that speaks via active screen reader D-Bus APIs.""" + + def __init__(self): + self.speechLock = threading.Lock() + self.isAvailable = False + self.activeController = None + self.availableControllers = [] + + self.isReading = False + self.isPausedReading = False + self.readingCallback = None + self.currentText = "" + self.speechRate = 0 + self.readingGeneration = 0 + + self._initialize_controllers() + + def _initialize_controllers(self): + """Discover available screen reader remote controllers.""" + if not HAS_DASBUS: + print("Warning: python-dasbus not installed. Screen reader engine unavailable.") + return + + controllerCandidates = [ + ScreenReaderRemoteController( + serviceName="org.gnome.Orca.Service", + mainPath="/org/gnome/Orca/Service", + processName="orca", + displayName="Orca" + ), + ScreenReaderRemoteController( + serviceName="org.stormux.Cthulhu.Service", + mainPath="/org/stormux/Cthulhu/Service", + processName="cthulhu", + displayName="Cthulhu" + ) + ] + + self.availableControllers = [controller for controller in controllerCandidates if controller.available] + self.activeController = self._select_active_controller() + self.isAvailable = self.activeController is not None + + if self.activeController: + print(f"Screen reader engine connected to {self.activeController.displayName}.") + else: + print("Warning: No supported screen reader D-Bus service detected (Orca/Cthulhu).") + + def _is_process_running(self, processName): + """Return True when the process name is running.""" + try: + result = subprocess.run( + ["pgrep", "-x", processName], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=1 + ) + return result.returncode == 0 and bool(result.stdout.strip()) + except Exception: + return False + + def _get_newest_running_pid(self, processName): + """Return highest PID for a running process name, or -1.""" + try: + result = subprocess.run( + ["pgrep", "-x", processName], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=1 + ) + if result.returncode != 0: + return -1 + pidValues = [int(pid.strip()) for pid in result.stdout.splitlines() if pid.strip().isdigit()] + return max(pidValues) if pidValues else -1 + except Exception: + return -1 + + def _select_active_controller(self): + """ + Pick the best controller: + 1) running service with newest PID + 2) any available service + """ + if not self.availableControllers: + return None + + runningControllers = [ + controller for controller in self.availableControllers + if self._is_process_running(controller.processName) + ] + + if len(runningControllers) == 1: + return runningControllers[0] + + if len(runningControllers) > 1: + newestController = None + newestPid = -1 + for controller in runningControllers: + controllerPid = self._get_newest_running_pid(controller.processName) + if controllerPid > newestPid: + newestPid = controllerPid + newestController = controller + if newestController: + return newestController + + return self.availableControllers[0] + + def get_active_reader_name(self): + """Return active screen reader display name.""" + if self.activeController: + return self.activeController.displayName + return "Unavailable" + + def is_available(self): + """Check if screen reader engine is available.""" + return self.isAvailable + + def close(self): + """Release resources and stop in-flight reading state.""" + self.cancel_reading() + + def cleanup(self): + """Cleanup resources - alias for close().""" + self.close() + + def _estimate_duration_seconds(self, text): + """Estimate speech duration for callback timing.""" + words = max(1, len(str(text).split())) + adjustedWpm = max(90.0, min(500.0, 180.0 + (float(self.speechRate) * 2.5))) + wordsDuration = (words / adjustedWpm) * 60.0 + punctuationCount = str(text).count(".") + str(text).count("!") + str(text).count("?") + punctuationPause = punctuationCount * 0.12 + return max(0.8, wordsDuration + punctuationPause) + + def _start_completion_timer(self, readingGeneration, text): + """Start background completion timer and invoke callback on completion.""" + duration = self._estimate_duration_seconds(text) + + def completion_thread(): + elapsed = 0.0 + interval = 0.1 + + while elapsed < duration: + time.sleep(interval) + elapsed += interval + if readingGeneration != self.readingGeneration: + return + + callback = None + with self.speechLock: + if readingGeneration != self.readingGeneration: + return + self.isReading = False + self.isPausedReading = False + callback = self.readingCallback + + if callback: + callback('COMPLETED') + + workerThread = threading.Thread(target=completion_thread, daemon=True) + workerThread.start() + + def _try_switch_controller(self): + """Try to switch to another available controller.""" + if not self.availableControllers: + return False + if not self.activeController: + self.activeController = self.availableControllers[0] + return True + + for controller in self.availableControllers: + if controller is self.activeController: + continue + self.activeController = controller + return True + + return False + + def speak(self, text, interrupt=True): + """ + Present text via active screen reader. + + Args: + text: text to present + interrupt: interrupt current speech first + """ + if not self.isAvailable or not is_valid_text(text): + return False + + with self.speechLock: + activeController = self.activeController + if not activeController: + return False + + if interrupt: + activeController.interrupt_speech() + + if activeController.present_message(str(text)): + return True + + if self._try_switch_controller(): + activeController = self.activeController + if activeController: + if interrupt: + activeController.interrupt_speech() + if activeController.present_message(str(text)): + return True + + return False + + def stop(self): + """Stop current speech output.""" + self.cancel_reading() + + def speak_reading(self, text, callback=None): + """ + Present reading text with completion callback. + + Args: + text: text to read + callback: called with 'COMPLETED' when reading is estimated done + """ + if not self.isAvailable or not is_valid_text(text): + return + + textStr = str(text).replace('\n', ' ').replace('\r', ' ') + textStr = " ".join(textStr.split()).strip() + if not textStr: + return + + with self.speechLock: + self.readingGeneration += 1 + currentGeneration = self.readingGeneration + self.currentText = textStr + self.readingCallback = callback + self.isReading = True + self.isPausedReading = False + + if not self.speak(textStr, interrupt=True): + with self.speechLock: + if currentGeneration == self.readingGeneration: + self.isReading = False + return + + self._start_completion_timer(currentGeneration, textStr) + + def pause_reading(self): + """Pause reading by interrupting speech and marking paused state.""" + with self.speechLock: + if not self.isReading: + return + self.readingGeneration += 1 + self.isReading = False + self.isPausedReading = True + + if self.activeController: + self.activeController.interrupt_speech() + + def resume_reading(self): + """Resume reading from paragraph start.""" + with self.speechLock: + if not self.isPausedReading or not self.currentText: + return + callback = self.readingCallback + textToResume = self.currentText + self.isPausedReading = False + + self.speak_reading(textToResume, callback=callback) + + def cancel_reading(self): + """Cancel current reading.""" + with self.speechLock: + self.readingGeneration += 1 + self.isReading = False + self.isPausedReading = False + self.readingCallback = None + + if self.activeController: + self.activeController.interrupt_speech() + + def is_reading_active(self): + """Check if currently reading (not paused).""" + return self.isReading and not self.isPausedReading + + def is_reading_paused(self): + """Check if reading is paused.""" + return self.isPausedReading + + def set_rate(self, rate): + """Store virtual speech rate used for completion timing estimation.""" + try: + self.speechRate = max(-100, min(100, int(rate))) + except Exception: + self.speechRate = 0 + + def set_voice(self, voiceName): + """Compatibility no-op for shared engine API.""" + _ = voiceName + + def list_voices(self): + """Compatibility no-op for shared engine API.""" + return [] + + def list_output_modules(self): + """Compatibility no-op for shared engine API.""" + return [] + + def set_output_module(self, moduleName): + """Compatibility no-op for shared engine API.""" + _ = moduleName diff --git a/src/wav_exporter.py b/src/wav_exporter.py index 07936cb..78a8cc0 100644 --- a/src/wav_exporter.py +++ b/src/wav_exporter.py @@ -125,7 +125,7 @@ class WavExporter: TtsEngine instance or None if not available """ readerEngine = self.config.get_reader_engine() - if readerEngine == 'speechd': + if readerEngine != 'piper': print("Error: WAV export requires piper-tts. Set reader_engine=piper in config.") return None