commit 1d19ed377c96fc7fdf2e2443433dd4b80155df54 Author: Storm Dragon Date: Sat Oct 4 02:55:01 2025 -0400 Initial commit. diff --git a/bookstorm.py b/bookstorm.py new file mode 100755 index 0000000..5b8fb6f --- /dev/null +++ b/bookstorm.py @@ -0,0 +1,1198 @@ +#!/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()) diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..bc2fc9f --- /dev/null +++ b/src/__init__.py @@ -0,0 +1 @@ +# BookStorm source modules diff --git a/src/book.py b/src/book.py new file mode 100644 index 0000000..20aa886 --- /dev/null +++ b/src/book.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Book Data Structures + +Common book and chapter classes used by all parsers. +""" + + +class Book: + """Represents a parsed book with navigation and content""" + + def __init__(self, title="Untitled", author="Unknown"): + """ + Initialize book + + Args: + title: Book title + author: Book author + """ + self.title = title + self.author = author + self.chapters = [] # List of Chapter objects + + def add_chapter(self, chapter): + """Add a chapter to the book""" + self.chapters.append(chapter) + + def get_chapter(self, index): + """Get chapter by index""" + if 0 <= index < len(self.chapters): + return self.chapters[index] + return None + + def get_total_chapters(self): + """Get total number of chapters""" + return len(self.chapters) + + +class Chapter: + """Represents a single chapter with paragraphs""" + + def __init__(self, title="Untitled"): + """ + Initialize chapter + + Args: + title: Chapter title + """ + self.title = title + self.paragraphs = [] # List of paragraph strings + + def add_paragraph(self, text): + """Add a paragraph to the chapter""" + if text and text.strip(): + self.paragraphs.append(text.strip()) + + def get_paragraph(self, index): + """Get paragraph by index""" + if 0 <= index < len(self.paragraphs): + return self.paragraphs[index] + return None + + def get_total_paragraphs(self): + """Get total number of paragraphs""" + return len(self.paragraphs) diff --git a/src/book_selector.py b/src/book_selector.py new file mode 100644 index 0000000..5602f36 --- /dev/null +++ b/src/book_selector.py @@ -0,0 +1,319 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Book Selector + +Interactive file browser for selecting book files. +Supports navigation and filtering by supported formats. +""" + +from pathlib import Path +import zipfile + + +class BookSelector: + """Book file selection interface""" + + def __init__(self, startDir=None, supportedFormats=None, speechEngine=None): + """ + Initialize book selector + + Args: + startDir: Starting directory (default: home) + supportedFormats: List of supported file extensions (default: ['.zip', '.epub']) + speechEngine: SpeechEngine instance for accessibility + """ + if startDir is None: + startDir = Path.home() + + if supportedFormats is None: + supportedFormats = ['.zip', '.epub'] + + self.currentDir = Path(startDir).resolve() + self.supportedFormats = supportedFormats + self.speechEngine = speechEngine + self.currentSelection = 0 + self.inBrowser = False + self.items = [] + + def select_book_interactive(self): + """ + Interactive book selection with directory navigation + + Returns: + Selected book path or None if cancelled + """ + while True: + print(f"\nCurrent directory: {self.currentDir}") + print("-" * 60) + + # List directories and supported files + items = self._list_items() + + if not items: + print("No books or directories found") + print("\nCommands:") + print(" .. - Go to parent directory") + print(" q - Cancel") + print() + + choice = input("Select> ").strip() + if choice == 'q': + return None + elif choice == '..': + self._go_parent() + continue + + # Display items + for idx, item in enumerate(items): + prefix = "[DIR]" if item['isDir'] else "[BOOK]" + print(f"{idx + 1}. {prefix} {item['name']}") + + print("-" * 60) + print("\nCommands:") + print(" - Select item") + print(" .. - Go to parent directory") + print(" q - Cancel") + print() + + try: + choice = input("Select> ").strip() + + if choice == 'q': + return None + + elif choice == '..': + self._go_parent() + + else: + # Select item by number + try: + itemNum = int(choice) + if 1 <= itemNum <= len(items): + selectedItem = items[itemNum - 1] + + if selectedItem['isDir']: + # Navigate into directory + self.currentDir = selectedItem['path'] + else: + # Return selected book + return str(selectedItem['path']) + else: + print(f"Invalid number. Choose 1-{len(items)}") + + except ValueError: + print("Invalid input. Enter a number, '..' for parent, or 'q' to cancel") + + except (EOFError, KeyboardInterrupt): + print("\nCancelled") + return None + + def _list_items(self): + """ + List directories and supported book files in current directory + + Returns: + List of item dictionaries + """ + items = [] + + try: + # Add directories (excluding hidden) + for item in sorted(self.currentDir.iterdir()): + if item.name.startswith('.'): + continue + + if item.is_dir(): + items.append({ + 'name': item.name, + 'path': item, + 'isDir': True + }) + + # Add supported book files + for item in sorted(self.currentDir.iterdir()): + if item.name.startswith('.'): + continue + + if item.is_file() and item.suffix.lower() in self.supportedFormats: + # For zip files, validate that they're actually DAISY books + if item.suffix.lower() == '.zip': + if not self._is_daisy_zip(item): + continue # Skip non-DAISY zip files + + items.append({ + 'name': item.name, + 'path': item, + 'isDir': False + }) + + except PermissionError: + print(f"Permission denied: {self.currentDir}") + + return items + + def _go_parent(self): + """Navigate to parent directory""" + parent = self.currentDir.parent + if parent != self.currentDir: # Not at root + self.currentDir = parent + else: + print("Already at root directory") + + def _is_daisy_zip(self, zipPath): + """ + Check if a zip file contains a DAISY book + + Args: + zipPath: Path to zip file + + Returns: + True if zip contains DAISY book markers, False otherwise + """ + try: + with zipfile.ZipFile(zipPath, 'r') as zf: + fileList = zf.namelist() + + # Check for DAISY 2.02 marker (ncc.html) + if 'ncc.html' in fileList: + return True + + # Check for DAISY 3 marker (.ncx file) + for filename in fileList: + if filename.lower().endswith('.ncx'): + return True + + return False + except (zipfile.BadZipFile, PermissionError, OSError): + # If we can't read it, don't show it + return False + + def reset_to_directory(self, directory): + """ + Reset browser to a specific directory + + Args: + directory: Path to directory to reset to + """ + dirPath = Path(directory).resolve() + if dirPath.exists() and dirPath.is_dir(): + self.currentDir = dirPath + + def get_current_directory(self): + """ + Get current directory path + + Returns: + Path object of current directory + """ + return self.currentDir + + def enter_browser(self): + """Enter the book browser""" + self.inBrowser = True + self.currentSelection = 0 + self.items = self._list_items() + + if self.speechEngine: + self.speechEngine.speak("Book browser. Use arrow keys to navigate, Enter to select, Backspace for parent directory, L to set library, Escape to cancel.") + + # Speak current directory and first item + if self.speechEngine: + dirName = self.currentDir.name if self.currentDir.name else str(self.currentDir) + self.speechEngine.speak(f"Directory: {dirName}") + + if self.items: + self._speak_current_item() + elif self.speechEngine: + self.speechEngine.speak("Empty directory") + + def navigate_browser(self, direction): + """Navigate browser up or down""" + if not self.items: + return + + if direction == 'up': + self.currentSelection = (self.currentSelection - 1) % len(self.items) + elif direction == 'down': + self.currentSelection = (self.currentSelection + 1) % len(self.items) + + self._speak_current_item() + + def _speak_current_item(self): + """Speak current item with name first, then type""" + if not self.items or not self.speechEngine: + return + + item = self.items[self.currentSelection] + if item['isDir']: + text = f"{item['name']}, directory" + else: + text = f"{item['name']}, book" + + self.speechEngine.speak(text) + + def activate_current_item(self): + """ + Activate current item (enter directory or return book path) + + Returns: + Book path if selected, None if navigating or empty + """ + if not self.items: + if self.speechEngine: + self.speechEngine.speak("No items") + return None + + item = self.items[self.currentSelection] + + if item['isDir']: + # Navigate into directory + self.currentDir = item['path'] + self.currentSelection = 0 + self.items = self._list_items() + + if self.speechEngine: + dirName = self.currentDir.name if self.currentDir.name else str(self.currentDir) + self.speechEngine.speak(f"Entered directory: {dirName}") + + if self.items: + self._speak_current_item() + elif self.speechEngine: + self.speechEngine.speak("Empty directory") + + return None + else: + # Book selected + if self.speechEngine: + self.speechEngine.speak(f"Loading: {item['name']}") + return str(item['path']) + + def go_parent_directory(self): + """Go to parent directory""" + parent = self.currentDir.parent + if parent != self.currentDir: + self.currentDir = parent + self.currentSelection = 0 + self.items = self._list_items() + + if self.speechEngine: + dirName = self.currentDir.name if self.currentDir.name else str(self.currentDir) + self.speechEngine.speak(f"Parent directory: {dirName}") + + if self.items: + self._speak_current_item() + elif self.speechEngine: + self.speechEngine.speak("Empty directory") + else: + if self.speechEngine: + self.speechEngine.speak("Already at root") + + def is_in_browser(self): + """Check if currently in browser""" + return self.inBrowser + + def exit_browser(self): + """Exit the browser""" + self.inBrowser = False + if self.speechEngine: + self.speechEngine.speak("Cancelled") diff --git a/src/bookmark_manager.py b/src/bookmark_manager.py new file mode 100644 index 0000000..19961aa --- /dev/null +++ b/src/bookmark_manager.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Bookmark Manager + +Manages reading positions for books using SQLite for persistence. +Tracks chapter, paragraph, and sentence positions. +""" + +import sqlite3 +import hashlib +from pathlib import Path +from datetime import datetime + + +class BookmarkManager: + """Manages bookmarks for books""" + + def __init__(self, dbPath=None): + """ + Initialize bookmark manager + + Args: + dbPath: Path to SQLite database (default: ~/.bookstorm/bookmarks.db) + """ + if dbPath is None: + homePath = Path.home() + bookstormDir = homePath / ".bookstorm" + bookstormDir.mkdir(exist_ok=True) + dbPath = bookstormDir / "bookmarks.db" + + self.dbPath = dbPath + self._init_db() + + def _init_db(self): + """Initialize database schema""" + conn = sqlite3.connect(self.dbPath) + cursor = conn.cursor() + + cursor.execute(''' + CREATE TABLE IF NOT EXISTS bookmarks ( + book_id TEXT PRIMARY KEY, + book_path TEXT NOT NULL, + book_title TEXT, + chapter_index INTEGER NOT NULL DEFAULT 0, + paragraph_index INTEGER NOT NULL DEFAULT 0, + sentence_index INTEGER NOT NULL DEFAULT 0, + last_accessed TEXT, + created_at TEXT + ) + ''') + + conn.commit() + conn.close() + + def _get_book_id(self, bookPath): + """Generate unique book ID from file path""" + bookPath = str(Path(bookPath).resolve()) + return hashlib.sha256(bookPath.encode()).hexdigest()[:16] + + def save_bookmark(self, bookPath, bookTitle, chapterIndex, paragraphIndex, sentenceIndex=0): + """ + Save bookmark for a book + + Args: + bookPath: Path to book file + bookTitle: Title of the book + chapterIndex: Current chapter index + paragraphIndex: Current paragraph index + sentenceIndex: Current sentence index (default: 0) + """ + bookId = self._get_book_id(bookPath) + timestamp = datetime.now().isoformat() + + conn = sqlite3.connect(self.dbPath) + cursor = conn.cursor() + + cursor.execute(''' + INSERT OR REPLACE INTO bookmarks + (book_id, book_path, book_title, chapter_index, paragraph_index, + sentence_index, last_accessed, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, + COALESCE((SELECT created_at FROM bookmarks WHERE book_id = ?), ?)) + ''', (bookId, str(bookPath), bookTitle, chapterIndex, paragraphIndex, + sentenceIndex, timestamp, bookId, timestamp)) + + conn.commit() + conn.close() + + def get_bookmark(self, bookPath): + """ + Get bookmark for a book + + Args: + bookPath: Path to book file + + Returns: + Dictionary with bookmark data or None if not found + """ + bookId = self._get_book_id(bookPath) + + conn = sqlite3.connect(self.dbPath) + cursor = conn.cursor() + + cursor.execute(''' + SELECT chapter_index, paragraph_index, sentence_index, + book_title, last_accessed + FROM bookmarks + WHERE book_id = ? + ''', (bookId,)) + + row = cursor.fetchone() + conn.close() + + if row: + return { + 'chapterIndex': row[0], + 'paragraphIndex': row[1], + 'sentenceIndex': row[2], + 'bookTitle': row[3], + 'lastAccessed': row[4] + } + + return None + + def delete_bookmark(self, bookPath): + """ + Delete bookmark for a book + + Args: + bookPath: Path to book file + """ + bookId = self._get_book_id(bookPath) + + conn = sqlite3.connect(self.dbPath) + cursor = conn.cursor() + + cursor.execute('DELETE FROM bookmarks WHERE book_id = ?', (bookId,)) + + conn.commit() + conn.close() + + def list_bookmarks(self): + """ + List all bookmarks + + Returns: + List of dictionaries with bookmark data + """ + conn = sqlite3.connect(self.dbPath) + cursor = conn.cursor() + + cursor.execute(''' + SELECT book_path, book_title, chapter_index, paragraph_index, + sentence_index, last_accessed + FROM bookmarks + ORDER BY last_accessed DESC + ''') + + rows = cursor.fetchall() + conn.close() + + bookmarks = [] + for row in rows: + bookmarks.append({ + 'bookPath': row[0], + 'bookTitle': row[1], + 'chapterIndex': row[2], + 'paragraphIndex': row[3], + 'sentenceIndex': row[4], + 'lastAccessed': row[5] + }) + + return bookmarks diff --git a/src/config_manager.py b/src/config_manager.py new file mode 100644 index 0000000..772227f --- /dev/null +++ b/src/config_manager.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Config Manager + +Manages BookStorm settings using INI format. +Settings stored in ~/.config/stormux/bookstorm/settings.ini +""" + +import configparser +from pathlib import Path + + +class ConfigManager: + """Manages application configuration""" + + def __init__(self, configPath=None): + """ + Initialize config manager + + Args: + configPath: Path to config file (optional) + """ + if configPath is None: + homePath = Path.home() + configDir = homePath / ".config" / "stormux" / "bookstorm" + configDir.mkdir(parents=True, exist_ok=True) + configPath = configDir / "settings.ini" + + self.configPath = Path(configPath) + self.config = configparser.ConfigParser() + + # Load or create config + if self.configPath.exists(): + self.config.read(self.configPath) + else: + self._create_default_config() + + def _create_default_config(self): + """Create default configuration""" + self.config['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', + 'speechd_voice': '', + 'speechd_output_module': '', + 'speech_rate': '0' + } + + self.config['Reading'] = { + 'auto_advance': 'true', + 'auto_save_bookmark': 'true' + } + + self.config['Display'] = { + 'show_text': 'true' + } + + self.config['Paths'] = { + 'last_book': '', + 'books_directory': str(Path.home()), + 'library_directory': '' + } + + self.save() + + def get(self, section, key, fallback=None): + """ + Get configuration value + + Args: + section: Config section + key: Config key + fallback: Default value if not found + + Returns: + Configuration value + """ + try: + return self.config.get(section, key) + except (configparser.NoSectionError, configparser.NoOptionError): + return fallback + + def get_bool(self, section, key, fallback=False): + """ + Get boolean configuration value + + Args: + section: Config section + key: Config key + fallback: Default value if not found + + Returns: + Boolean configuration value + """ + try: + return self.config.getboolean(section, key) + except (configparser.NoSectionError, configparser.NoOptionError): + return fallback + + def set(self, section, key, value): + """ + Set configuration value + + Args: + section: Config section + key: Config key + value: Value to set + """ + if not self.config.has_section(section): + self.config.add_section(section) + + self.config.set(section, key, str(value)) + + def save(self): + """Save configuration to file""" + with open(self.configPath, 'w') as configFile: + self.config.write(configFile) + + def get_voice_model(self): + """Get configured voice model path""" + return self.get('TTS', 'voice_model') + + def set_voice_model(self, modelPath): + """Set voice model path""" + self.set('TTS', 'voice_model', str(modelPath)) + self.save() + + def get_voice_dir(self): + """Get voice models directory""" + return self.get('TTS', 'voice_dir', '/usr/share/piper-voices/en/en_US') + + def set_voice_dir(self, voiceDir): + """Set voice models directory""" + self.set('TTS', 'voice_dir', str(voiceDir)) + self.save() + + def get_last_book(self): + """Get last opened book path""" + lastBook = self.get('Paths', 'last_book') + return lastBook if lastBook else None + + def set_last_book(self, bookPath): + """Set last opened book path""" + self.set('Paths', 'last_book', str(bookPath)) + self.save() + + def get_books_directory(self): + """Get books directory for file browser""" + return self.get('Paths', 'books_directory', str(Path.home())) + + def set_books_directory(self, booksDir): + """Set books directory""" + self.set('Paths', 'books_directory', str(booksDir)) + self.save() + + def get_auto_advance(self): + """Get auto-advance setting""" + return self.get_bool('Reading', 'auto_advance', True) + + def get_auto_save(self): + """Get auto-save bookmark setting""" + return self.get_bool('Reading', 'auto_save_bookmark', True) + + def get_reader_engine(self): + """Get reader engine (piper or speechd)""" + return self.get('TTS', 'reader_engine', 'piper') + + def set_reader_engine(self, engine): + """Set reader engine (piper or speechd)""" + if engine in ['piper', 'speechd']: + self.set('TTS', 'reader_engine', engine) + self.save() + + def get_speechd_voice(self): + """Get speech-dispatcher voice""" + return self.get('TTS', 'speechd_voice', '') + + def set_speechd_voice(self, voice): + """Set speech-dispatcher voice""" + self.set('TTS', 'speechd_voice', str(voice)) + self.save() + + def get_speechd_output_module(self): + """Get speech-dispatcher output module""" + return self.get('TTS', 'speechd_output_module', '') + + def set_speechd_output_module(self, module): + """Set speech-dispatcher output module""" + self.set('TTS', 'speechd_output_module', str(module)) + self.save() + + def get_speech_rate(self): + """Get speech rate""" + try: + return int(self.get('TTS', 'speech_rate', '0')) + except ValueError: + return 0 + + def set_speech_rate(self, rate): + """Set speech rate""" + self.set('TTS', 'speech_rate', str(rate)) + self.save() + + def get_show_text(self): + """Get show text display setting""" + return self.get_bool('Display', 'show_text', True) + + def set_show_text(self, enabled): + """Set show text display setting""" + self.set('Display', 'show_text', str(enabled).lower()) + self.save() + + def get_library_directory(self): + """Get library directory (default starting point for book browser)""" + return self.get('Paths', 'library_directory', '') + + def set_library_directory(self, libraryDir): + """Set library directory""" + self.set('Paths', 'library_directory', str(libraryDir)) + self.save() diff --git a/src/daisy_parser.py b/src/daisy_parser.py new file mode 100644 index 0000000..a4d455f --- /dev/null +++ b/src/daisy_parser.py @@ -0,0 +1,324 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +DAISY Book Parser + +Handles parsing of DAISY 2.02 and DAISY 3 book formats. +Extracts structure and content for text-to-speech playback. +""" + +import zipfile +import tempfile +import shutil +from pathlib import Path +from bs4 import BeautifulSoup +import re +from src.book import Book, Chapter + + +class DaisyParser: + """Parser for DAISY format books""" + + def __init__(self): + self.tempDir = None + + def parse(self, daisyPath): + """ + Parse a DAISY book (zip file) + + Args: + daisyPath: Path to DAISY zip file + + Returns: + Book object + """ + daisyPath = Path(daisyPath) + + if not daisyPath.exists(): + raise FileNotFoundError(f"DAISY file not found: {daisyPath}") + + # Extract zip to temp directory + self.tempDir = tempfile.mkdtemp(prefix="daisy_") + tempPath = Path(self.tempDir) + + try: + with zipfile.ZipFile(daisyPath, 'r') as zipRef: + zipRef.extractall(tempPath) + + # Detect DAISY version and parse accordingly + if (tempPath / "ncc.html").exists(): + return self._parse_daisy2(tempPath) + elif (tempPath / "navigation.ncx").exists() or list(tempPath.glob("*.ncx")): + return self._parse_daisy3(tempPath) + else: + raise ValueError("Unknown DAISY format: no ncc.html or navigation.ncx found") + + except Exception as e: + self.cleanup() + raise e + + def _parse_daisy2(self, basePath): + """Parse DAISY 2.02 format (NCC.html based)""" + nccPath = basePath / "ncc.html" + + with open(nccPath, 'r', encoding='utf-8', errors='ignore') as f: + soup = BeautifulSoup(f.read(), 'html.parser') + + # Get title + titleTag = soup.find('title') + bookTitle = titleTag.get_text().strip() if titleTag else "Unknown Title" + + # Find all headings (h1-h6) which represent navigation points + headings = soup.find_all(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']) + + chapters = [] + for heading in headings: + # Get chapter title + chapterTitle = heading.get_text().strip() + + # Find linked content file + link = heading.find('a') + if not link or not link.get('href'): + continue + + contentHref = link.get('href') + contentPath = basePath / contentHref.split('#')[0] + + if contentPath.exists(): + paragraphs = self._extract_paragraphs(contentPath) + if paragraphs: + chapter = Chapter(chapterTitle) + chapter.paragraphs = paragraphs + chapters.append(chapter) + + book = Book(bookTitle) + for chapter in chapters: + book.add_chapter(chapter) + return book + + def _parse_daisy3(self, basePath): + """Parse DAISY 3 format (NCX based)""" + # Find NCX file for title + ncxFiles = list(basePath.glob("*.ncx")) + if not ncxFiles: + ncxFiles = [basePath / "navigation.ncx"] + + ncxPath = ncxFiles[0] + + with open(ncxPath, 'r', encoding='utf-8', errors='ignore') as f: + soup = BeautifulSoup(f.read(), features='xml') + + # Get title + titleTag = soup.find('docTitle') + if titleTag: + textTag = titleTag.find('text') + bookTitle = textTag.get_text().strip() if textTag else "Unknown Title" + else: + bookTitle = "Unknown Title" + + # Find DTBook XML file (main content) + dtbookFiles = list(basePath.glob("*.xml")) + # Exclude navigation.ncx if it was named .xml + dtbookFiles = [f for f in dtbookFiles if not f.name.endswith('.ncx')] + + if dtbookFiles: + # Try to parse DTBook using NCX navigation structure first + chapters = self._parse_dtbook_with_ncx(dtbookFiles[0], basePath, soup) + + if not chapters: + # Fallback: Parse DTBook XML directly for content + chapters = self._parse_dtbook(dtbookFiles[0]) + else: + # Fallback to old method for HTML-based DAISY + chapters = self._parse_daisy3_html(basePath, soup) + + book = Book(bookTitle) + for chapter in chapters: + book.add_chapter(chapter) + return book + + def _parse_daisy3_html(self, basePath, ncxSoup): + """Parse DAISY 3 with HTML content files (fallback)""" + navPoints = ncxSoup.find_all('navPoint') + + chapters = [] + for navPoint in navPoints: + # Get chapter title + navLabel = navPoint.find('navLabel') + if navLabel: + textTag = navLabel.find('text') + chapterTitle = textTag.get_text().strip() if textTag else "Untitled Chapter" + else: + chapterTitle = "Untitled Chapter" + + # Find content source + content = navPoint.find('content') + if not content or not content.get('src'): + continue + + contentSrc = content.get('src') + contentPath = basePath / contentSrc.split('#')[0] + + if contentPath.exists(): + paragraphs = self._extract_paragraphs(contentPath) + if paragraphs: + chapter = Chapter(chapterTitle) + chapter.paragraphs = paragraphs + chapters.append(chapter) + + return chapters + + def _parse_dtbook_with_ncx(self, dtbookPath, basePath, ncxSoup): + """ + Parse DTBook using NCX navigation structure + + Args: + dtbookPath: Path to DTBook XML file + basePath: Base directory path + ncxSoup: BeautifulSoup object of parsed NCX + + Returns: + List of Chapter objects or None if parsing fails + """ + try: + # Load DTBook content + with open(dtbookPath, 'r', encoding='utf-8', errors='ignore') as f: + dtbookSoup = BeautifulSoup(f.read(), features='xml') + + # Find all top-level navPoints (chapters) + navMap = ncxSoup.find('navMap') + if not navMap: + return None + + chapters = [] + for navPoint in navMap.find_all('navPoint', recursive=False): + # Get chapter title + navLabel = navPoint.find('navLabel') + if navLabel: + textTag = navLabel.find('text') + chapterTitle = textTag.get_text().strip() if textTag else "Untitled" + else: + chapterTitle = "Untitled" + + # Get content source + content = navPoint.find('content') + if not content or not content.get('src'): + continue + + contentSrc = content.get('src') + + # Extract fragment identifier (anchor) + parts = contentSrc.split('#') + anchor = parts[1] if len(parts) > 1 else None + + if not anchor: + continue + + # Find the element in DTBook by ID + section = dtbookSoup.find(id=anchor) + if not section: + continue + + # Extract paragraphs from this section + paragraphs = [] + for p in section.find_all('p'): + text = p.get_text().strip() + text = re.sub(r'\s+', ' ', text) + if text: + paragraphs.append(text) + + if paragraphs: + chapter = Chapter(chapterTitle) + chapter.paragraphs = paragraphs + chapters.append(chapter) + + return chapters if chapters else None + + except Exception as e: + print(f"Error parsing DTBook with NCX: {e}") + return None + + def _parse_dtbook(self, dtbookPath): + """Parse DTBook XML format""" + with open(dtbookPath, 'r', encoding='utf-8', errors='ignore') as f: + soup = BeautifulSoup(f.read(), features='xml') + + chapters = [] + + # Find all level1 elements (top-level sections) + level1Elements = soup.find_all('level1') + + for level1 in level1Elements: + # Get chapter title from h1, h2, or id + chapterTitle = None + + # Try to find heading + for hTag in ['h1', 'h2', 'h3']: + heading = level1.find(hTag) + if heading: + chapterTitle = heading.get_text().strip() + break + + # Fallback to id + if not chapterTitle: + chapterTitle = level1.get('id', 'Untitled Chapter') + + # Extract paragraphs from this level1 + paragraphs = [] + for p in level1.find_all('p'): + text = p.get_text().strip() + text = re.sub(r'\s+', ' ', text) + if text: + paragraphs.append(text) + + if paragraphs: + chapter = Chapter(chapterTitle) + chapter.paragraphs = paragraphs + chapters.append(chapter) + + return chapters + + def _extract_paragraphs(self, htmlPath): + """Extract paragraphs from HTML content file""" + with open(htmlPath, 'r', encoding='utf-8', errors='ignore') as f: + soup = BeautifulSoup(f.read(), 'html.parser') + + paragraphs = [] + + # Find all paragraph tags + for p in soup.find_all('p'): + text = p.get_text().strip() + # Clean up whitespace + text = re.sub(r'\s+', ' ', text) + if text: + paragraphs.append(text) + + # If no

