1199 lines
46 KiB
Python
Executable File
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())
|