Files
bookstorm/bookstorm.py
2025-10-04 02:55:01 -04:00

1199 lines
46 KiB
Python
Executable File

#!/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 <book.epub> # Read EPUB book")
print(" python bookstorm.py <book.pdf> # Read PDF book")
print(" python bookstorm.py <book.txt> # Read TXT book")
print(" python bookstorm.py <book.zip> # 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())