Files
bookstorm/bookstorm.py
2025-10-05 20:19:16 -04:00

2060 lines
81 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.audio_parser import AudioParser
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
from src.recent_books_menu import RecentBooksMenu
from src.audiobookshelf_client import AudiobookshelfClient
from src.audiobookshelf_menu import AudiobookshelfMenu
from src.server_link_manager import ServerLinkManager
from src.bookmarks_menu import BookmarksMenu
class BookReader:
"""Main book reader class"""
def __init__(self, bookPath, config=None):
"""
Initialize book reader
Args:
bookPath: Path to book file (or None for server streaming)
config: ConfigManager instance
"""
self.bookPath = Path(bookPath) if bookPath else None
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', '.m4b', '.m4a', '.mp3']
self.bookSelector = BookSelector(booksDir, supportedFormats, self.speechEngine)
# Initialize sleep timer menu
self.sleepTimerMenu = SleepTimerMenu(self.speechEngine)
# Initialize recent books menu
self.recentBooksMenu = RecentBooksMenu(self.bookmarkManager, self.speechEngine)
# Initialize bookmarks menu
self.bookmarksMenu = BookmarksMenu(self.bookmarkManager, self.speechEngine)
# Initialize Audiobookshelf client and menu (lazy init - only create when accessed)
self.absClient = None
self.absMenu = None
self.serverLinkManager = ServerLinkManager()
self.serverBook = None # Server book metadata for streaming
self.isStreaming = False # Track if currently streaming
self.sessionId = None # Active listening session ID
# 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()
# Audio bookmark state
self.savedAudioPosition = 0.0 # Saved audio position for resume
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)
elif suffix in ['.m4b', '.m4a', '.mp3']:
# Audio book file
self.parser = AudioParser()
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()}")
# If it's an audio book, load it into the player
if hasattr(self.book, 'isAudioBook') and self.book.isAudioBook:
if not self.audioPlayer.load_audio_file(self.book.audioPath):
raise Exception("Failed to load audio file")
# Inform user about navigation capabilities
if self.book.get_total_chapters() == 1:
print("\nNote: This audio file has no chapter markers.")
print("Navigation: Only play/pause/stop supported (no chapter jumping)")
self.speechEngine.speak("Audio book loaded. No chapter markers found. Only basic playback controls available.")
else:
print(f"\nChapter navigation: Enabled ({self.book.get_total_chapters()} chapters)")
self.speechEngine.speak(f"Audio book loaded with {self.book.get_total_chapters()} chapters. Chapter navigation enabled.")
# 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']
self.savedAudioPosition = bookmark.get('audioPosition', 0.0)
# For audio books, show resume position
if hasattr(self.book, 'isAudioBook') and self.book.isAudioBook and self.savedAudioPosition > 0:
minutes = int(self.savedAudioPosition // 60)
seconds = int(self.savedAudioPosition % 60)
print(f"Resuming from chapter {self.currentChapter + 1} at {minutes}m {seconds}s")
else:
print(f"Resuming from chapter {self.currentChapter + 1}, paragraph {self.currentParagraph + 1}")
else:
print("Starting from beginning")
self.savedAudioPosition = 0.0
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)
"""
# Don't save if no book is loaded
if not self.book:
return
# For audio books, calculate current playback position
audioPosition = 0.0
if hasattr(self.book, 'isAudioBook') and self.book.isAudioBook:
# Get current chapter start time
chapter = self.book.get_chapter(self.currentChapter)
if chapter and hasattr(chapter, 'startTime'):
chapterStartTime = chapter.startTime
else:
chapterStartTime = 0.0
# Get playback position within the audio file
if self.audioPlayer.is_audio_file_playing() or self.audioPlayer.is_paused():
playbackPos = self.audioPlayer.get_audio_position()
# Total position = chapter start + position within current playback
audioPosition = chapterStartTime + playbackPos
self.bookmarkManager.save_bookmark(
self.bookPath,
self.book.title,
self.currentChapter,
self.currentParagraph,
audioPosition=audioPosition
)
# Sync progress to server if streaming or server-linked
self._sync_progress_to_server(audioPosition)
if speakFeedback:
self.speechEngine.speak("Bookmark saved")
def _sync_progress_to_server(self, audioPosition=0.0):
"""
Sync progress to Audiobookshelf server
Args:
audioPosition: Current audio position in seconds
"""
# Only sync if we have an active ABS client
if not self.absClient or not self.absClient.is_authenticated():
return
# Check if this is a streaming book or server-linked book
serverId = None
if self.serverBook:
# Streaming book
serverId = self.serverBook.get('id')
else:
# Check if local book is linked to server
serverLink = self.serverLinkManager.get_link(str(self.bookPath))
if serverLink:
serverId = serverLink.get('server_id')
if not serverId:
return
# Calculate progress
duration = 0.0
if hasattr(self.book, 'totalDuration'):
duration = self.book.totalDuration
if duration <= 0:
return
currentTime = audioPosition
progress = min(currentTime / duration, 1.0) if duration > 0 else 0.0
# Upload progress to server
success = self.absClient.update_progress(serverId, currentTime, duration, progress)
if success:
print(f"Progress synced to server: {int(progress * 100)}%")
# Also sync session if active
if self.sessionId:
self.absClient.sync_session(self.sessionId, currentTime, duration, progress)
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))
# Set caption - handle case where no book is loaded yet
if self.book:
pygame.display.set_caption(f"BookStorm - {self.book.title}")
else:
pygame.display.set_caption("BookStorm - Press A/B/R to load book")
# 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
if self.book:
self.displayText = "Press SPACE to start reading"
self.statusText = f"Book: {self.book.title}"
print(f"\n{self.book.title} - {self.book.get_total_chapters()} chapters")
print("Press SPACE to start reading")
self.speechEngine.speak("BookStorm ready. Press SPACE to start reading. Press i for info. Press h for help.")
else:
self.displayText = "No book loaded"
self.statusText = "Press A for Audiobookshelf, B for local books, R for recent books"
print("\nNo book loaded")
print("Press A for Audiobookshelf, B for local books, R for recent books")
# Speech message already given earlier
# Cached rendered surfaces to prevent memory leak from re-rendering 30 FPS
self.cachedDisplayText = None
self.cachedStatusText = None
self.cachedSurfaces = []
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() or
self.recentBooksMenu.is_in_menu() or
(self.absMenu and self.absMenu.is_in_menu()))
if self.isPlaying and not inAnyMenu and self.book:
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/chapter
# Speech-dispatcher uses callbacks for auto-advance
isAudioBook = self.book and hasattr(self.book, 'isAudioBook') and self.book.isAudioBook
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() or
self.recentBooksMenu.is_in_menu() or
(self.absMenu and self.absMenu.is_in_menu()))
if self.isPlaying and not inAnyMenu and self.book:
if isAudioBook:
# Check if audio file playback finished
if not self.audioPlayer.is_audio_file_playing() and not self.audioPlayer.is_paused():
# Audio chapter finished, advance to next chapter
if not self.next_chapter():
self.displayText = "End of book reached"
self.isPlaying = False
self.save_bookmark(speakFeedback=False)
else:
# Start next chapter
self._start_paragraph_playback()
elif readerEngine == 'piper':
# 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:
# Stop playback
readerEngine = self.config.get_reader_engine()
if readerEngine == 'speechd':
self.readingEngine.cancel_reading()
else:
self.audioPlayer.stop()
# Save bookmark
self.save_bookmark(speakFeedback=False)
# Close Audiobookshelf session if active
if self.sessionId and self.absClient:
try:
self.absClient.close_session(self.sessionId)
except:
pass
# Clean up speech engines
if self.speechEngine:
self.speechEngine.close()
if readerEngine == 'speechd' and self.readingEngine:
self.readingEngine.close()
# Clear pygame resources
self.cachedSurfaces.clear()
pygame.quit()
def _handle_pygame_key(self, event):
"""Handle pygame key event"""
# Check if in Audiobookshelf menu
if self.absMenu and self.absMenu.is_in_menu():
self._handle_audiobookshelf_key(event)
return
# Check if in recent books menu
if self.recentBooksMenu.is_in_menu():
self._handle_recent_books_key(event)
return
# Check if in bookmarks menu
if self.bookmarksMenu.is_in_menu():
self._handle_bookmarks_key(event)
return
# 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 (only if book is loaded)
if not self.book:
self.speechEngine.speak("No book loaded. Press A for Audiobookshelf, B for local books, or R for recent books.")
return
isAudioBook = hasattr(self.book, 'isAudioBook') and self.book.isAudioBook
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 isAudioBook:
# Handle audio book pause/resume
if self.audioPlayer.is_paused():
self.speechEngine.speak("Resuming")
self.audioPlayer.resume_audio_file()
else:
self.speechEngine.speak("Paused")
self.audioPlayer.pause_audio_file()
elif 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 not self.book:
self.speechEngine.speak("No book loaded")
return
isAudioBook = hasattr(self.book, 'isAudioBook') and self.book.isAudioBook
if shiftPressed or isAudioBook:
# Next chapter (or for audio books, always go to 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 (text books only)
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 not self.book:
self.speechEngine.speak("No book loaded")
return
isAudioBook = hasattr(self.book, 'isAudioBook') and self.book.isAudioBook
if shiftPressed or isAudioBook:
# Previous chapter (or for audio books, always go to 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 (text books only)
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:
if not self.book:
self.speechEngine.speak("No book loaded")
return
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_r:
# Open recent books menu
self.recentBooksMenu.enter_menu()
elif event.key == pygame.K_k:
# Open bookmarks menu
if self.book:
self.bookmarksMenu.enter_menu(str(self.bookPath))
else:
self.speechEngine.speak("No book loaded")
elif event.key == pygame.K_a:
# Open Audiobookshelf browser
self._open_audiobookshelf_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. k: bookmarks menu. r: recent books. b: browse books. a: audiobookshelf. 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:
if not self.book:
self.speechEngine.speak("No book loaded. Press H for help.")
return
# 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_recent_books_key(self, event):
"""Handle key events when in recent books menu"""
if event.key == pygame.K_UP:
self.recentBooksMenu.navigate_menu('up')
elif event.key == pygame.K_DOWN:
self.recentBooksMenu.navigate_menu('down')
elif event.key == pygame.K_RETURN:
# Select book
selectedBook = self.recentBooksMenu.activate_current_item()
if selectedBook:
# Book was selected, load it
self.recentBooksMenu.exit_menu()
self._load_new_book(selectedBook)
elif event.key == pygame.K_ESCAPE:
self.recentBooksMenu.exit_menu()
def _handle_bookmarks_key(self, event):
"""Handle key events when in bookmarks menu"""
if event.key == pygame.K_UP:
self.bookmarksMenu.navigate_menu('up')
elif event.key == pygame.K_DOWN:
self.bookmarksMenu.navigate_menu('down')
elif event.key == pygame.K_RETURN:
# Activate current item
result = self.bookmarksMenu.activate_current_item()
if result:
action = result.get('action')
if action == 'jump':
# Jump to bookmark
bookmark = result.get('bookmark')
self._jump_to_bookmark(bookmark)
self.bookmarksMenu.exit_menu()
elif action == 'create':
# Create new bookmark
self._create_named_bookmark()
elif event.key == pygame.K_DELETE or event.key == pygame.K_d:
# Delete current bookmark
self.bookmarksMenu.delete_current_bookmark()
elif event.key == pygame.K_ESCAPE:
self.bookmarksMenu.exit_menu()
def _jump_to_bookmark(self, bookmark):
"""
Jump to a named bookmark
Args:
bookmark: Bookmark dictionary
"""
chapterIndex = bookmark['chapterIndex']
paragraphIndex = bookmark['paragraphIndex']
audioPosition = bookmark.get('audioPosition', 0.0)
bookmarkName = bookmark['name']
# Stop current playback
self.isPlaying = False
if self.ttsEngine:
self.audioPlayer.stop()
else:
self.readingEngine.stop()
self.audioPlayer.stop_audio_file()
# Update position
self.currentChapter = chapterIndex
self.currentParagraph = paragraphIndex
# For audio books, seek to audio position
if hasattr(self.book, 'isAudioBook') and self.book.isAudioBook:
# Find chapter that contains this audio position
for i, chapter in enumerate(self.book.chapters):
if hasattr(chapter, 'startTime'):
chapterEnd = chapter.startTime + chapter.duration
if chapter.startTime <= audioPosition < chapterEnd:
self.currentChapter = i
# Position within chapter
positionInChapter = audioPosition - chapter.startTime
# Seek to position
if self.audioPlayer.is_audio_file_loaded():
self.audioPlayer.seek_audio_position(audioPosition)
break
# Speak feedback
if self.speechEngine:
chapter = self.book.get_chapter(self.currentChapter)
chapterTitle = chapter.title if chapter else "Unknown"
self.speechEngine.speak(f"Jumped to bookmark: {bookmarkName}. Chapter: {chapterTitle}")
# Update display
if self.config.get_show_text():
self._render_screen()
def _create_named_bookmark(self):
"""Create a new named bookmark"""
import getpass
if self.speechEngine:
self.speechEngine.speak("Enter bookmark name. Check terminal.")
print("\n=== Create Bookmark ===")
bookmarkName = input("Bookmark name: ").strip()
if not bookmarkName:
if self.speechEngine:
self.speechEngine.speak("Cancelled")
return
# Calculate audio position if audio book
audioPosition = 0.0
if hasattr(self.book, 'isAudioBook') and self.book.isAudioBook:
chapter = self.book.get_chapter(self.currentChapter)
if chapter and hasattr(chapter, 'startTime'):
chapterStartTime = chapter.startTime
else:
chapterStartTime = 0.0
if self.audioPlayer.is_audio_file_playing() or self.audioPlayer.is_paused():
playbackPos = self.audioPlayer.get_audio_position()
audioPosition = chapterStartTime + playbackPos
# Create bookmark
success = self.bookmarkManager.create_named_bookmark(
self.bookPath,
bookmarkName,
self.currentChapter,
self.currentParagraph,
audioPosition=audioPosition
)
if success:
if self.speechEngine:
self.speechEngine.speak(f"Bookmark created: {bookmarkName}")
print(f"Bookmark '{bookmarkName}' created!")
# Reload bookmarks in menu
self.bookmarksMenu._load_bookmarks()
self.bookmarksMenu._speak_current_item()
else:
if self.speechEngine:
self.speechEngine.speak(f"Bookmark name already exists: {bookmarkName}")
print(f"ERROR: Bookmark with name '{bookmarkName}' already exists")
def _open_audiobookshelf_browser(self):
"""Open Audiobookshelf browser"""
# Check if server is configured
if not self.config.is_abs_configured():
self.speechEngine.speak("Audiobookshelf not configured. Please set server URL and username in config.")
print("\nAudiobookshelf not configured.")
print("Edit ~/.config/stormux/bookstorm/settings.ini and add:")
print("[Audiobookshelf]")
print("server_url = https://your-server.com")
print("username = your-username")
print("\nThen login with password when prompted.")
return
# Initialize client if needed
if not self.absClient:
serverUrl = self.config.get_abs_server_url()
self.absClient = AudiobookshelfClient(serverUrl, self.config)
# Check if already authenticated
if not self.absClient.is_authenticated():
# Need to login
self.speechEngine.speak("Audiobookshelf login required. Check terminal for password prompt.")
print("\n=== Audiobookshelf Login ===")
username = self.config.get_abs_username()
print(f"Username: {username}")
# Get password from user
import getpass
password = getpass.getpass("Password: ")
if not self.absClient.login(username, password):
self.speechEngine.speak("Login failed. Check username and password.")
return
self.speechEngine.speak("Login successful")
# Test connection
if not self.absClient.test_connection():
self.speechEngine.speak("Connection test failed. Check server URL.")
return
# Initialize menu if needed
if not self.absMenu:
self.absMenu = AudiobookshelfMenu(self.absClient, self.config, self.speechEngine)
# Open browser
self.absMenu.enter_menu()
def _handle_audiobookshelf_key(self, event):
"""Handle key events when in Audiobookshelf menu"""
if event.key == pygame.K_UP:
self.absMenu.navigate_menu('up')
elif event.key == pygame.K_DOWN:
self.absMenu.navigate_menu('down')
elif event.key == pygame.K_LEFT:
self.absMenu.change_view('left')
elif event.key == pygame.K_RIGHT:
self.absMenu.change_view('right')
elif event.key == pygame.K_RETURN:
# Select item (library or book)
result = self.absMenu.activate_current_item()
if result:
action = result.get('action')
if action == 'open_local':
# Open local copy of book
localPath = result.get('path')
if localPath:
if self.absMenu:
self.absMenu.exit_menu()
self._load_new_book(localPath)
elif action == 'stream':
# Stream from server
serverBook = result.get('serverBook')
if serverBook:
self._stream_audiobook(serverBook)
elif action == 'download':
# Download book to library
serverBook = result.get('serverBook')
if serverBook:
self._download_audiobook(serverBook)
elif event.key == pygame.K_BACKSPACE:
# Go back
if self.absMenu:
self.absMenu.go_back()
elif event.key == pygame.K_ESCAPE:
if self.absMenu:
self.absMenu.exit_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 _stream_audiobook(self, serverBook):
"""
Stream audiobook from Audiobookshelf server
Args:
serverBook: Server book dictionary from API
"""
# Extract book metadata
# Try different ID fields (structure varies by API endpoint)
serverId = serverBook.get('id') or serverBook.get('libraryItemId')
if not serverId:
self.speechEngine.speak("Error: Book ID not found")
print(f"\nERROR: No valid ID found in book metadata")
print(f"Available keys: {list(serverBook.keys())}")
return
media = serverBook.get('media', {})
metadata = media.get('metadata', {})
title = metadata.get('title', 'Unknown')
author = metadata.get('authorName', '')
duration = media.get('duration', 0.0)
print(f"\nDEBUG: Streaming book ID: {serverId}")
print(f"DEBUG: Title from metadata: {title}")
# Get streaming URL (pass full book details to avoid re-fetching)
self.speechEngine.speak(f"Loading stream for {title}. Please wait.")
print(f"\nGetting stream URL for: {title}")
streamUrl = self.absClient.get_stream_url(serverId, itemDetails=serverBook)
if not streamUrl:
self.speechEngine.speak("Failed to get stream URL. Check terminal for errors.")
print("\nERROR: Could not get stream URL")
print(f"Book structure keys: {list(serverBook.keys())}")
if 'media' in serverBook:
print(f"Media keys: {list(serverBook['media'].keys())}")
return
# Get chapters from server (from the book details we already have)
serverChapters = media.get('chapters', [])
# Create AudioBook object with stream URL
from src.audio_parser import AudioBook, AudioChapter
book = AudioBook(title=title, author=author, audioPath=streamUrl)
book.totalDuration = duration
# Add chapters from server
if serverChapters:
for chapterData in serverChapters:
chapterTitle = chapterData.get('title', 'Untitled')
startTime = chapterData.get('start', 0.0)
chapterDuration = chapterData.get('end', 0.0) - startTime
chapter = AudioChapter(
title=chapterTitle,
startTime=startTime,
duration=chapterDuration
)
book.add_chapter(chapter)
else:
# No chapters - treat entire book as single chapter
chapter = AudioChapter(
title=title,
startTime=0.0,
duration=duration
)
book.add_chapter(chapter)
# Store server metadata for progress sync
self.serverBook = serverBook
self.isStreaming = True
# Load the book
self.book = book
self.bookPath = streamUrl # Store URL as path for tracking
# Save server book reference for resume on restart
# Use special format: abs://{server_id} so we can detect and resume
self.config.set_last_book(f"abs://{serverId}")
print(f"DEBUG: Saved last_book as: abs://{serverId}")
# Create listening session
self.sessionId = self.absClient.create_session(serverId)
if self.sessionId:
print(f"Created listening session: {self.sessionId}")
# Try to load progress from server
serverProgress = self.absClient.get_progress(serverId)
if serverProgress:
progressTime = serverProgress.get('currentTime', 0.0)
print(f"Resuming from server progress: {int(progressTime)}s")
# Find chapter that contains this time
for i, chap in enumerate(book.chapters):
if hasattr(chap, 'startTime'):
chapterEnd = chap.startTime + chap.duration
if chap.startTime <= progressTime < chapterEnd:
self.currentChapter = i
break
else:
# No server progress, start from beginning
self.currentChapter = 0
# Initialize position
self.currentParagraph = 0
# Load stream URL directly - pygame_player will handle caching via ffmpeg
print(f"Loading stream: {streamUrl[:80]}...")
self.speechEngine.speak("Loading stream. This may take a moment.")
# Load the stream URL - pygame_player will cache it using ffmpeg
# Pass auth token so ffmpeg can authenticate
if not self.audioPlayer.load_audio_file(streamUrl, authToken=self.absClient.authToken):
self.speechEngine.speak("Failed to load stream. Check terminal for errors.")
print("\nERROR: Failed to load stream from server")
print("Make sure ffmpeg is installed: sudo pacman -S ffmpeg")
return
# Success! Start playing
self.speechEngine.speak(f"Now streaming {title}. Press space to pause.")
print(f"\nNow streaming: {title} by {author}")
print(f"Chapters: {len(book.chapters)}")
# Exit menu (if we came from menu - not needed when resuming from startup)
if self.absMenu:
self.absMenu.exit_menu()
# Update UI if enabled
if self.config.get_show_text():
self._render_screen()
# Start playback
self.audioPlayer.play_audio_file()
self.isPlaying = True
self.isAudioBook = True
def _download_audiobook(self, serverBook):
"""
Download audiobook from Audiobookshelf server
Args:
serverBook: Server book dictionary from API
"""
# Check library directory is set
libraryDir = self.config.get_library_directory()
if not libraryDir:
self.speechEngine.speak("Library directory not set. Please set library directory first. Press B then L.")
print("\nERROR: Library directory not set.")
print("Press 'b' to browse files, then 'L' to set library directory.")
return
libraryPath = Path(libraryDir)
if not libraryPath.exists():
self.speechEngine.speak("Library directory does not exist.")
print(f"\nERROR: Library directory does not exist: {libraryDir}")
return
# Extract book metadata
# Try different ID fields (structure varies by API endpoint)
serverId = serverBook.get('id') or serverBook.get('libraryItemId')
if not serverId:
self.speechEngine.speak("Error: Book ID not found")
print(f"\nERROR: No valid ID found in book metadata")
print(f"Available keys: {list(serverBook.keys())}")
return
media = serverBook.get('media', {})
metadata = media.get('metadata', {})
title = metadata.get('title', 'Unknown')
author = metadata.get('authorName', '')
duration = media.get('duration', 0.0)
numChapters = media.get('numChapters', 0)
print(f"\nDEBUG: Downloading book ID: {serverId}")
print(f"DEBUG: Title from metadata: {title}")
# Create sanitized filename
safeTitle = "".join(c for c in title if c.isalnum() or c in (' ', '-', '_')).strip()
safeTitle = safeTitle.replace(' ', '_')
# Determine file extension from server
audioFiles = media.get('audioFiles', [])
if audioFiles:
firstFile = audioFiles[0]
fileMetadata = firstFile.get('metadata', {})
fileName = fileMetadata.get('filename', '')
fileExt = Path(fileName).suffix if fileName else '.m4b'
else:
fileExt = '.m4b'
outputPath = libraryPath / f"{safeTitle}{fileExt}"
# Check if file already exists
if outputPath.exists():
self.speechEngine.speak("Book already downloaded.")
print(f"\nBook already exists: {outputPath}")
# Open it anyway
if self.absMenu:
self.absMenu.exit_menu()
self._load_new_book(str(outputPath))
return
# Notify user
self.speechEngine.speak(f"Downloading {title}. This may take several minutes.")
print(f"\nDownloading: {title}")
print(f"Output: {outputPath}")
print("Please wait...")
# Progress callback
lastPercent = 0
def progress_callback(percent):
nonlocal lastPercent
# Only update every 10%
if percent >= lastPercent + 10:
print(f"Progress: {percent}%")
lastPercent = percent
# Download file
success = self.absClient.download_audio_file(serverId, str(outputPath), progress_callback)
if not success:
self.speechEngine.speak("Download failed. Check terminal for errors.")
print("\nDownload failed!")
return
# Create server link
serverUrl = self.config.get_abs_server_url()
libraryId = serverBook.get('libraryId', '')
self.serverLinkManager.create_link(
bookPath=str(outputPath),
serverUrl=serverUrl,
serverId=serverId,
libraryId=libraryId,
title=title,
author=author,
duration=duration,
chapters=numChapters
)
# Success!
self.speechEngine.speak(f"Download complete. Opening {title}.")
print(f"\nDownload complete! Opening book...")
# Exit menu and open the book
if self.absMenu:
self.absMenu.exit_menu()
self._load_new_book(str(outputPath))
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 (audio books, piper-tts, and speech-dispatcher)"""
# Handle case where no book is loaded
if not self.book:
return
isAudioBook = hasattr(self.book, 'isAudioBook') and self.book.isAudioBook
readerEngine = self.config.get_reader_engine()
if isAudioBook:
# Stop audio file playback
self.audioPlayer.stop_audio_file()
elif 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
# Check if this is an audio book
if hasattr(self.book, 'isAudioBook') and self.book.isAudioBook:
# Audio book playback
self._start_audio_chapter_playback(chapter)
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 _start_audio_chapter_playback(self, chapter):
"""Start playing audio book chapter"""
# Update display text and status
self.displayText = f"Playing: {chapter.title}"
self.statusText = f"Ch {self.currentChapter + 1}/{self.book.get_total_chapters()}: {chapter.title}"
# Determine start position
# If we have a saved audio position and we're on the saved chapter, use it
if self.savedAudioPosition > 0.0:
startTime = self.savedAudioPosition
# Clear saved position so we don't use it again (only for initial resume)
self.savedAudioPosition = 0.0
minutes = int(startTime // 60)
seconds = int(startTime % 60)
print(f"Resuming playback at {minutes}m {seconds}s")
else:
# Get start time from audio chapter
if hasattr(chapter, 'startTime'):
startTime = chapter.startTime
else:
startTime = 0.0
# Seek to position and play
if self.audioPlayer.audioFileLoaded:
self.audioPlayer.play_audio_file(startPosition=startTime)
else:
print("ERROR: Audio file not loaded!")
self.isPlaying = False
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"""
# Close active listening session if any
if self.sessionId and self.absClient:
print(f"Closing listening session: {self.sessionId}")
self.absClient.close_session(self.sessionId)
self.sessionId = None
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()
# Check if last book was an Audiobookshelf stream
if lastBook and lastBook.startswith('abs://'):
# Extract server book ID
serverId = lastBook[6:] # Remove 'abs://' prefix
print(f"Resuming Audiobookshelf book: {serverId}")
# Try to reconnect and stream
print(f"Last book was an Audiobookshelf stream")
# Start BookStorm even if stream fails - user can browse/select
bookPathFallback = None # Will trigger interactive mode if stream fails
if config.is_abs_configured():
try:
# Initialize Audiobookshelf client
from src.audiobookshelf_client import AudiobookshelfClient
serverUrl = config.get_abs_server_url()
absClient = AudiobookshelfClient(serverUrl, config)
if absClient.is_authenticated() and absClient.test_connection():
# Get book details
print(f"Fetching book details from server...")
bookDetails = absClient.get_library_item_details(serverId)
if bookDetails:
# Successfully got book details - try to stream
print(f"Found book on server, preparing to stream...")
try:
from src.speech_engine import SpeechEngine
speechEngine = SpeechEngine()
reader = BookReader(None, config)
reader.absClient = absClient
reader._stream_audiobook(bookDetails)
reader.run_interactive()
return 0
except Exception as e:
print(f"Error resuming stream: {e}")
print("Opening BookStorm anyway - use 'a' to browse server or 'b' for local books")
else:
print(f"Book not found on server (may have been deleted)")
print("Opening BookStorm anyway - use 'a' to browse server or 'b' for local books")
else:
print("Cannot connect to Audiobookshelf server or session expired")
print("Opening BookStorm anyway - use 'a' to reconnect or 'b' for local books")
except Exception as e:
print(f"Error connecting to Audiobookshelf: {e}")
print("Opening BookStorm anyway - use 'a' to reconnect or 'b' for local books")
else:
print("Audiobookshelf not configured")
print("Opening BookStorm anyway - use 'o' to configure server or 'b' for local books")
# Fall through to open BookStorm in interactive mode
# Clear last_book so we don't loop on this error
config.set_last_book('')
# Open BookStorm without a book - user can browse
print("\nStarting BookStorm in interactive mode...")
print("Press 'a' for Audiobookshelf, 'b' for local books, 'r' for recent books")
try:
from src.speech_engine import SpeechEngine
speechEngine = SpeechEngine()
speechEngine.speak("Could not resume stream. Press A for Audiobookshelf, B for local books, or R for recent books.")
# Create a minimal book to satisfy BookReader initialization
# We'll use a dummy book that tells user what to do
reader = BookReader(None, config)
reader.book = None # No book loaded yet
reader.run_interactive() # User can use menus to load a book
return 0
except Exception as e:
print(f"Error starting BookStorm: {e}")
import traceback
traceback.print_exc()
return 1
elif lastBook and Path(lastBook).exists():
bookPath = lastBook
else:
# No book available - open in interactive mode
print("BookStorm - Accessible Book Reader")
if lastBook:
print(f"\nNote: Last book no longer exists: {lastBook}")
else:
print("\nNo previous book found")
print("Starting in interactive mode...")
print("Press 'a' for Audiobookshelf, 'b' for local books, 'r' for recent books\n")
try:
from src.speech_engine import SpeechEngine
speechEngine = SpeechEngine()
speechEngine.speak("BookStorm ready. Press A for Audiobookshelf, B for local books, or R for recent books.")
reader = BookReader(None, config)
reader.book = None
reader.run_interactive()
return 0
except Exception as e:
print(f"Error starting BookStorm: {e}")
import traceback
traceback.print_exc()
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 book.epub --wav # Export to WAV files")
return 1
# Check if book exists (only for local books)
if bookPath and 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())