tags, try divs or just get all text + if not paragraphs: + # Try divs + for div in soup.find_all('div'): + text = div.get_text().strip() + text = re.sub(r'\s+', ' ', text) + if text and len(text) > 10: # Avoid tiny fragments + paragraphs.append(text) + + # Last resort: split body text by double newlines + if not paragraphs: + body = soup.find('body') + if body: + text = body.get_text() + # Split on multiple newlines or periods followed by newline + chunks = re.split(r'\n\n+', text) + for chunk in chunks: + chunk = chunk.strip() + chunk = re.sub(r'\s+', ' ', chunk) + if chunk: + paragraphs.append(chunk) + + return paragraphs + + def cleanup(self): + """Clean up temporary files""" + if self.tempDir and Path(self.tempDir).exists(): + shutil.rmtree(self.tempDir) + self.tempDir = None diff --git a/src/epub_parser.py b/src/epub_parser.py new file mode 100644 index 0000000..ac03d63 --- /dev/null +++ b/src/epub_parser.py @@ -0,0 +1,426 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +EPUB Parser + +Parses EPUB format ebooks and extracts text content. +EPUB files are ZIP archives containing XHTML/HTML content. +""" + +import zipfile +import tempfile +import shutil +from pathlib import Path +from bs4 import BeautifulSoup +from src.book import Book, Chapter + + +class EpubParser: + """Parser for EPUB format ebooks""" + + def __init__(self): + """Initialize EPUB parser""" + self.tempDir = None + + def parse(self, epubPath): + """ + Parse EPUB file + + Args: + epubPath: Path to EPUB file + + Returns: + Book object + """ + epubPath = Path(epubPath) + + # Create temp directory for extraction + self.tempDir = tempfile.mkdtemp(prefix='bookstorm_epub_') + tempPath = Path(self.tempDir) + + try: + # Extract EPUB (it's a ZIP file) + with zipfile.ZipFile(epubPath, 'r') as zipRef: + zipRef.extractall(tempPath) + + # Find content.opf file + opfPath = self._find_opf(tempPath) + if not opfPath: + raise ValueError("Could not find content.opf in EPUB") + + # Parse OPF to get metadata and spine + metadata, spine, manifest = self._parse_opf(opfPath) + + # Create book + book = Book() + book.title = metadata.get('title', epubPath.stem) + book.author = metadata.get('creator', 'Unknown') + + # Try to use TOC structure first + opfDir = opfPath.parent + tocChapters = self._parse_toc_structure(tempPath, opfDir, manifest) + + if tocChapters: + # Successfully parsed using TOC + for chapter in tocChapters: + book.add_chapter(chapter) + else: + # Fallback: Parse content files in spine order + for itemId in spine: + if itemId in manifest: + contentPath = opfDir / manifest[itemId] + if contentPath.exists(): + chapters = self._parse_content_file(contentPath) + for chapter in chapters: + book.add_chapter(chapter) + + return book + + except Exception as e: + raise Exception(f"Error parsing EPUB: {e}") + + def _find_opf(self, epubDir): + """ + Find content.opf file in EPUB + + Args: + epubDir: Extracted EPUB directory + + Returns: + Path to content.opf or None + """ + # Check container.xml first + containerPath = epubDir / 'META-INF' / 'container.xml' + if containerPath.exists(): + try: + with open(containerPath, 'r', encoding='utf-8') as f: + soup = BeautifulSoup(f.read(), features='xml') + rootfile = soup.find('rootfile') + if rootfile and rootfile.get('full-path'): + return epubDir / rootfile['full-path'] + except Exception: + pass + + # Fallback: search for .opf files + for opfFile in epubDir.rglob('*.opf'): + return opfFile + + return None + + def _parse_opf(self, opfPath): + """ + Parse OPF file to get metadata, spine, and manifest + + Args: + opfPath: Path to content.opf + + Returns: + Tuple of (metadata dict, spine list, manifest dict) + """ + with open(opfPath, 'r', encoding='utf-8', errors='ignore') as f: + soup = BeautifulSoup(f.read(), features='xml') + + # Extract metadata + metadata = {} + metadataTag = soup.find('metadata') + if metadataTag: + title = metadataTag.find('dc:title') + if title: + metadata['title'] = title.get_text(strip=True) + + creator = metadataTag.find('dc:creator') + if creator: + metadata['creator'] = creator.get_text(strip=True) + + # Extract manifest (id -> href mapping) + manifest = {} + manifestTag = soup.find('manifest') + if manifestTag: + for item in manifestTag.find_all('item'): + itemId = item.get('id') + href = item.get('href') + if itemId and href: + manifest[itemId] = href + + # Extract spine (reading order) + spine = [] + spineTag = soup.find('spine') + if spineTag: + for itemref in spineTag.find_all('itemref'): + idref = itemref.get('idref') + if idref: + spine.append(idref) + + return metadata, spine, manifest + + def _parse_toc_structure(self, epubDir, opfDir, manifest): + """ + Parse TOC structure (NCX or nav.xhtml) to get chapters + + Args: + epubDir: Root EPUB directory + opfDir: Directory containing OPF file + manifest: Manifest dict from OPF + + Returns: + List of Chapter objects or None if TOC not found + """ + # Try EPUB 3 nav.xhtml first + navChapters = self._parse_nav_xhtml(epubDir, opfDir, manifest) + if navChapters: + return navChapters + + # Try EPUB 2 NCX + ncxChapters = self._parse_ncx(epubDir, opfDir, manifest) + if ncxChapters: + return ncxChapters + + return None + + def _parse_nav_xhtml(self, epubDir, opfDir, manifest): + """ + Parse EPUB 3 nav.xhtml for TOC structure + + Args: + epubDir: Root EPUB directory + opfDir: Directory containing OPF file + manifest: Manifest dict from OPF + + Returns: + List of Chapter objects or None + """ + # Find nav document in manifest + navPath = None + for itemId, href in manifest.items(): + if 'nav' in href.lower() and href.endswith(('.xhtml', '.html')): + navPath = opfDir / href + if navPath.exists(): + break + + if not navPath or not navPath.exists(): + return None + + try: + with open(navPath, 'r', encoding='utf-8', errors='ignore') as f: + soup = BeautifulSoup(f.read(), 'html.parser') + + # Find TOC nav element + tocNav = soup.find('nav', attrs={'epub:type': 'toc'}) + if not tocNav: + tocNav = soup.find('nav', id='toc') + if not tocNav: + return None + + # Extract chapters from nav list + chapters = [] + for link in tocNav.find_all('a', href=True): + chapterTitle = link.get_text(strip=True) + href = link.get('href') + + if not chapterTitle or not href: + continue + + # Extract content from href location + paragraphs = self._extract_content_from_href(opfDir, href) + if paragraphs: + chapter = Chapter(chapterTitle) + chapter.paragraphs = paragraphs + chapters.append(chapter) + + return chapters if chapters else None + + except Exception as e: + print(f"Error parsing nav.xhtml: {e}") + return None + + def _parse_ncx(self, epubDir, opfDir, manifest): + """ + Parse EPUB 2 NCX file for TOC structure + + Args: + epubDir: Root EPUB directory + opfDir: Directory containing OPF file + manifest: Manifest dict from OPF + + Returns: + List of Chapter objects or None + """ + # Find NCX file in manifest + ncxPath = None + for itemId, href in manifest.items(): + if href.endswith('.ncx'): + ncxPath = opfDir / href + if ncxPath.exists(): + break + + # Fallback: search for any .ncx file + if not ncxPath or not ncxPath.exists(): + ncxFiles = list(epubDir.rglob('*.ncx')) + if ncxFiles: + ncxPath = ncxFiles[0] + + if not ncxPath or not ncxPath.exists(): + return None + + try: + with open(ncxPath, 'r', encoding='utf-8', errors='ignore') as f: + soup = BeautifulSoup(f.read(), features='xml') + + # Find all navPoints (top-level only) + navMap = soup.find('navMap') + if not navMap: + return None + + chapters = [] + for navPoint in navMap.find_all('navPoint', recursive=False): + # Get chapter title + navLabel = navPoint.find('navLabel') + if navLabel: + textTag = navLabel.find('text') + chapterTitle = textTag.get_text(strip=True) if textTag else "Untitled" + else: + chapterTitle = "Untitled" + + # Get content source + content = navPoint.find('content') + if not content or not content.get('src'): + continue + + href = content.get('src') + + # Extract content from href location + paragraphs = self._extract_content_from_href(opfDir, href) + if paragraphs: + chapter = Chapter(chapterTitle) + chapter.paragraphs = paragraphs + chapters.append(chapter) + + return chapters if chapters else None + + except Exception as e: + print(f"Error parsing NCX: {e}") + return None + + def _extract_content_from_href(self, opfDir, href): + """ + Extract paragraphs from a specific href location + + Args: + opfDir: Directory containing OPF file + href: Content href (may include #anchor) + + Returns: + List of paragraph strings + """ + # Split href into file and anchor + parts = href.split('#') + filePath = opfDir / parts[0] + anchor = parts[1] if len(parts) > 1 else None + + if not filePath.exists(): + return [] + + try: + with open(filePath, 'r', encoding='utf-8', errors='ignore') as f: + soup = BeautifulSoup(f.read(), 'html.parser') + + # If anchor specified, find that element + if anchor: + section = soup.find(id=anchor) + if not section: + # Try to find by name attribute + section = soup.find(attrs={'name': anchor}) + if not section: + # Fallback to entire body + section = soup.find('body') or soup + else: + section = soup.find('body') or soup + + # Extract paragraphs from section + paragraphs = [] + for p in section.find_all('p'): + text = p.get_text(strip=True) + if text: + paragraphs.append(text) + + return paragraphs + + except Exception as e: + print(f"Error extracting content from {href}: {e}") + return [] + + def _parse_content_file(self, contentPath): + """ + Parse XHTML/HTML content file + + Args: + contentPath: Path to content file + + Returns: + List of Chapter objects + """ + try: + with open(contentPath, 'r', encoding='utf-8', errors='ignore') as f: + soup = BeautifulSoup(f.read(), 'html.parser') + except Exception as e: + print(f"Error reading content file {contentPath}: {e}") + return [] + + chapters = [] + + # Look for main content sections + # Try h1, h2, section elements + sections = soup.find_all(['section', 'div'], class_=lambda x: x and 'section' in x.lower() if x else False) + + if not sections: + # Fallback: treat entire file as one chapter + sections = [soup.find('body') or soup] + + for section in sections: + # Find chapter title + title = None + for hTag in ['h1', 'h2', 'h3']: + heading = section.find(hTag) + if heading: + title = heading.get_text(strip=True) + break + + if not title: + title = contentPath.stem + + # Extract paragraphs + paragraphs = [] + for p in section.find_all('p'): + text = p.get_text(strip=True) + if text: + paragraphs.append(text) + + # Only add chapter if it has content + if paragraphs: + chapter = Chapter(title) + chapter.paragraphs = paragraphs + chapters.append(chapter) + + # If no chapters found, extract all paragraphs as one chapter + if not chapters: + title = contentPath.stem + paragraphs = [] + for p in soup.find_all('p'): + text = p.get_text(strip=True) + if text: + paragraphs.append(text) + + if paragraphs: + chapter = Chapter(title) + chapter.paragraphs = paragraphs + chapters.append(chapter) + + return chapters + + def cleanup(self): + """Clean up temporary files""" + if self.tempDir and Path(self.tempDir).exists(): + try: + shutil.rmtree(self.tempDir) + except Exception as e: + print(f"Warning: Could not remove temp directory: {e}") diff --git a/src/options_menu.py b/src/options_menu.py new file mode 100644 index 0000000..20f7cea --- /dev/null +++ b/src/options_menu.py @@ -0,0 +1,490 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Options Menu + +Interactive menu system for BookStorm settings. +Inspired by soundstorm's hierarchical menu system. +""" + +from pathlib import Path +from src.tts_engine import TtsEngine + + +class OptionsMenu: + """Options menu for configuring BookStorm settings""" + + def __init__(self, config, speechEngine, voiceSelector, audioPlayer, ttsReloadCallback=None): + """ + Initialize options menu + + Args: + config: ConfigManager instance + speechEngine: SpeechEngine instance + voiceSelector: VoiceSelector instance + audioPlayer: PygamePlayer instance + ttsReloadCallback: Optional callback to reload TTS engine + """ + self.config = config + self.speechEngine = speechEngine + self.voiceSelector = voiceSelector + self.audioPlayer = audioPlayer + self.ttsReloadCallback = ttsReloadCallback + self.currentSelection = 0 + self.inMenu = False + + def show_main_menu(self): + """ + Show main options menu + + Returns: + Menu items as list of dicts + """ + readerEngine = self.config.get_reader_engine() + readerEngineText = "Piper-TTS" if readerEngine == "piper" else "Speech-Dispatcher" + + menuItems = [ + { + 'label': f"Reader Engine: {readerEngineText}", + 'action': 'toggle_reader_engine' + }, + { + 'label': "Select Reader Voice", + 'action': 'select_voice' + } + ] + + # Add output module selection only when using speech-dispatcher + if readerEngine == 'speechd': + menuItems.append({ + 'label': "Select Speech Engine", + 'action': 'select_output_module' + }) + + # Add text display toggle + showText = self.config.get_show_text() + showTextLabel = "Show Text Display: On" if showText else "Show Text Display: Off" + + menuItems.extend([ + { + 'label': showTextLabel, + 'action': 'toggle_show_text' + }, + { + 'label': "Speech Rate Settings", + 'action': 'speech_rate' + }, + { + 'label': "Back", + 'action': 'back' + } + ]) + + return menuItems + + def navigate_menu(self, direction): + """ + Navigate menu up or down + + Args: + direction: 'up' or 'down' + + Returns: + Current menu item + """ + menuItems = self.show_main_menu() + + if direction == 'up': + self.currentSelection = (self.currentSelection - 1) % len(menuItems) + elif direction == 'down': + self.currentSelection = (self.currentSelection + 1) % len(menuItems) + + currentItem = menuItems[self.currentSelection] + self.speechEngine.speak(currentItem['label']) + return currentItem + + def activate_current_item(self): + """ + Activate currently selected menu item + + Returns: + True to stay in menu, False to exit + """ + menuItems = self.show_main_menu() + currentItem = menuItems[self.currentSelection] + action = currentItem['action'] + + if action == 'toggle_reader_engine': + return self._toggle_reader_engine() + elif action == 'select_voice': + return self._select_voice() + elif action == 'select_output_module': + return self._select_output_module() + elif action == 'toggle_show_text': + return self._toggle_show_text() + elif action == 'speech_rate': + return self._speech_rate_info() + elif action == 'back': + self.speechEngine.speak("Closing options menu") + return False + + return True + + def _toggle_reader_engine(self): + """Toggle between piper-tts and speech-dispatcher""" + currentEngine = self.config.get_reader_engine() + + 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." + + # Reload TTS engine if callback available + needsRestart = False + if self.ttsReloadCallback: + try: + self.ttsReloadCallback() + except Exception as e: + print(f"Error reloading TTS engine: {e}") + needsRestart = True + else: + needsRestart = True + + if needsRestart: + # Show restart confirmation dialog + self.previousEngine = oldEngine + self.inRestartMenu = True + self.restartSelection = 0 + message = "Restart required. Restart now or cancel?" + self.speechEngine.speak(message) + print(message) + # Speak first option + self.speechEngine.speak("Restart now") + else: + self.speechEngine.speak(message) + print(message) + + return True + + def _select_voice(self): + """Select voice based on current reader engine""" + readerEngine = self.config.get_reader_engine() + + if readerEngine == 'piper': + return self._select_piper_voice() + else: + return self._select_speechd_voice() + + 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.") + + voices = self.voiceSelector.get_voices() + if not voices: + self.speechEngine.speak("No piper voices found.") + return True + + # Store current selection for voice browsing + self.voiceSelection = 0 + self.voiceList = voices + self.inVoiceMenu = True + + # Speak first voice using piper + try: + voice = voices[0] + tts = TtsEngine(voice['path']) + + voiceName = voice['name'].split('(')[0].strip() + text = f"Hi, my name is {voiceName}" + wavData = tts.text_to_wav_data(text) + + if wavData: + self.audioPlayer.play_wav_data(wavData) + except Exception as e: + # Fallback to speech-dispatcher + print(f"Error playing voice sample: {e}") + self.speechEngine.speak(voices[0]['name']) + + return True + + def _select_speechd_voice(self): + """Select speech-dispatcher voice""" + voices = self.speechEngine.list_voices() + + if not voices: + self.speechEngine.speak("No speech dispatcher voices available.") + return True + + self.speechEngine.speak("Selecting speech dispatcher voice. Use arrow keys to browse, enter to select, escape to cancel.") + + # Store current selection for voice browsing + self.voiceSelection = 0 + self.voiceList = voices + self.inVoiceMenu = True + + # Speak first voice + if len(voices) > 0: + voice = voices[0] + if isinstance(voice, tuple): + voiceName = f"{voice[0]} ({voice[1]})" + else: + voiceName = str(voice) + self.speechEngine.speak(voiceName) + + return True + + def navigate_voice_menu(self, direction): + """Navigate voice selection menu""" + if not hasattr(self, 'voiceList') or not self.voiceList: + return + + if direction == 'up': + self.voiceSelection = (self.voiceSelection - 1) % len(self.voiceList) + elif direction == 'down': + self.voiceSelection = (self.voiceSelection + 1) % len(self.voiceList) + + # Speak current voice + voice = self.voiceList[self.voiceSelection] + readerEngine = self.config.get_reader_engine() + + if readerEngine == 'piper': + # Use piper to speak the voice name with that voice + try: + tts = TtsEngine(voice['path']) + + # Generate voice speaking its own name + voiceName = voice['name'].split('(')[0].strip() + text = f"Hi, my name is {voiceName}" + wavData = tts.text_to_wav_data(text) + + if wavData: + self.audioPlayer.play_wav_data(wavData) + except Exception as e: + # Fallback to speech-dispatcher if error + print(f"Error playing voice sample: {e}") + self.speechEngine.speak(voice['name']) + else: + # Format speech-dispatcher voice tuple + if isinstance(voice, tuple): + voiceName = f"{voice[0]} ({voice[1]})" + else: + voiceName = str(voice) + self.speechEngine.speak(voiceName) + + def select_current_voice(self): + """Select the currently highlighted voice""" + if not hasattr(self, 'voiceList') or not self.voiceList: + return True + + voice = self.voiceList[self.voiceSelection] + readerEngine = self.config.get_reader_engine() + + if readerEngine == 'piper': + # Save piper voice + self.config.set_voice_model(voice['path']) + message = f"Voice selected: {voice['name']}." + + # Reload TTS engine if callback available + if self.ttsReloadCallback: + try: + self.ttsReloadCallback() + except Exception as e: + print(f"Error reloading TTS engine: {e}") + message += " Restart required." + else: + message += " Restart required." + + self.speechEngine.speak(message) + print(message) + else: + # Save speechd voice - extract name from tuple + if isinstance(voice, tuple): + voiceName = voice[0] # First element is the voice name + displayName = f"{voice[0]} ({voice[1]})" + else: + voiceName = str(voice) + displayName = voiceName + + self.config.set_speechd_voice(voiceName) + self.speechEngine.set_voice(voiceName) + message = f"Voice selected: {displayName}" + self.speechEngine.speak(message) + print(message) + + # Exit voice menu + self.inVoiceMenu = False + return True + + def _select_output_module(self): + """Select speech-dispatcher output module""" + modules = self.speechEngine.list_output_modules() + + if not modules: + self.speechEngine.speak("No output modules available.") + return True + + self.speechEngine.speak("Selecting speech engine. Use arrow keys to browse, enter to select, escape to cancel.") + + # Store current selection for module browsing + self.moduleSelection = 0 + self.moduleList = modules + self.inModuleMenu = True + + # Speak first module + if len(modules) > 0: + self.speechEngine.speak(modules[0]) + + return True + + def navigate_module_menu(self, direction): + """Navigate output module selection menu""" + if not hasattr(self, 'moduleList') or not self.moduleList: + return + + if direction == 'up': + self.moduleSelection = (self.moduleSelection - 1) % len(self.moduleList) + elif direction == 'down': + self.moduleSelection = (self.moduleSelection + 1) % len(self.moduleList) + + # Speak current module + module = self.moduleList[self.moduleSelection] + self.speechEngine.speak(module) + + def select_current_module(self): + """Select the currently highlighted output module""" + if not hasattr(self, 'moduleList') or not self.moduleList: + return True + + module = self.moduleList[self.moduleSelection] + + # Save and set output module + self.config.set_speechd_output_module(module) + self.speechEngine.set_output_module(module) + message = f"Speech engine selected: {module}" + self.speechEngine.speak(message) + print(message) + + # Exit module menu + self.inModuleMenu = False + return True + + def exit_module_menu(self): + """Exit output module selection menu""" + self.inModuleMenu = False + self.speechEngine.speak("Cancelled. Back to options menu.") + # Speak current main menu item + menuItems = self.show_main_menu() + if menuItems and self.currentSelection < len(menuItems): + self.speechEngine.speak(menuItems[self.currentSelection]['label']) + + def is_in_module_menu(self): + """Check if currently in output module selection submenu""" + return hasattr(self, 'inModuleMenu') and self.inModuleMenu + + def is_in_restart_menu(self): + """Check if currently in restart confirmation dialog""" + return hasattr(self, 'inRestartMenu') and self.inRestartMenu + + def navigate_restart_menu(self, direction): + """Navigate restart confirmation menu""" + # Toggle between "Restart now" (0) and "Cancel" (1) + if direction == 'up' or direction == 'down': + self.restartSelection = 1 - self.restartSelection + + # Speak current option + if self.restartSelection == 0: + self.speechEngine.speak("Restart now") + else: + self.speechEngine.speak("Cancel") + + def select_restart_option(self): + """Handle restart menu selection""" + if self.restartSelection == 0: + # Restart now - exit cleanly + self.speechEngine.speak("Restarting. Please run bookstorm again.") + import sys + sys.exit(0) + else: + # Cancel - revert engine change + self.config.set_reader_engine(self.previousEngine) + self.inRestartMenu = False + self.speechEngine.speak("Cancelled. Engine change reverted.") + # Speak current main menu item + menuItems = self.show_main_menu() + if menuItems and self.currentSelection < len(menuItems): + self.speechEngine.speak(menuItems[self.currentSelection]['label']) + return True + + def exit_restart_menu(self): + """Exit restart confirmation menu (same as cancel)""" + self.config.set_reader_engine(self.previousEngine) + self.inRestartMenu = False + self.speechEngine.speak("Cancelled. Engine change reverted.") + # Speak current main menu item + menuItems = self.show_main_menu() + if menuItems and self.currentSelection < len(menuItems): + self.speechEngine.speak(menuItems[self.currentSelection]['label']) + + def _toggle_show_text(self): + """Toggle text display on/off""" + currentSetting = self.config.get_show_text() + newSetting = not currentSetting + self.config.set_show_text(newSetting) + + if newSetting: + message = "Text display: On" + else: + message = "Text display: Off" + + self.speechEngine.speak(message) + print(message) + return True + + def _speech_rate_info(self): + """Show speech rate information""" + currentRate = self.config.get_speech_rate() + message = f"Current speech rate: {currentRate}. Use Page Up and Page Down to adjust during reading." + self.speechEngine.speak(message) + print(message) + return True + + def enter_menu(self): + """Enter the options menu""" + self.inMenu = True + self.currentSelection = 0 + self.inVoiceMenu = False + self.speechEngine.speak("Options menu. Use arrow keys to navigate, Enter to select, Escape to close.") + + # Speak first item + menuItems = self.show_main_menu() + if menuItems: + self.speechEngine.speak(menuItems[0]['label']) + + def is_in_menu(self): + """Check if currently in menu""" + return self.inMenu + + def is_in_voice_menu(self): + """Check if currently in voice selection submenu""" + return hasattr(self, 'inVoiceMenu') and self.inVoiceMenu + + def exit_voice_menu(self): + """Exit voice selection menu""" + self.inVoiceMenu = False + self.speechEngine.speak("Cancelled. Back to options menu.") + # Speak current main menu item + menuItems = self.show_main_menu() + if menuItems and self.currentSelection < len(menuItems): + self.speechEngine.speak(menuItems[self.currentSelection]['label']) + + def exit_menu(self): + """Exit the menu""" + self.inMenu = False + self.inVoiceMenu = False + self.currentSelection = 0 diff --git a/src/pdf_parser.py b/src/pdf_parser.py new file mode 100644 index 0000000..23e6860 --- /dev/null +++ b/src/pdf_parser.py @@ -0,0 +1,94 @@ +"""PDF parser for BookStorm - extracts text from PDF files.""" + +import re +from pathlib import Path +from pypdf import PdfReader +from src.book import Book, Chapter + + +def clean_hyphenated_text(text): + """Clean up hyphenated line breaks in text. + + Converts 'word-\nword' to 'word' to fix PDF line wrapping. + """ + # Remove hyphens at end of lines followed by continuation + text = re.sub(r'-\s*\n\s*', '', text) + return text + + +def split_into_paragraphs(text): + """Split text into paragraphs using double newlines. + + Falls back to single newlines if no double newlines found. + """ + # Try splitting on double newlines first + paragraphs = re.split(r'\n\s*\n', text) + + # Clean up whitespace + paragraphs = [p.strip() for p in paragraphs if p.strip()] + + # If we got very few paragraphs, fall back to single newlines + if len(paragraphs) < 3: + paragraphs = [p.strip() for p in text.split('\n') if p.strip()] + + return paragraphs + + +class PdfParser: + """Parser for PDF files""" + + def __init__(self): + pass + + def parse(self, pdfPath): + """Parse a PDF file and return Book object. + + Args: + pdfPath: Path to the PDF file + + Returns: + Book object with each page as a chapter + """ + pdfPath = Path(pdfPath) + + if not pdfPath.exists(): + raise FileNotFoundError(f"PDF file not found: {pdfPath}") + + reader = PdfReader(pdfPath) + + # Use filename as book title (without extension) + bookTitle = pdfPath.stem + book = Book(title=bookTitle) + + for pageNum, page in enumerate(reader.pages): + # Create chapter for this page + chapterTitle = f"Page {pageNum + 1}" + chapter = Chapter(title=chapterTitle) + + # Extract text from page + pageText = page.extract_text() + + if not pageText or not pageText.strip(): + # Empty page, add placeholder + chapter.add_paragraph(f"Page {pageNum + 1} is empty or contains no extractable text.") + else: + # Clean up hyphenated line breaks + pageText = clean_hyphenated_text(pageText) + + # Split into paragraphs + paragraphs = split_into_paragraphs(pageText) + + if not paragraphs: + chapter.add_paragraph(f"Page {pageNum + 1} contains no readable text.") + else: + for paragraph in paragraphs: + chapter.add_paragraph(paragraph) + + book.add_chapter(chapter) + + return book + + def cleanup(self): + """Cleanup any resources (no-op for PDF parser)""" + pass + diff --git a/src/pygame_player.py b/src/pygame_player.py new file mode 100644 index 0000000..1c6c5e7 --- /dev/null +++ b/src/pygame_player.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Pygame Audio Player + +Audio playback using pygame.mixer with integrated event handling. +Simpler and more reliable than PyAudio approach. +""" + +import io +import pygame + + +class PygamePlayer: + """Audio player using pygame.mixer""" + + def __init__(self): + """Initialize pygame audio player""" + self.isInitialized = False + self.isPaused = False + self.currentSound = None # Track current sound for cleanup + + try: + # Initialize pygame mixer only (not full pygame) + # Use only 1 channel to force immediate cleanup of old sounds + pygame.mixer.init(frequency=22050, size=-16, channels=1, buffer=512) + pygame.mixer.set_num_channels(1) # Limit to 1 channel for sequential playback + self.isInitialized = True + except Exception as e: + print(f"Warning: Could not initialize pygame mixer: {e}") + self.isInitialized = False + + def play_wav_data(self, wavData): + """ + Play WAV audio data + + Args: + wavData: Bytes containing WAV audio data + + Returns: + True if playback started successfully + """ + if not self.isInitialized: + return False + + try: + # Cleanup previous sound to prevent memory leak + if self.currentSound: + # Explicitly stop to release pygame's internal buffers + # This is safe since we call play_wav_data only when ready for next paragraph + self.currentSound.stop() + del self.currentSound + self.currentSound = None + + # Load WAV data from bytes + # CRITICAL: Must close BytesIO after Sound is created to prevent memory leak + wavBuffer = io.BytesIO(wavData) + try: + sound = pygame.mixer.Sound(wavBuffer) + finally: + # Close BytesIO buffer immediately - pygame.mixer.Sound copies the data + wavBuffer.close() + del wavBuffer # Explicitly delete + + # Play the sound and keep reference for cleanup + sound.play() + self.currentSound = sound + self.isPaused = False + return True + + except Exception as e: + print(f"Error playing audio: {e}") + return False + + def pause(self): + """Pause playback""" + if self.isInitialized: + pygame.mixer.pause() + self.isPaused = True + + def resume(self): + """Resume playback""" + if self.isInitialized: + pygame.mixer.unpause() + self.isPaused = False + + def stop(self): + """Stop playback""" + if self.isInitialized: + pygame.mixer.stop() + self.isPaused = False + # Cleanup current sound reference + if self.currentSound: + del self.currentSound + self.currentSound = None + + def is_playing(self): + """Check if audio is currently playing""" + if not self.isInitialized: + return False + return pygame.mixer.get_busy() + + def is_paused(self): + """Check if audio is paused""" + return self.isPaused + + def cleanup(self): + """Cleanup resources""" + if self.isInitialized: + # Stop and cleanup current sound + if self.currentSound: + self.currentSound.stop() + del self.currentSound + self.currentSound = None + pygame.mixer.quit() + self.isInitialized = False + + def is_available(self): + """Check if pygame mixer is available""" + return self.isInitialized diff --git a/src/sleep_timer_menu.py b/src/sleep_timer_menu.py new file mode 100644 index 0000000..3522c34 --- /dev/null +++ b/src/sleep_timer_menu.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Sleep Timer Menu + +Provides a menu for setting sleep timer or quitting immediately. +""" + +import time + + +class SleepTimerMenu: + """Sleep timer menu for quit/sleep options""" + + def __init__(self, speechEngine): + """ + Initialize sleep timer menu + + Args: + speechEngine: SpeechEngine instance for UI feedback + """ + self.speechEngine = speechEngine + self.inMenu = False + self.currentIndex = 0 + + # Menu options: (label, minutes) + # 0 means quit immediately + self.menuItems = [ + ("Quit now", 0), + ("Sleep in 5 minutes", 5), + ("Sleep in 10 minutes", 10), + ("Sleep in 15 minutes", 15), + ("Sleep in 20 minutes", 20), + ("Sleep in 25 minutes", 25), + ("Sleep in 30 minutes", 30), + ("Sleep in 35 minutes", 35), + ("Sleep in 40 minutes", 40), + ("Sleep in 45 minutes", 45), + ("Sleep in 50 minutes", 50), + ("Sleep in 55 minutes", 55), + ("Sleep in 60 minutes", 60), + ] + + # Timer state + self.timerActive = False + self.timerEndTime = None + self.timerMinutes = 0 + + def enter_menu(self): + """Enter the sleep timer menu""" + self.inMenu = True + self.currentIndex = 0 + self._speak_current_item() + + def exit_menu(self): + """Exit the sleep timer menu""" + self.inMenu = False + + def is_in_menu(self): + """Check if currently in menu""" + return self.inMenu + + def navigate_menu(self, direction): + """ + Navigate menu up or down + + Args: + direction: 'up' or 'down' + """ + if direction == 'up': + self.currentIndex = (self.currentIndex - 1) % len(self.menuItems) + elif direction == 'down': + self.currentIndex = (self.currentIndex + 1) % len(self.menuItems) + + self._speak_current_item() + + def activate_current_item(self): + """ + Activate current menu item + + Returns: + Tuple: (shouldQuitNow, shouldContinue) + shouldQuitNow: True if user selected "Quit now" + shouldContinue: True if timer was set (continue reading) + """ + label, minutes = self.menuItems[self.currentIndex] + + if minutes == 0: + # Quit now + self.speechEngine.speak("Quitting now") + self.inMenu = False + return (True, False) + else: + # Set sleep timer + self.timerActive = True + self.timerMinutes = minutes + self.timerEndTime = time.time() + (minutes * 60) + self.speechEngine.speak(f"Sleep timer set for {minutes} minutes") + self.inMenu = False + return (False, True) + + def check_timer(self): + """ + Check if sleep timer has expired + + Returns: + True if timer expired and should quit + """ + if self.timerActive and time.time() >= self.timerEndTime: + return True + return False + + def get_time_remaining(self): + """ + Get time remaining on sleep timer + + Returns: + Tuple: (minutes, seconds) or None if no timer active + """ + if not self.timerActive: + return None + + remaining = self.timerEndTime - time.time() + if remaining <= 0: + return (0, 0) + + minutes = int(remaining // 60) + seconds = int(remaining % 60) + return (minutes, seconds) + + def cancel_timer(self): + """Cancel active sleep timer""" + if self.timerActive: + self.timerActive = False + self.timerEndTime = None + self.speechEngine.speak("Sleep timer cancelled") + + def is_timer_active(self): + """Check if sleep timer is active""" + return self.timerActive + + def _speak_current_item(self): + """Speak the current menu item""" + label, _ = self.menuItems[self.currentIndex] + self.speechEngine.speak(label) diff --git a/src/speech_engine.py b/src/speech_engine.py new file mode 100644 index 0000000..c07c255 --- /dev/null +++ b/src/speech_engine.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Speech Engine + +Handles text-to-speech for UI feedback using speech-dispatcher. +Based on soundstorm's speech.py implementation. +""" + +import threading + +try: + import speechd + HAS_SPEECHD = True +except ImportError: + HAS_SPEECHD = False + + +class SpeechEngine: + """Text-to-speech engine for UI accessibility using speech-dispatcher""" + + def __init__(self): + """Initialize speech engine""" + self.client = None + self.speechLock = threading.Lock() + self.isAvailable = False + + # Reading state tracking with callback support + self.isReading = False + self.isPausedReading = False + self.readingCallback = None # Callback for when reading finishes + + # Track UI speech thread to prevent accumulation + self.uiSpeechThread = None + + if HAS_SPEECHD: + try: + self.client = speechd.SSIPClient('bookstorm') + self.isAvailable = True + except Exception as e: + print(f"Warning: Could not initialize speech-dispatcher: {e}") + self.isAvailable = False + else: + print("Warning: python3-speechd not installed. UI will not be accessible.") + + def speak(self, text, interrupt=True): + """ + Speak text using speech-dispatcher + + Args: + text: Text to speak + interrupt: If True, stop current speech first (default: True) + """ + if not self.isAvailable or not text: + return + + # Safety: Wait for previous UI speech thread to finish if still running + # This prevents thread accumulation on rapid UI feedback calls + if self.uiSpeechThread and self.uiSpeechThread.is_alive(): + # Don't wait forever - if thread is stuck, let it die (daemon thread) + self.uiSpeechThread.join(timeout=0.1) + + def speak_thread(): + with self.speechLock: + try: + if interrupt: + self.client.stop() + self.client.speak(str(text)) + except Exception as e: + print(f"Speech error: {e}") + + self.uiSpeechThread = threading.Thread(target=speak_thread) + self.uiSpeechThread.daemon = True + self.uiSpeechThread.start() + + def stop(self): + """Stop current speech""" + if self.isAvailable: + try: + self.client.stop() + except Exception: + pass + + def speak_reading(self, text, callback=None): + """ + Speak text for book reading with callback support + + Args: + text: Text to speak + callback: Optional callback function to call when speech finishes + Callback receives one argument: finish_type (COMPLETED or INTERRUPTED) + """ + if not self.isAvailable: + print("ERROR: Speech-dispatcher not available") + return + + if not text: + print("ERROR: No text to speak") + return + + try: + # Only cancel if we're already reading + if self.isReading: + try: + self.client.cancel() + except Exception: + pass + + # Normalize text - replace newlines with spaces + # (speech-dispatcher may stop at newlines) + textStr = str(text).replace('\n', ' ').replace('\r', ' ') + + # Clean up multiple spaces + import re + textStr = re.sub(r'\s+', ' ', textStr).strip() + + print(f"Speech-dispatcher: Speaking {len(textStr)} characters") + + self.isReading = True + self.isPausedReading = False + self.readingCallback = callback + + # Define callback function for speech-dispatcher + def speech_callback(callbackType, indexMark=None): + """Callback from speech-dispatcher when speech events occur""" + if callbackType == speechd.CallbackType.END: + # Speech completed normally + self.isReading = False + self.isPausedReading = False + if self.readingCallback: + self.readingCallback('COMPLETED') + elif callbackType == speechd.CallbackType.CANCEL: + # Speech was interrupted/cancelled + self.isReading = False + self.isPausedReading = False + if self.readingCallback: + self.readingCallback('INTERRUPTED') + + # Speak with callback (event_types is speechd API parameter) + self.client.speak( + textStr, + callback=speech_callback, + event_types=[speechd.CallbackType.END, speechd.CallbackType.CANCEL] + ) + except Exception as e: + print(f"Speech error: {e}") + import traceback + traceback.print_exc() + self.isReading = False + + def pause_reading(self): + """Pause current reading""" + if not self.isAvailable: + return + + if self.isReading and not self.isPausedReading: + try: + self.client.pause() + self.isPausedReading = True + print("Speech-dispatcher: Paused") + except Exception as e: + print(f"Pause error: {e}") + # Reset state on error + self.isReading = False + self.isPausedReading = False + + def resume_reading(self): + """Resume paused reading""" + if not self.isAvailable: + return + + if self.isPausedReading: + try: + self.client.resume() + self.isPausedReading = False + print("Speech-dispatcher: Resumed") + except Exception as e: + print(f"Resume error: {e}") + # Reset state on error + self.isReading = False + self.isPausedReading = False + + def cancel_reading(self): + """Cancel current reading""" + if not self.isAvailable: + return + + try: + self.client.cancel() + # Note: Canceling will trigger the CANCEL callback + except Exception as e: + print(f"Cancel error: {e}") + finally: + # Reset state + self.isReading = False + self.isPausedReading = False + self.readingCallback = None + + 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): + """ + Set speech rate + + Args: + rate: Speech rate (-100 to 100, 0 is normal) + """ + if self.isAvailable: + try: + rate = max(-100, min(100, rate)) + self.client.set_rate(rate) + except Exception as e: + print(f"Error setting speech rate: {e}") + + def set_voice(self, voiceName): + """ + Set speech voice + + Args: + voiceName: Voice name (e.g., 'Lyubov') + """ + if self.isAvailable: + try: + self.client.set_synthesis_voice(voiceName) + except Exception as e: + print(f"Error setting voice: {e}") + + def list_voices(self): + """ + List available voices + + Returns: + List of voice tuples (name, language, variant) + """ + if self.isAvailable: + try: + voices = self.client.list_synthesis_voices() + # Return list of tuples: (name, language, variant) + return list(voices) if voices else [] + except Exception as e: + print(f"Error listing voices: {e}") + return [] + return [] + + def list_output_modules(self): + """ + List available output modules (speech synthesizers) + + Returns: + List of output module names (e.g., 'espeak-ng', 'festival', 'flite') + """ + if self.isAvailable: + try: + modules = self.client.list_output_modules() + return list(modules) if modules else [] + except Exception as e: + print(f"Error listing output modules: {e}") + return [] + return [] + + def set_output_module(self, moduleName): + """ + Set the output module (speech synthesizer) + + Args: + moduleName: Name of the output module (e.g., 'espeak-ng') + """ + if self.isAvailable: + try: + self.client.set_output_module(moduleName) + except Exception as e: + print(f"Error setting output module: {e}") + + def is_available(self): + """Check if speech-dispatcher is available""" + return self.isAvailable + + def cleanup(self): + """Close speech-dispatcher connection""" + if self.isAvailable and self.client: + try: + self.client.close() + except Exception: + pass diff --git a/src/tts_engine.py b/src/tts_engine.py new file mode 100644 index 0000000..66722ce --- /dev/null +++ b/src/tts_engine.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +TTS Engine Wrapper + +Wrapper for piper-tts to generate speech from text. +Handles streaming audio generation for real-time playback. +""" + +import subprocess +import wave +import io +import struct + + +class TtsEngine: + """Text-to-speech engine using piper-tts""" + + def __init__(self, modelPath="/usr/share/piper-voices/en/en_US/hfc_male/medium/en_US-hfc_male-medium.onnx"): + """ + Initialize TTS engine + + Args: + modelPath: Path to piper-tts voice model + """ + self.modelPath = modelPath + self.sampleRate = 22050 + self.sampleWidth = 2 # 16-bit + self.channels = 1 + + def text_to_wav_data(self, text): + """ + Convert text to WAV audio data + + Args: + text: Text to convert to speech + + Returns: + Bytes containing WAV audio data + + Raises: + RuntimeError: If piper-tts fails + """ + if not text.strip(): + return None + + # Safety: Limit text size to prevent excessive memory usage + # ~10,000 chars = ~10-15 minutes of audio at normal reading speed + MAX_TEXT_LENGTH = 10000 + if len(text) > MAX_TEXT_LENGTH: + print(f"Warning: Paragraph too long ({len(text)} chars), truncating to {MAX_TEXT_LENGTH}") + text = text[:MAX_TEXT_LENGTH] + "..." + + process = None + try: + # Run piper-tts with raw output + process = subprocess.Popen( + [ + 'piper-tts', + '--model', self.modelPath, + '--output-raw' + ], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE + ) + + # Send text and get raw audio with timeout (60 seconds max) + # This prevents hanging if piper-tts gets stuck + rawAudio = None + stderr = None + try: + rawAudio, stderr = process.communicate( + input=text.encode('utf-8'), + timeout=60 + ) + except subprocess.TimeoutExpired: + process.kill() + process.communicate() # Clean up + raise RuntimeError("piper-tts timed out (>60s)") + + if process.returncode != 0: + errorMsg = stderr.decode('utf-8', errors='ignore') + del stderr # Free stderr buffer immediately + stderr = None + raise RuntimeError(f"piper-tts failed: {errorMsg}") + + # Free stderr buffer immediately (can be large if piper-tts is verbose) + if stderr: + del stderr + stderr = None + + # Convert raw PCM to WAV format + wavData = self._raw_to_wav(rawAudio) + + # CRITICAL: Delete rawAudio immediately after conversion + # This is a huge uncompressed buffer (~1-2MB per paragraph) + if rawAudio: + del rawAudio + rawAudio = None + + # Ensure process is fully terminated and cleaned up + if process: + try: + process.wait(timeout=0.1) + except subprocess.TimeoutExpired: + pass + + return wavData + + except FileNotFoundError: + raise RuntimeError("piper-tts not found. Please install piper-tts.") + except Exception as e: + # Clean up buffers on error + if rawAudio: + del rawAudio + if stderr: + del stderr + # Ensure subprocess is terminated if something goes wrong + if process and process.poll() is None: + process.kill() + try: + process.wait(timeout=2) + except subprocess.TimeoutExpired: + pass # Process is truly stuck, nothing we can do + raise RuntimeError(f"TTS generation failed: {str(e)}") + + def _raw_to_wav(self, rawData): + """ + Convert raw PCM data to WAV format + + Args: + rawData: Raw PCM audio bytes + + Returns: + WAV formatted bytes + """ + wavBuffer = io.BytesIO() + + try: + with wave.open(wavBuffer, 'wb') as wavFile: + wavFile.setnchannels(self.channels) + wavFile.setsampwidth(self.sampleWidth) + wavFile.setframerate(self.sampleRate) + wavFile.writeframes(rawData) + + wavBuffer.seek(0) + result = wavBuffer.read() + finally: + # Explicitly close BytesIO to free memory + wavBuffer.close() + + return result + + def get_audio_params(self): + """ + Get audio parameters for playback + + Returns: + Dictionary with sampleRate, sampleWidth, channels + """ + return { + 'sampleRate': self.sampleRate, + 'sampleWidth': self.sampleWidth, + 'channels': self.channels + } diff --git a/src/txt_parser.py b/src/txt_parser.py new file mode 100644 index 0000000..6bc79e2 --- /dev/null +++ b/src/txt_parser.py @@ -0,0 +1,169 @@ +"""TXT parser for BookStorm - extracts text from plain text files.""" + +import re +from pathlib import Path +from src.book import Book, Chapter + + +def detect_chapter_breaks(text): + """Detect chapter breaks in text using various heuristics. + + Returns: + list of tuples: [(chapterTitle, chapterText), ...] + """ + chapters = [] + + # Pattern 1: Common chapter headings (case insensitive) + # Matches: "Chapter 1", "Chapter One", "CHAPTER 1:", etc. + chapterPattern = re.compile( + r'^(Chapter\s+[IVXLCDM\d]+[:\-\s]*.*)$', + re.MULTILINE | re.IGNORECASE + ) + + # Pattern 2: Markdown-style headings + # Matches: "# Chapter Title", "## Section Title" + markdownPattern = re.compile( + r'^(#{1,3}\s+.+)$', + re.MULTILINE + ) + + # Pattern 3: Page breaks (form feed character) + pageBreakPattern = re.compile(r'\f+') + + # Pattern 4: Multiple blank lines (3+) + multiBlankPattern = re.compile(r'\n\s*\n\s*\n\s*\n+') + + # Try chapter headings first + chapterMatches = list(chapterPattern.finditer(text)) + if len(chapterMatches) >= 2: + # Found multiple chapter markers + for i, match in enumerate(chapterMatches): + title = match.group(1).strip() + start = match.start() + end = chapterMatches[i + 1].start() if i + 1 < len(chapterMatches) else len(text) + chapterText = text[start:end].strip() + # Remove title from chapter text + chapterText = chapterText[len(match.group(0)):].strip() + chapters.append((title, chapterText)) + return chapters + + # Try markdown headings + markdownMatches = list(markdownPattern.finditer(text)) + if len(markdownMatches) >= 2: + for i, match in enumerate(markdownMatches): + title = match.group(1).strip() + # Remove leading # symbols for title + title = re.sub(r'^#+\s*', '', title) + start = match.start() + end = markdownMatches[i + 1].start() if i + 1 < len(markdownMatches) else len(text) + chapterText = text[start:end].strip() + chapterText = chapterText[len(match.group(0)):].strip() + chapters.append((title, chapterText)) + return chapters + + # Try page breaks + pageBreakParts = pageBreakPattern.split(text) + if len(pageBreakParts) >= 2: + for i, part in enumerate(pageBreakParts): + if part.strip(): + title = f"Section {i + 1}" + chapters.append((title, part.strip())) + if chapters: + return chapters + + # Try multiple blank lines as separators + multiBlankParts = multiBlankPattern.split(text) + if len(multiBlankParts) >= 3: # At least 3 sections + for i, part in enumerate(multiBlankParts): + if part.strip(): + # Try to extract a title from first line + lines = part.strip().split('\n', 1) + if len(lines) > 1 and len(lines[0]) < 100: + # First line might be a title + title = lines[0].strip() + content = lines[1].strip() if len(lines) > 1 else "" + else: + title = f"Section {i + 1}" + content = part.strip() + chapters.append((title, content)) + if chapters: + return chapters + + # No clear chapter breaks found, treat as single chapter + return [("Full Text", text.strip())] + + +def split_into_paragraphs(text): + """Split text into paragraphs using double newlines. + + Falls back to single newlines if no double newlines found. + """ + # Try splitting on double newlines first + paragraphs = re.split(r'\n\s*\n', text) + + # Clean up whitespace + paragraphs = [p.strip() for p in paragraphs if p.strip()] + + # If we got very few paragraphs, fall back to single newlines + if len(paragraphs) < 3: + paragraphs = [p.strip() for p in text.split('\n') if p.strip()] + + return paragraphs + + +class TxtParser: + """Parser for plain text files""" + + def __init__(self): + pass + + def parse(self, txtPath): + """Parse a TXT file and return Book object. + + Args: + txtPath: Path to the TXT file + + Returns: + Book object with detected chapters or single chapter + """ + txtPath = Path(txtPath) + + if not txtPath.exists(): + raise FileNotFoundError(f"TXT file not found: {txtPath}") + + # Read file with encoding detection + try: + with open(txtPath, 'r', encoding='utf-8') as f: + text = f.read() + except UnicodeDecodeError: + # Try with latin-1 as fallback + with open(txtPath, 'r', encoding='latin-1') as f: + text = f.read() + + # Use filename as book title + bookTitle = txtPath.stem + book = Book(title=bookTitle) + + # Detect chapters + detectedChapters = detect_chapter_breaks(text) + + # Process each chapter + for chapterTitle, chapterText in detectedChapters: + chapter = Chapter(title=chapterTitle) + + # Split into paragraphs + paragraphs = split_into_paragraphs(chapterText) + + if not paragraphs: + chapter.add_paragraph("(Empty chapter)") + else: + for paragraph in paragraphs: + chapter.add_paragraph(paragraph) + + book.add_chapter(chapter) + + return book + + def cleanup(self): + """Cleanup any resources (no-op for TXT parser)""" + pass diff --git a/src/voice_selector.py b/src/voice_selector.py new file mode 100644 index 0000000..8f4a8a0 --- /dev/null +++ b/src/voice_selector.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Voice Selector + +Interactive voice selection interface for piper-tts voices. +Allows browsing, testing, and selecting voice models. +""" + +from pathlib import Path +from src.tts_engine import TtsEngine +from src.pygame_player import PygamePlayer + + +class VoiceSelector: + """Voice selection interface""" + + def __init__(self, voiceDir=None): + """ + Initialize voice selector + + Args: + voiceDir: Directory containing voice models + """ + if voiceDir is None: + voiceDir = "/usr/share/piper-voices/en/en_US" + + self.voiceDir = Path(voiceDir) + self.voices = [] + self._scan_voices() + + def _scan_voices(self): + """Scan for available voice models""" + self.voices = [] + + if not self.voiceDir.exists(): + return + + # Find all .onnx files recursively + for onnxFile in self.voiceDir.rglob("*.onnx"): + voiceName = self._extract_voice_name(onnxFile) + self.voices.append({ + 'name': voiceName, + 'path': str(onnxFile), + 'relPath': str(onnxFile.relative_to(self.voiceDir)) + }) + + # Sort by name + self.voices.sort(key=lambda v: v['name']) + + def _extract_voice_name(self, voicePath): + """ + Extract readable voice name from path + + Args: + voicePath: Path to voice model file + + Returns: + Human-readable voice name + """ + voicePath = Path(voicePath) + + # Get parts of the path + parts = voicePath.parts + + # Try to extract from filename pattern: en_US-voicename-quality.onnx + filename = voicePath.stem # Remove .onnx + nameParts = filename.split('-') + + if len(nameParts) >= 2: + # nameParts[1] is usually the voice name + voiceName = nameParts[1].replace('_', ' ').title() + quality = nameParts[2] if len(nameParts) > 2 else '' + + if quality: + return f"{voiceName} ({quality})" + return voiceName + + # Fallback to filename + return voicePath.stem + + def get_voices(self): + """ + Get list of available voices + + Returns: + List of voice dictionaries + """ + return self.voices + + def select_voice_interactive(self): + """ + Interactive voice selection + + Returns: + Selected voice path or None if cancelled + """ + if not self.voices: + print("No voices found in", self.voiceDir) + return None + + print("\nAvailable Voices:") + print("-" * 60) + + for idx, voice in enumerate(self.voices): + print(f"{idx + 1}. {voice['name']}") + + print("-" * 60) + print("\nCommands:") + print(" - Select voice") + print(" t - Test voice") + print(" q - Cancel") + print() + + while True: + try: + choice = input("Select voice> ").strip().lower() + + if choice == 'q': + return None + + # Test voice + if choice.startswith('t '): + try: + voiceNum = int(choice[2:]) + if 1 <= voiceNum <= len(self.voices): + self._test_voice(self.voices[voiceNum - 1]) + else: + print(f"Invalid voice number. Choose 1-{len(self.voices)}") + except ValueError: + print("Invalid input. Use: t ") + continue + + # Select voice + try: + voiceNum = int(choice) + if 1 <= voiceNum <= len(self.voices): + selectedVoice = self.voices[voiceNum - 1] + print(f"Selected: {selectedVoice['name']}") + return selectedVoice['path'] + else: + print(f"Invalid voice number. Choose 1-{len(self.voices)}") + except ValueError: + print("Invalid input. Enter a number, 't ' to test, or 'q' to cancel") + + except (EOFError, KeyboardInterrupt): + print("\nCancelled") + return None + + def _test_voice(self, voice): + """ + Test a voice by playing sample text + + Args: + voice: Voice dictionary + """ + print(f"\nTesting voice: {voice['name']}") + + voiceName = voice['name'].split('(')[0].strip() # Remove quality suffix + testText = f"Hi, my name is {voiceName}, and I am a piper text to speech voice. Do you like the way I sound?" + + try: + tts = TtsEngine(voice['path']) + player = PygamePlayer() + + print("Generating speech...") + wavData = tts.text_to_wav_data(testText) + + if wavData: + print("Playing...") + player.play_wav_data(wavData) + # Wait for playback to finish + import time + while player.is_playing(): + time.sleep(0.1) + + player.cleanup() + + except Exception as e: + print(f"Error testing voice: {e}") + + def find_voice_by_name(self, name): + """ + Find voice by name (case-insensitive partial match) + + Args: + name: Voice name to search for + + Returns: + Voice path or None if not found + """ + name = name.lower() + + for voice in self.voices: + if name in voice['name'].lower(): + return voice['path'] + + return None