diff --git a/bookstorm.py b/bookstorm.py index 6fbf595..5212362 100755 --- a/bookstorm.py +++ b/bookstorm.py @@ -51,6 +51,8 @@ from src.audiobookshelf_menu import AudiobookshelfMenu from src.server_link_manager import ServerLinkManager from src.bookmarks_menu import BookmarksMenu from src.wav_exporter import WavExporter +from src.braille_output import BrailleOutput +from src.braille_menu import BrailleMenu class BookReader: @@ -80,6 +82,20 @@ 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 @@ -89,7 +105,8 @@ class BookReader: self.speechEngine, voiceSelector, self.audioPlayer, - ttsReloadCallback=reloadCallback + ttsReloadCallback=reloadCallback, + brailleOutput=self.brailleOutput ) # Initialize book selector @@ -311,17 +328,22 @@ class BookReader: print(f"[Paragraph {self.currentParagraph + 1}/{chapter.get_total_paragraphs()}]") print(f"\n{paragraph}\n") - # Generate and play audio - try: - print("Generating speech...") - wavData = self.ttsEngine.text_to_wav_data(paragraph) - if wavData: - print("Playing...") - completed = self.audioPlayer.play_wav_data(wavData, blocking=True) - return completed - except Exception as e: - print(f"Error during playback: {e}") - return False + # 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: + print("Generating speech...") + wavData = self.ttsEngine.text_to_wav_data(paragraph) + if wavData: + print("Playing...") + 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 @@ -684,6 +706,12 @@ 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 @@ -830,6 +858,53 @@ 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 + + # For debugging, print the key code + print(f"DEBUG: Braille key pressed: {keyCode}") + + # 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 + print("DEBUG: Braille pan forward") + self.brailleOutput.pan_forward() + elif command == CMD_FWINLT or (keyCode & 0xFF) == 0x02: + # Backward panning + print("DEBUG: Braille pan backward") + self.brailleOutput.pan_backward() + else: + # Unknown key - just log it for now + print(f"DEBUG: Unknown Braille key: 0x{keyCode:08x}") + + except Exception as e: + print(f"DEBUG: Error handling Braille key: {e}") + def _handle_pygame_key(self, event): """Handle pygame key event""" # Check if in Audiobookshelf menu @@ -2132,50 +2207,63 @@ class BookReader: # Post pygame event to handle in main loop pygame.event.post(pygame.event.Event(SPEECH_FINISHED_EVENT)) - self.readingEngine.speak_reading(paragraph, callback=on_speech_finished) + # 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) else: # Use piper-tts for reading with buffering - wavData = None - try: - # Check if we have buffered audio ready - with self.bufferLock: - if self.bufferedAudio is not None: - # Use pre-generated audio - wavData = self.bufferedAudio - self.bufferedAudio = None + + # 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 + with self.bufferLock: + if self.bufferedAudio is not None: + # Use pre-generated audio + wavData = self.bufferedAudio + self.bufferedAudio = None + else: + # Generate audio now (first paragraph or after navigation) + wavData = self.ttsEngine.text_to_wav_data(paragraph) + + if wavData: + # Stop any existing audio playback + if self.audioPlayer.is_audio_file_playing(): + self.audioPlayer.stop_audio_file() + + # 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 else: - # Generate audio now (first paragraph or after navigation) - wavData = self.ttsEngine.text_to_wav_data(paragraph) - - if wavData: - # Stop any existing audio playback - if self.audioPlayer.is_audio_file_playing(): - self.audioPlayer.stop_audio_file() - - # 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 - 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("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""" @@ -2365,6 +2453,9 @@ 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 3b8738a..ed75b7d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,6 +6,13 @@ mutagen>=1.45.0 pypdf mpv +# 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 +# brltty: System daemon for Braille display hardware +# liblouis: Braille translation library diff --git a/src/braille_menu.py b/src/braille_menu.py new file mode 100644 index 0000000..7d4cce2 --- /dev/null +++ b/src/braille_menu.py @@ -0,0 +1,285 @@ +""" +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 new file mode 100644 index 0000000..d399f0c --- /dev/null +++ b/src/braille_output.py @@ -0,0 +1,352 @@ +""" +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 8fd077f..726d8bc 100644 --- a/src/config_manager.py +++ b/src/config_manager.py @@ -76,6 +76,14 @@ 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): @@ -326,3 +334,55 @@ class ConfigManager: speed = max(0.5, min(2.0, float(speed))) self.set('Audio', 'playback_speed', str(speed)) 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/options_menu.py b/src/options_menu.py index b209e8c..537e13b 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): + def __init__(self, config, speechEngine, voiceSelector, audioPlayer, ttsReloadCallback=None, brailleOutput=None): """ Initialize options menu @@ -24,12 +24,14 @@ 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 @@ -78,6 +80,10 @@ class OptionsMenu: 'label': "Speech Rate Settings", 'action': 'speech_rate' }, + { + 'label': "Braille Settings", + 'action': 'braille_settings' + }, { 'label': absLabel, 'action': 'audiobookshelf_setup' @@ -132,6 +138,8 @@ 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': @@ -493,6 +501,40 @@ 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