#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ BookStorm - Accessible Book Reader A book reader with text-to-speech support for DAISY, EPUB, and PDF formats. Uses piper-tts for high-quality speech synthesis. """ import sys import argparse import threading import gc import os from pathlib import Path try: from setproctitle import setproctitle HAS_SETPROCTITLE = True except ImportError: HAS_SETPROCTITLE = False try: import pygame HAS_PYGAME = True # Define custom pygame event for speech-dispatcher callbacks SPEECH_FINISHED_EVENT = pygame.USEREVENT + 1 except ImportError: HAS_PYGAME = False SPEECH_FINISHED_EVENT = None from src.daisy_parser import DaisyParser from src.epub_parser import EpubParser from src.pdf_parser import PdfParser from src.txt_parser import TxtParser from src.bookmark_manager import BookmarkManager from src.tts_engine import TtsEngine from src.config_manager import ConfigManager from src.voice_selector import VoiceSelector from src.book_selector import BookSelector from src.pygame_player import PygamePlayer from src.speech_engine import SpeechEngine from src.options_menu import OptionsMenu from src.sleep_timer_menu import SleepTimerMenu class BookReader: """Main book reader class""" def __init__(self, bookPath, config=None): """ Initialize book reader Args: bookPath: Path to book file config: ConfigManager instance """ self.bookPath = Path(bookPath) self.book = None self.currentChapter = 0 self.currentParagraph = 0 self.config = config or ConfigManager() # Initialize components self.parser = None # Will be set based on file type self.bookmarkManager = BookmarkManager() self.speechEngine = SpeechEngine() # UI feedback self.audioPlayer = PygamePlayer() # Configure speech engine from saved settings speechRate = self.config.get_speech_rate() self.speechEngine.set_rate(speechRate) # Initialize options menu voiceSelector = VoiceSelector(self.config.get_voice_dir()) # Create callback reference for TTS engine reloading reloadCallback = self.reload_tts_engine self.optionsMenu = OptionsMenu( self.config, self.speechEngine, voiceSelector, self.audioPlayer, ttsReloadCallback=reloadCallback ) # Initialize book selector # Use library directory if set, otherwise use last books directory libraryDir = self.config.get_library_directory() if libraryDir and Path(libraryDir).exists(): booksDir = libraryDir else: booksDir = self.config.get_books_directory() supportedFormats = ['.zip', '.epub', '.pdf', '.txt'] self.bookSelector = BookSelector(booksDir, supportedFormats, self.speechEngine) # Initialize sleep timer menu self.sleepTimerMenu = SleepTimerMenu(self.speechEngine) # Initialize reading engine based on config readerEngine = self.config.get_reader_engine() if readerEngine == 'speechd': # Use separate speech-dispatcher session for reading # (UI uses self.speechEngine, reading uses self.readingEngine) self.ttsEngine = None self.readingEngine = SpeechEngine() # Separate session for book reading # Apply saved speech-dispatcher settings to reading engine 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) # Apply speech rate to reading engine self.readingEngine.set_rate(speechRate) else: # Use piper-tts self.readingEngine = None voiceModel = self.config.get_voice_model() self.ttsEngine = TtsEngine(voiceModel) # Playback state self.isRunning = False self.isPlaying = False # Audio buffering for seamless playback self.bufferedAudio = None # Pre-generated next paragraph self.bufferThread = None self.cancelBuffer = False self.bufferLock = threading.Lock() def load_book(self): """Load and parse the book""" message = f"Loading book {self.bookPath.stem}" print(message) self.speechEngine.speak(message) # Detect format and create appropriate parser suffix = self.bookPath.suffix.lower() if suffix in ['.epub']: self.parser = EpubParser() self.book = self.parser.parse(self.bookPath) elif suffix in ['.zip']: # Assume DAISY format for zip files self.parser = DaisyParser() self.book = self.parser.parse(self.bookPath) elif suffix in ['.pdf']: self.parser = PdfParser() self.book = self.parser.parse(self.bookPath) elif suffix in ['.txt']: self.parser = TxtParser() self.book = self.parser.parse(self.bookPath) else: raise ValueError(f"Unsupported book format: {self.bookPath.suffix}") print(f"Loaded: {self.book.title}") print(f"Chapters: {self.book.get_total_chapters()}") # Load bookmark if exists (but don't announce it) bookmark = self.bookmarkManager.get_bookmark(self.bookPath) if bookmark: self.currentChapter = bookmark['chapterIndex'] self.currentParagraph = bookmark['paragraphIndex'] print(f"Resuming from chapter {self.currentChapter + 1}, paragraph {self.currentParagraph + 1}") else: print("Starting from beginning") def read_current_paragraph(self): """Read the current paragraph aloud""" chapter = self.book.get_chapter(self.currentChapter) if not chapter: return False paragraph = chapter.get_paragraph(self.currentParagraph) if not paragraph: return False # Show what we're reading print(f"\n[Chapter {self.currentChapter + 1}/{self.book.get_total_chapters()}: {chapter.title}]") 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 return True def next_paragraph(self): """Move to next paragraph""" chapter = self.book.get_chapter(self.currentChapter) if not chapter: return False if self.currentParagraph < chapter.get_total_paragraphs() - 1: self.currentParagraph += 1 return True else: # Move to next chapter return self.next_chapter() def previous_paragraph(self): """Move to previous paragraph""" if self.currentParagraph > 0: self.currentParagraph -= 1 return True else: # Move to previous chapter if self.previous_chapter(): # Go to last paragraph of previous chapter chapter = self.book.get_chapter(self.currentChapter) if chapter: self.currentParagraph = chapter.get_total_paragraphs() - 1 return True return False def next_chapter(self): """Move to next chapter""" if self.currentChapter < self.book.get_total_chapters() - 1: self.currentChapter += 1 self.currentParagraph = 0 return True return False def previous_chapter(self): """Move to previous chapter""" if self.currentChapter > 0: self.currentChapter -= 1 self.currentParagraph = 0 return True return False def save_bookmark(self, speakFeedback=True): """Save current position as bookmark Args: speakFeedback: Whether to speak "Bookmark saved" (default True) """ self.bookmarkManager.save_bookmark( self.bookPath, self.book.title, self.currentChapter, self.currentParagraph ) if speakFeedback: self.speechEngine.speak("Bookmark saved") def reload_tts_engine(self): """Reload TTS engine with current config settings""" readerEngine = self.config.get_reader_engine() if readerEngine == 'speechd': # Using speech-dispatcher, apply settings to reading engine self.ttsEngine = None # Recreate reading engine self.readingEngine = SpeechEngine() # Apply saved speech-dispatcher settings 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) # Apply speech rate speechRate = self.config.get_speech_rate() self.readingEngine.set_rate(speechRate) message = "Speech-dispatcher settings reloaded successfully" print(message) self.speechEngine.speak(message) else: # Reload piper-tts with new voice self.readingEngine = None voiceModel = self.config.get_voice_model() self.ttsEngine = TtsEngine(voiceModel) message = "Voice reloaded successfully" print(message) self.speechEngine.speak(message) def run_interactive(self): """Run in interactive mode with pygame event loop""" if not HAS_PYGAME: print("\nError: pygame is required for BookStorm") print("Install with: pip install pygame") return if not self.audioPlayer.is_available(): print("\nError: Could not initialize pygame audio") return # Initialize pygame display with larger window for large print text pygame.init() self.screen = pygame.display.set_mode((1600, 900)) pygame.display.set_caption(f"BookStorm - {self.book.title}") # Initialize font for large print display (72pt for severe visual impairment) self.font = pygame.font.Font(None, 96) # 96 pixels ≈ 72pt self.smallFont = pygame.font.Font(None, 36) # For status info (27pt) # Colors self.bgColor = (0, 0, 0) # Black background self.textColor = (255, 255, 255) # White text self.statusColor = (180, 180, 180) # Gray for status # Current display text self.displayText = "Press SPACE to start reading" self.statusText = f"Book: {self.book.title}" # Cached rendered surfaces to prevent memory leak from re-rendering 30 FPS self.cachedDisplayText = None self.cachedStatusText = None self.cachedSurfaces = [] print(f"\n{self.book.title} - {self.book.get_total_chapters()} chapters") print("Press SPACE to start reading") # Speak controls for accessibility self.speechEngine.speak("BookStorm ready. Press SPACE to start reading. Press i for info. Press h for help.") self._run_pygame_loop() def _render_screen(self): """Render text to pygame window""" self.screen.fill(self.bgColor) # Check if text display is enabled showText = self.config.get_show_text() if showText: # Only re-render if text changed (prevents massive Surface object leak) if self.cachedDisplayText != self.displayText or self.cachedStatusText != self.statusText: # Explicitly delete old cached surfaces before clearing for surfaceType, surface, position in self.cachedSurfaces: del surface self.cachedSurfaces.clear() # Render status text at top statusSurface = self.smallFont.render(self.statusText, True, self.statusColor) self.cachedSurfaces.append(('status', statusSurface, (20, 20))) # Render main text with word wrapping words = self.displayText.split(' ') lines = [] currentLine = [] for word in words: testLine = ' '.join(currentLine + [word]) testSurface = self.font.render(testLine, True, self.textColor) if testSurface.get_width() < 1560: # Leave 40px margin (1600-40) currentLine.append(word) del testSurface # Delete test surface immediately else: del testSurface # Delete test surface immediately if currentLine: lines.append(' '.join(currentLine)) currentLine = [word] if currentLine: lines.append(' '.join(currentLine)) # Render wrapped lines and cache them yPos = 100 for line in lines: if yPos > 850: # Don't render beyond window (900-50 margin) break textSurface = self.font.render(line, True, self.textColor) self.cachedSurfaces.append(('text', textSurface, (20, yPos))) yPos += 110 # Line spacing for 96px font # Update cache markers self.cachedDisplayText = self.displayText self.cachedStatusText = self.statusText # Blit cached surfaces for surfaceType, surface, position in self.cachedSurfaces: self.screen.blit(surface, position) else: # Show simple message when text display is off message = "Text display off (press O for options)" textSurface = self.smallFont.render(message, True, self.statusColor) textRect = textSurface.get_rect(center=(800, 450)) self.screen.blit(textSurface, textRect) del textSurface pygame.display.flip() def _run_pygame_loop(self): """Main pygame event loop""" self.isRunning = True self.isPlaying = False clock = pygame.time.Clock() gcCounter = 0 # Counter for periodic garbage collection memoryWarningShown = False # Track if we've warned about high memory try: while self.isRunning: # Process pygame events events = pygame.event.get() for event in events: if event.type == pygame.QUIT: self.isRunning = False elif event.type == pygame.KEYDOWN: self._handle_pygame_key(event) elif event.type == SPEECH_FINISHED_EVENT: # Speech-dispatcher 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()) if self.isPlaying and not inAnyMenu: if not self.next_paragraph(): self.displayText = "End of book reached" self.isPlaying = False self.save_bookmark(speakFeedback=False) else: # Start next paragraph self._start_paragraph_playback() # Explicitly delete event objects to help GC del events # Check if sleep timer has expired if self.sleepTimerMenu.check_timer(): self.speechEngine.speak("Sleep timer expired. Goodbye.") self.isRunning = False self.isPlaying = False # Check if we need to advance to next paragraph (piper-tts only) # Speech-dispatcher uses callbacks for auto-advance 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()) if self.isPlaying and readerEngine == 'piper' and not inAnyMenu: # Check piper-tts / pygame player state playbackFinished = not self.audioPlayer.is_playing() and not self.audioPlayer.is_paused() if playbackFinished: # Current paragraph finished, advance if not self.next_paragraph(): self.displayText = "End of book reached" self.isPlaying = False self.save_bookmark(speakFeedback=False) else: # Start next paragraph with error recovery try: self._start_paragraph_playback() except Exception as e: print(f"Error starting playback: {e}") self.speechEngine.speak("Playback error") self.isPlaying = False # Render the screen self._render_screen() # Periodic garbage collection to prevent memory creep # Every ~10 seconds (300 frames at 30 FPS) run GC gcCounter += 1 if gcCounter >= 300: # Clear any accumulated pygame events before GC pygame.event.clear() # Alternate between fast (gen 0) and full GC if gcCounter % 600 == 0: gc.collect() # Full collection every 20 seconds else: gc.collect(generation=0) # Fast collection every 10 seconds # Debug: Print memory usage every 10 seconds try: import resource memUsage = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024 # MB print(f"DEBUG: Memory usage: {memUsage:.1f} MB") # Memory watchdog: warn if exceeding 2GB (50% on Pi 4GB) if memUsage > 2048 and not memoryWarningShown: memoryWarningShown = True self.speechEngine.speak("Warning: High memory usage detected. Consider restarting BookStorm soon.") print("WARNING: Memory usage exceeds 2GB - consider restarting") except: pass gcCounter = 0 # Limit to 30 FPS to avoid CPU spinning clock.tick(30) except KeyboardInterrupt: print("\n\nInterrupted") finally: readerEngine = self.config.get_reader_engine() if readerEngine == 'speechd': self.readingEngine.cancel_reading() else: self.audioPlayer.stop() self.save_bookmark(speakFeedback=False) # Clear cached surfaces before quitting self.cachedSurfaces.clear() pygame.quit() def _handle_pygame_key(self, event): """Handle pygame key event""" # Check if in book browser if self.bookSelector.is_in_browser(): self._handle_browser_key(event) return # Check if in sleep timer menu if self.sleepTimerMenu.is_in_menu(): self._handle_sleep_timer_key(event) return # Check if in options menu if self.optionsMenu.is_in_menu(): self._handle_menu_key(event) return # Check for shift modifier mods = pygame.key.get_mods() shiftPressed = mods & pygame.KMOD_SHIFT if event.key == pygame.K_SPACE: # Toggle play/pause readerEngine = self.config.get_reader_engine() if not self.isPlaying: # Speak UI feedback (always safe with separate sessions) self.speechEngine.speak("Starting playback") self.isPlaying = True self._start_paragraph_playback() else: # Toggle pause/resume if readerEngine == 'speechd': # Handle speech-dispatcher pause/resume if self.readingEngine.is_reading_paused(): self.speechEngine.speak("Resuming") self.readingEngine.resume_reading() else: self.speechEngine.speak("Paused") self.readingEngine.pause_reading() else: # Handle piper-tts pause/resume if self.audioPlayer.is_paused(): self.speechEngine.speak("Resuming") self.audioPlayer.resume() else: self.speechEngine.speak("Paused") self.audioPlayer.pause() elif event.key == pygame.K_n: if shiftPressed: # Next chapter self._stop_playback() if self.next_chapter(): chapter = self.book.get_chapter(self.currentChapter) self.speechEngine.speak(f"Chapter {self.currentChapter + 1}: {chapter.title}") if self.isPlaying: self._start_paragraph_playback() else: self.speechEngine.speak("No next chapter") self.isPlaying = False else: # Next paragraph self._stop_playback() if self.next_paragraph(): self.speechEngine.speak(f"Paragraph {self.currentParagraph + 1}") if self.isPlaying: self._start_paragraph_playback() else: self.speechEngine.speak("End of book") self.isPlaying = False elif event.key == pygame.K_p: if shiftPressed: # Previous chapter self._stop_playback() if self.previous_chapter(): chapter = self.book.get_chapter(self.currentChapter) self.speechEngine.speak(f"Chapter {self.currentChapter + 1}: {chapter.title}") if self.isPlaying: self._start_paragraph_playback() else: self.speechEngine.speak("No previous chapter") else: # Previous paragraph self._stop_playback() if self.previous_paragraph(): self.speechEngine.speak(f"Paragraph {self.currentParagraph + 1}") if self.isPlaying: self._start_paragraph_playback() else: self.speechEngine.speak("Beginning of book") elif event.key == pygame.K_s: readerEngine = self.config.get_reader_engine() # Pause playback while saving wasPaused = False if readerEngine == 'speechd': wasPaused = self.readingEngine.is_reading_paused() if not wasPaused and self.readingEngine.is_reading_active(): self.readingEngine.pause_reading() else: wasPaused = self.audioPlayer.is_paused() if not wasPaused and self.audioPlayer.is_playing(): self.audioPlayer.pause() # Speak feedback (safe with separate sessions) self.save_bookmark(speakFeedback=True) # Resume playback if not wasPaused and self.isPlaying: if readerEngine == 'speechd': self.readingEngine.resume_reading() else: self.audioPlayer.resume() elif event.key == pygame.K_PAGEUP: # Increase speech rate readerEngine = self.config.get_reader_engine() currentRate = self.config.get_speech_rate() newRate = min(100, currentRate + 10) self.config.set_speech_rate(newRate) self.speechEngine.set_rate(newRate) # Apply to reading engine as well if readerEngine == 'speechd': self.readingEngine.set_rate(newRate) self.speechEngine.speak(f"Speech rate: {newRate}") elif event.key == pygame.K_PAGEDOWN: # Decrease speech rate readerEngine = self.config.get_reader_engine() currentRate = self.config.get_speech_rate() newRate = max(-100, currentRate - 10) self.config.set_speech_rate(newRate) self.speechEngine.set_rate(newRate) # Apply to reading engine as well if readerEngine == 'speechd': self.readingEngine.set_rate(newRate) self.speechEngine.speak(f"Speech rate: {newRate}") elif event.key == pygame.K_b: # Open book browser - reset to library directory if set libraryDir = self.config.get_library_directory() if libraryDir and Path(libraryDir).exists(): self.bookSelector.reset_to_directory(libraryDir) self.bookSelector.enter_browser() elif event.key == pygame.K_o: # Open options menu self.optionsMenu.enter_menu() elif event.key == pygame.K_h: # Help self.speechEngine.speak("SPACE: play pause. n: next paragraph. p: previous paragraph. Shift N: next chapter. Shift P: previous chapter. s: save bookmark. b: browse books. o: options menu. i: current info. Page Up Down: adjust speech rate. t: time remaining. h: help. q: quit or sleep timer") elif event.key == pygame.K_i: # Speak current position info chapter = self.book.get_chapter(self.currentChapter) if chapter: info = f"{self.book.title}. {self.book.get_total_chapters()} chapters. Currently at chapter {self.currentChapter + 1}: {chapter.title}. Paragraph {self.currentParagraph + 1} of {chapter.get_total_paragraphs()}" self.speechEngine.speak(info) elif event.key == pygame.K_t: # Speak time remaining on sleep timer if self.sleepTimerMenu.is_timer_active(): timeRemaining = self.sleepTimerMenu.get_time_remaining() if timeRemaining: minutes, seconds = timeRemaining if minutes > 0: self.speechEngine.speak(f"{minutes} minutes {seconds} seconds remaining") else: self.speechEngine.speak(f"{seconds} seconds remaining") else: self.speechEngine.speak("No sleep timer active") elif event.key == pygame.K_q or event.key == pygame.K_ESCAPE: # Open sleep timer menu self.sleepTimerMenu.enter_menu() def _handle_browser_key(self, event): """Handle key events when in book browser""" if event.key == pygame.K_UP: self.bookSelector.navigate_browser('up') elif event.key == pygame.K_DOWN: self.bookSelector.navigate_browser('down') elif event.key == pygame.K_RETURN: # Select item (book or directory) selectedBook = self.bookSelector.activate_current_item() if selectedBook: # Book was selected, load it self.bookSelector.exit_browser() self._load_new_book(selectedBook) elif event.key == pygame.K_BACKSPACE or event.key == pygame.K_LEFT: # Go to parent directory self.bookSelector.go_parent_directory() elif event.key == pygame.K_l: # Set current directory as library directory currentDir = self.bookSelector.get_current_directory() self.config.set_library_directory(str(currentDir)) dirName = currentDir.name if currentDir.name else str(currentDir) self.speechEngine.speak(f"Library set to {dirName}") elif event.key == pygame.K_ESCAPE: self.bookSelector.exit_browser() def _handle_menu_key(self, event): """Handle key events when in options menu""" # Check if in restart confirmation dialog if self.optionsMenu.is_in_restart_menu(): if event.key == pygame.K_UP: self.optionsMenu.navigate_restart_menu('up') elif event.key == pygame.K_DOWN: self.optionsMenu.navigate_restart_menu('down') elif event.key == pygame.K_RETURN: self.optionsMenu.select_restart_option() elif event.key == pygame.K_ESCAPE: self.optionsMenu.exit_restart_menu() # Check if in voice selection submenu elif self.optionsMenu.is_in_voice_menu(): if event.key == pygame.K_UP: self.optionsMenu.navigate_voice_menu('up') elif event.key == pygame.K_DOWN: self.optionsMenu.navigate_voice_menu('down') elif event.key == pygame.K_RETURN: self.optionsMenu.select_current_voice() elif event.key == pygame.K_ESCAPE: self.optionsMenu.exit_voice_menu() # Check if in output module selection submenu elif self.optionsMenu.is_in_module_menu(): if event.key == pygame.K_UP: self.optionsMenu.navigate_module_menu('up') elif event.key == pygame.K_DOWN: self.optionsMenu.navigate_module_menu('down') elif event.key == pygame.K_RETURN: self.optionsMenu.select_current_module() elif event.key == pygame.K_ESCAPE: self.optionsMenu.exit_module_menu() else: # Main options menu if event.key == pygame.K_UP: self.optionsMenu.navigate_menu('up') elif event.key == pygame.K_DOWN: self.optionsMenu.navigate_menu('down') elif event.key == pygame.K_RETURN: # Activate current menu item stayInMenu = self.optionsMenu.activate_current_item() if not stayInMenu: self.optionsMenu.exit_menu() elif event.key == pygame.K_ESCAPE: self.speechEngine.speak("Closing options menu") self.optionsMenu.exit_menu() def _handle_sleep_timer_key(self, event): """Handle key events when in sleep timer menu""" if event.key == pygame.K_UP: self.sleepTimerMenu.navigate_menu('up') elif event.key == pygame.K_DOWN: self.sleepTimerMenu.navigate_menu('down') elif event.key == pygame.K_RETURN: # Activate current menu item shouldQuitNow, shouldContinue = self.sleepTimerMenu.activate_current_item() if shouldQuitNow: # User selected "Quit now" self.isRunning = False self.isPlaying = False # If shouldContinue is True, timer is set and reading continues elif event.key == pygame.K_ESCAPE: self.speechEngine.speak("Cancelled") self.sleepTimerMenu.exit_menu() def _load_new_book(self, bookPath): """ Load a new book from file path Args: bookPath: Path to new book file """ # Stop current playback self.audioPlayer.stop() self._cancel_buffer() self.isPlaying = False # Save bookmark for current book if self.book: self.save_bookmark() # Update book path and config self.bookPath = Path(bookPath) self.config.set_last_book(bookPath) self.config.set_books_directory(str(self.bookPath.parent)) # Reset position self.currentChapter = 0 self.currentParagraph = 0 # Load new book try: self.load_book() self.speechEngine.speak("Ready") except Exception as e: message = f"Error loading book: {e}" print(message) self.speechEngine.speak(message) def _stop_playback(self): """Stop current playback (both piper-tts and speech-dispatcher)""" readerEngine = self.config.get_reader_engine() if readerEngine == 'speechd': # Cancel speech-dispatcher reading self.readingEngine.cancel_reading() else: # Stop piper-tts playback and cancel buffering self._cancel_buffer() self.audioPlayer.stop() def _start_paragraph_playback(self): """Start playing current paragraph""" chapter = self.book.get_chapter(self.currentChapter) if not chapter: print("ERROR: No chapter found!") return paragraph = chapter.get_paragraph(self.currentParagraph) if not paragraph: print("ERROR: No paragraph found!") return # Update display text and status self.displayText = paragraph self.statusText = f"Ch {self.currentChapter + 1}/{self.book.get_total_chapters()}: {chapter.title} | Para {self.currentParagraph + 1}/{chapter.get_total_paragraphs()}" # Use configured reader engine readerEngine = self.config.get_reader_engine() if readerEngine == 'speechd': # Use speech-dispatcher for reading with callback def on_speech_finished(finishType): """ Callback when speech-dispatcher finishes speaking. Must not call speechd commands directly (causes deadlock). Post pygame event instead. """ if finishType == 'COMPLETED' and self.isPlaying: # 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) 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 else: # Generate audio now (first paragraph or after navigation) wavData = self.ttsEngine.text_to_wav_data(paragraph) if wavData: self.audioPlayer.play_wav_data(wavData) # Explicitly delete wavData after playback starts to free memory # (pygame.mixer.Sound has already copied it) del wavData wavData = None # 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: # Ensure wavData is freed even on error if wavData is not None: del wavData def _buffer_next_paragraph(self): """Start buffering next paragraph in background thread""" # Only for piper-tts (speech-dispatcher handles buffering internally) readerEngine = self.config.get_reader_engine() if readerEngine != 'piper': return # Don't start a new buffer thread if one is already running # This prevents thread accumulation when playback outruns buffering if self.bufferThread and self.bufferThread.is_alive(): return # CRITICAL: Clear any stale buffered audio before starting new thread # This happens when buffer thread finishes AFTER we already generated audio synchronously with self.bufferLock: if self.bufferedAudio is not None: print("Warning: Discarding stale buffered audio (orphaned buffer)") del self.bufferedAudio self.bufferedAudio = None # Calculate next paragraph position nextChapter = self.currentChapter nextParagraph = self.currentParagraph + 1 chapter = self.book.get_chapter(nextChapter) if not chapter: return # Check if we need to move to next chapter if nextParagraph >= chapter.get_total_paragraphs(): nextChapter += 1 nextParagraph = 0 chapter = self.book.get_chapter(nextChapter) if not chapter: return # End of book # Get the paragraph to buffer paragraph = chapter.get_paragraph(nextParagraph) if not paragraph: return def buffer_thread(): """Background thread to generate audio""" wavData = None try: # Generate audio wavData = self.ttsEngine.text_to_wav_data(paragraph) # Check if cancelled if self.cancelBuffer: # Clean up if cancelled if wavData: del wavData return # Store buffered audio with self.bufferLock: if not self.cancelBuffer: self.bufferedAudio = wavData wavData = None # Transfer ownership, don't delete except Exception as e: print(f"Error buffering paragraph: {e}") # Clear buffer state on error to prevent stalls with self.bufferLock: self.bufferedAudio = None finally: # Clean up wavData if not transferred to bufferedAudio if wavData is not None: del wavData # Clear any cancelled buffer state with self.bufferLock: self.cancelBuffer = False # Start new buffer thread self.bufferThread = threading.Thread(target=buffer_thread, daemon=True) self.bufferThread.start() def _cancel_buffer(self): """Cancel in-progress buffering""" if self.bufferThread and self.bufferThread.is_alive(): self.cancelBuffer = True # Wait longer for TTS generation to finish (piper-tts can be slow) # If thread doesn't finish, it will be abandoned (daemon thread) self.bufferThread.join(timeout=3.0) if self.bufferThread.is_alive(): print("Warning: Buffer thread did not finish in time") # Clear buffered audio and explicitly delete to free memory with self.bufferLock: if self.bufferedAudio is not None: del self.bufferedAudio self.bufferedAudio = None self.cancelBuffer = False # Reset thread reference self.bufferThread = None def cleanup(self): """Cleanup resources""" self._cancel_buffer() self.audioPlayer.cleanup() self.speechEngine.cleanup() if self.readingEngine: self.readingEngine.cleanup() if self.parser: self.parser.cleanup() def main(): """Main entry point""" # Set process title for easier identification if HAS_SETPROCTITLE: setproctitle("BookStorm") parser = argparse.ArgumentParser( description="BookStorm - Accessible book reader with TTS", epilog="Press 'o' in the reader for options menu" ) parser.add_argument( 'book', nargs='?', help='Path to book file (EPUB, PDF, TXT, or DAISY zip). If not provided, will resume last book' ) parser.add_argument( '--wav', action='store_true', help='Export book to WAV files (by chapter) instead of interactive reading' ) parser.add_argument( '--output-dir', dest='outputDir', help='Output directory for exported audio (default: ./book_audio/)', default=None ) args = parser.parse_args() # Load configuration config = ConfigManager() # Determine which book to use bookPath = None if args.book: # Book provided on command line bookPath = args.book else: # Try to use last book lastBook = config.get_last_book() if lastBook and Path(lastBook).exists(): bookPath = lastBook else: # No book available print("BookStorm - Accessible Book Reader") print("\nUsage:") print(" python bookstorm.py # Read EPUB book") print(" python bookstorm.py # Read PDF book") print(" python bookstorm.py # Read TXT book") print(" python bookstorm.py # Read DAISY book") print(" python bookstorm.py # Resume last book") print(" python bookstorm.py book.epub --wav # Export to WAV files") print("\nPress 'o' in the reader to access options menu") if lastBook: print(f"\nNote: Last book no longer exists: {lastBook}") return 1 # Check if book exists if not Path(bookPath).exists(): print(f"Error: Book file not found: {bookPath}") return 1 # Handle export mode if args.wav: return export_to_wav(bookPath, config, args.outputDir) # Interactive reading mode config.set_last_book(bookPath) try: reader = BookReader(bookPath, config) reader.load_book() reader.run_interactive() except Exception as e: print(f"Error: {e}", file=sys.stderr) return 1 finally: if 'reader' in locals(): reader.cleanup() return 0 def export_to_wav(bookPath, config, outputDir=None): """ Export book to WAV files split by chapter Args: bookPath: Path to book file config: ConfigManager instance outputDir: Output directory (optional) Returns: Exit code """ from src.daisy_parser import DaisyParser from src.epub_parser import EpubParser from src.pdf_parser import PdfParser from src.txt_parser import TxtParser from src.tts_engine import TtsEngine import wave print(f"Exporting book to WAV: {bookPath}") # Parse book using appropriate parser bookPath = Path(bookPath) suffix = bookPath.suffix.lower() if suffix in ['.epub']: parser = EpubParser() elif suffix in ['.zip']: parser = DaisyParser() elif suffix in ['.pdf']: parser = PdfParser() elif suffix in ['.txt']: parser = TxtParser() else: print(f"Error: Unsupported book format: {suffix}") return 1 try: book = parser.parse(bookPath) except Exception as e: print(f"Error parsing book: {e}") return 1 # Determine output directory if outputDir is None: bookName = Path(bookPath).stem outputDir = Path(f"./{bookName}_audio") else: outputDir = Path(outputDir) outputDir.mkdir(parents=True, exist_ok=True) print(f"Output directory: {outputDir}") # Initialize TTS engine readerEngine = config.get_reader_engine() if readerEngine == 'speechd': print("Error: WAV export requires piper-tts. Set reader_engine=piper in config.") return 1 voiceModel = config.get_voice_model() tts = TtsEngine(voiceModel) print(f"Using voice: {voiceModel}") print(f"Chapters: {book.get_total_chapters()}") print() # Export each chapter for chapterIdx in range(book.get_total_chapters()): chapter = book.get_chapter(chapterIdx) if not chapter: continue chapterNum = chapterIdx + 1 print(f"Exporting Chapter {chapterNum}/{book.get_total_chapters()}: {chapter.title}") # Combine all paragraphs in chapter chapterText = "\n\n".join(chapter.paragraphs) # Generate audio try: wavData = tts.text_to_wav_data(chapterText) if not wavData: print(f" Warning: No audio generated for chapter {chapterNum}") continue # Save to file sanitizedTitle = "".join(c for c in chapter.title if c.isalnum() or c in (' ', '-', '_')).strip() if not sanitizedTitle: sanitizedTitle = f"Chapter_{chapterNum}" outputFile = outputDir / f"{chapterNum:03d}_{sanitizedTitle}.wav" with open(outputFile, 'wb') as f: f.write(wavData) print(f" Saved: {outputFile.name}") except Exception as e: print(f" Error generating audio for chapter {chapterNum}: {e}") continue parser.cleanup() print(f"\nExport complete! Files saved to: {outputDir}") return 0 if __name__ == '__main__': sys.exit(main())