2410 lines
98 KiB
Python
Executable File
2410 lines
98 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
|
|
import subprocess
|
|
|
|
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.folder_audiobook_parser import FolderAudiobookParser
|
|
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.mpv_player import MpvPlayer
|
|
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 = MpvPlayer()
|
|
self.ttsMpvProcess = None # For direct mpv subprocess for TTS
|
|
|
|
# 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"""
|
|
# Check if bookPath is a directory (folder audiobook)
|
|
if self.bookPath.is_dir():
|
|
message = f"Loading audiobook folder {self.bookPath.name}"
|
|
print(message)
|
|
self.speechEngine.speak(message)
|
|
|
|
# Use folder audiobook parser
|
|
self.parser = FolderAudiobookParser()
|
|
self.book = self.parser.parse(self.bookPath)
|
|
else:
|
|
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:
|
|
# Get saved playback speed from config
|
|
playbackSpeed = self.config.get_playback_speed()
|
|
|
|
# Check if multi-file audiobook (folder)
|
|
if hasattr(self.book, 'isMultiFile') and self.book.isMultiFile:
|
|
# Multi-file audiobook - load playlist into player
|
|
if not self.audioPlayer.load_audio_playlist(self.book.audioFiles, playbackSpeed=playbackSpeed):
|
|
raise Exception("Failed to load audio playlist")
|
|
else:
|
|
# Single-file audiobook
|
|
if not self.audioPlayer.load_audio_file(self.book.audioPath, playbackSpeed=playbackSpeed):
|
|
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.")
|
|
|
|
# Check if this book is linked to Audiobookshelf server
|
|
# If so, prioritize server progress over local bookmark
|
|
serverLink = self.serverLinkManager.get_link(str(self.bookPath))
|
|
serverProgressLoaded = False
|
|
|
|
if serverLink and self.absClient and self.absClient.is_authenticated():
|
|
serverId = serverLink.get('server_id')
|
|
if serverId:
|
|
try:
|
|
serverProgress = self.absClient.get_progress(serverId)
|
|
if serverProgress:
|
|
progressTime = serverProgress.get('currentTime', 0.0)
|
|
if progressTime > 0:
|
|
minutes = int(progressTime // 60)
|
|
seconds = int(progressTime % 60)
|
|
print(f"Resuming from server progress: {minutes}m {seconds}s")
|
|
|
|
# For audio books, save exact position
|
|
if hasattr(self.book, 'isAudioBook') and self.book.isAudioBook:
|
|
self.savedAudioPosition = progressTime
|
|
|
|
# Find chapter that contains this time
|
|
for i, chap in enumerate(self.book.chapters):
|
|
if hasattr(chap, 'startTime'):
|
|
chapterEnd = chap.startTime + chap.duration
|
|
if chap.startTime <= progressTime < chapterEnd:
|
|
self.currentChapter = i
|
|
break
|
|
else:
|
|
# Text book - use chapter/paragraph from server if available
|
|
# (Audiobookshelf doesn't track paragraph, so we'd need to enhance this)
|
|
pass
|
|
|
|
serverProgressLoaded = True
|
|
except Exception as e:
|
|
print(f"Could not load server progress: {e}")
|
|
|
|
# Fall back to local bookmark if no server progress
|
|
if not serverProgressLoaded:
|
|
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 local bookmark: 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:
|
|
# For multi-file audiobooks, sync currentChapter with playlist position
|
|
if hasattr(self.book, 'isMultiFile') and self.book.isMultiFile:
|
|
playlistIndex = self.audioPlayer.get_current_playlist_index()
|
|
self.currentChapter = playlistIndex
|
|
|
|
# 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: {progress * 100:.1f}%")
|
|
|
|
# Also sync session if active
|
|
if self.sessionId:
|
|
syncSuccess = self.absClient.sync_session(self.sessionId, currentTime, duration, progress)
|
|
if syncSuccess:
|
|
# Update session in server link to persist it
|
|
if self.bookPath:
|
|
self.serverLinkManager.update_session(str(self.bookPath), self.sessionId)
|
|
else:
|
|
# Session sync failed - might be expired, create new one
|
|
print(f"Session sync failed, creating new session...")
|
|
newSessionId = self.absClient.create_session(serverId)
|
|
if newSessionId:
|
|
self.sessionId = newSessionId
|
|
if self.bookPath:
|
|
self.serverLinkManager.update_session(str(self.bookPath), self.sessionId)
|
|
print(f"Created new session: {self.sessionId}")
|
|
|
|
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 subprocess state
|
|
# The TTS mpv process runs independently, so we need to check if it's still running
|
|
playbackFinished = False
|
|
if self.ttsMpvProcess:
|
|
# Check if the mpv subprocess has finished
|
|
if self.ttsMpvProcess.poll() is not None:
|
|
playbackFinished = True
|
|
else:
|
|
# No process exists, consider playback finished
|
|
playbackFinished = True
|
|
|
|
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
|
|
# pylint: disable=no-member
|
|
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:
|
|
# Save bookmark BEFORE stopping (so we can get current position)
|
|
self.save_bookmark(speakFeedback=False)
|
|
|
|
# Stop playback
|
|
readerEngine = self.config.get_reader_engine()
|
|
if readerEngine == 'speechd':
|
|
self.readingEngine.cancel_reading()
|
|
else:
|
|
self.audioPlayer.stop()
|
|
|
|
# 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. Right bracket: increase playback speed. Left bracket: decrease playback speed. Backspace: reset playback speed. 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()
|
|
|
|
elif event.key == pygame.K_RIGHTBRACKET:
|
|
# Increase playback speed (works for all books!)
|
|
if self.book:
|
|
currentSpeed = self.config.get_playback_speed()
|
|
newSpeed = min(2.0, currentSpeed + 0.1)
|
|
newSpeed = round(newSpeed, 1) # Round to 1 decimal place
|
|
self._change_playback_speed(newSpeed)
|
|
else:
|
|
self.speechEngine.speak("No book loaded")
|
|
|
|
elif event.key == pygame.K_LEFTBRACKET:
|
|
# Decrease playback speed (works for all books!)
|
|
if self.book:
|
|
currentSpeed = self.config.get_playback_speed()
|
|
newSpeed = max(0.5, currentSpeed - 0.1)
|
|
newSpeed = round(newSpeed, 1) # Round to 1 decimal place
|
|
self._change_playback_speed(newSpeed)
|
|
else:
|
|
self.speechEngine.speak("No book loaded")
|
|
|
|
elif event.key == pygame.K_BACKSPACE:
|
|
# Reset playback speed to 1.0
|
|
# Only if not in a menu (menus handle backspace themselves)
|
|
if not (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.book:
|
|
self._change_playback_speed(1.0)
|
|
else:
|
|
self.speechEngine.speak("No book loaded")
|
|
|
|
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(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 (only if we don't already have one from resume)
|
|
if not self.sessionId:
|
|
self.sessionId = self.absClient.create_session(serverId)
|
|
if self.sessionId:
|
|
print(f"Created listening session: {self.sessionId}")
|
|
else:
|
|
print(f"Using existing session ID: {self.sessionId}")
|
|
|
|
# Save session and server metadata to server link manager
|
|
# This allows resuming the stream with the same session
|
|
self.serverLinkManager.create_link(
|
|
bookPath=streamUrl,
|
|
serverUrl=self.absClient.serverUrl,
|
|
serverId=serverId,
|
|
libraryId=serverBook.get('libraryId', ''),
|
|
title=title,
|
|
author=author,
|
|
duration=duration,
|
|
chapters=len(book.chapters),
|
|
sessionId=self.sessionId,
|
|
serverBook=serverBook
|
|
)
|
|
|
|
# Try to load progress from server
|
|
serverProgress = self.absClient.get_progress(serverId)
|
|
if serverProgress:
|
|
progressTime = serverProgress.get('currentTime', 0.0)
|
|
minutes = int(progressTime // 60)
|
|
seconds = int(progressTime % 60)
|
|
print(f"Resuming from server progress: {minutes}m {seconds}s ({progressTime:.1f}s)")
|
|
|
|
# Save the exact position for playback resume
|
|
self.savedAudioPosition = progressTime
|
|
|
|
# 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
|
|
self.savedAudioPosition = 0.0
|
|
|
|
# Initialize position
|
|
self.currentParagraph = 0
|
|
|
|
# Load stream URL directly - mpv will handle streaming natively
|
|
print(f"Loading stream: {streamUrl[:80]}...")
|
|
self.speechEngine.speak("Loading stream. This may take a moment.")
|
|
|
|
# Load the stream URL - mpv handles streaming with auth headers
|
|
# Pass auth token for authentication
|
|
# Use saved playback speed from config
|
|
playbackSpeed = self.config.get_playback_speed()
|
|
if not self.audioPlayer.load_audio_file(streamUrl, authToken=self.absClient.authToken, playbackSpeed=playbackSpeed):
|
|
self.speechEngine.speak("Failed to load stream. Check terminal for errors.")
|
|
print("\nERROR: Failed to load stream from server")
|
|
print("Make sure mpv is installed: sudo pacman -S mpv")
|
|
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 (only if screen is initialized)
|
|
if self.config.get_show_text() and hasattr(self, 'screen') and self.screen:
|
|
self._render_screen()
|
|
|
|
# Start playback from saved position (if any)
|
|
startPos = self.savedAudioPosition if self.savedAudioPosition > 0 else 0.0
|
|
if startPos > 0:
|
|
minutes = int(startPos // 60)
|
|
seconds = int(startPos % 60)
|
|
print(f"Seeking to {minutes}m {seconds}s...")
|
|
self.audioPlayer.play_audio_file(startPosition=startPos)
|
|
self.isPlaying = True
|
|
self.isAudioBook = True
|
|
|
|
# Clear saved position after using it
|
|
self.savedAudioPosition = 0.0
|
|
|
|
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 _change_playback_speed(self, newSpeed):
|
|
"""
|
|
Change audio playback speed (instant with mpv!)
|
|
|
|
Args:
|
|
newSpeed: New playback speed (0.5 to 2.0)
|
|
"""
|
|
# Clamp speed to valid range
|
|
newSpeed = max(0.5, min(2.0, float(newSpeed)))
|
|
|
|
# Save to config
|
|
self.config.set_playback_speed(newSpeed)
|
|
|
|
# Apply speed change based on reader engine and book type
|
|
readerEngine = self.config.get_reader_engine()
|
|
isAudioBook = self.book and hasattr(self.book, 'isAudioBook') and self.book.isAudioBook
|
|
|
|
if isAudioBook:
|
|
# Audio books: instant speed change via MpvPlayer
|
|
self.audioPlayer.set_speed(newSpeed)
|
|
elif readerEngine == 'piper' and self.isPlaying:
|
|
# Piper-TTS: restart current paragraph with new speed
|
|
# Stop current subprocess
|
|
if self.ttsMpvProcess and self.ttsMpvProcess.poll() is None:
|
|
self.ttsMpvProcess.terminate()
|
|
self.ttsMpvProcess.wait(timeout=0.5)
|
|
# Restart playback of current paragraph
|
|
self._start_paragraph_playback()
|
|
|
|
# Speak feedback
|
|
speedPercent = int(newSpeed * 100)
|
|
self.speechEngine.speak(f"Speed {speedPercent} percent")
|
|
|
|
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()
|
|
# Terminate the TTS mpv subprocess if it's running
|
|
if self.ttsMpvProcess and self.ttsMpvProcess.poll() is None:
|
|
self.ttsMpvProcess.terminate()
|
|
self.ttsMpvProcess.wait(timeout=1)
|
|
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:
|
|
# Stop any existing TTS mpv process
|
|
if self.ttsMpvProcess and self.ttsMpvProcess.poll() is None:
|
|
self.ttsMpvProcess.terminate()
|
|
self.ttsMpvProcess.wait()
|
|
|
|
# Get audio parameters from TTS engine
|
|
audioParams = self.ttsEngine.get_audio_params()
|
|
sampleRate = audioParams['sampleRate']
|
|
sampleWidth = audioParams['sampleWidth']
|
|
channels = audioParams['channels']
|
|
|
|
# Determine mpv audio format string
|
|
# piper-tts outputs 16-bit signed PCM
|
|
mpvAudioFormat = 's16' # 16-bit signed integer
|
|
if channels == 2: # Stereo
|
|
mpvAudioFormat += 'le' # Little-endian (default for WAV)
|
|
|
|
# Launch mpv subprocess to read from stdin
|
|
# Get current playback speed from config
|
|
playbackSpeed = self.config.get_playback_speed()
|
|
mpvCmd = [
|
|
'mpv',
|
|
'--no-terminal',
|
|
f'--speed={playbackSpeed}',
|
|
'--', '-'
|
|
]
|
|
|
|
self.ttsMpvProcess = subprocess.Popen(
|
|
mpvCmd,
|
|
stdin=subprocess.PIPE
|
|
)
|
|
|
|
# Write WAV data to mpv's stdin in a separate thread
|
|
def write_mpv_stdin(process, data):
|
|
try:
|
|
process.stdin.write(data)
|
|
process.stdin.flush()
|
|
process.stdin.close()
|
|
# Explicitly delete data to free memory immediately
|
|
del data
|
|
except Exception as e:
|
|
print(f"Error writing WAV data to mpv stdin: {e}")
|
|
finally:
|
|
# Wait for mpv to finish and clean up the process
|
|
try:
|
|
process.wait()
|
|
except:
|
|
pass
|
|
|
|
threading.Thread(
|
|
target=write_mpv_stdin,
|
|
args=(self.ttsMpvProcess, wavData),
|
|
daemon=True
|
|
).start()
|
|
|
|
# Explicitly delete wavData after playback starts to free memory
|
|
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:
|
|
# The variable is already set to None in all relevant paths
|
|
pass
|
|
|
|
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}"
|
|
|
|
# Check if multi-file audiobook (each chapter is a separate file)
|
|
isMultiFile = hasattr(self.book, 'isMultiFile') and self.book.isMultiFile
|
|
|
|
# Determine start position
|
|
# If we have a saved audio position and we're on the saved chapter, use it
|
|
# Save the position before clearing it (we'll need it later for multi-file)
|
|
resumePosition = self.savedAudioPosition
|
|
if resumePosition > 0.0:
|
|
startTime = resumePosition
|
|
# 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:
|
|
if isMultiFile:
|
|
# For multi-file audiobooks, seek to the correct file in playlist
|
|
# Chapter index = file index (each file is a chapter)
|
|
if self.audioPlayer.seek_to_playlist_index(self.currentChapter):
|
|
# Calculate position within current file
|
|
# If resuming (saved position > 0), use position relative to chapter start
|
|
positionInFile = 0.0
|
|
if resumePosition > 0.0:
|
|
# Get current chapter's start time to calculate position within file
|
|
chapter = self.book.get_chapter(self.currentChapter)
|
|
if chapter and hasattr(chapter, 'startTime'):
|
|
positionInFile = resumePosition - chapter.startTime
|
|
# Ensure position is within file bounds
|
|
if positionInFile < 0:
|
|
positionInFile = 0.0
|
|
elif hasattr(chapter, 'duration') and positionInFile > chapter.duration:
|
|
positionInFile = chapter.duration
|
|
# Start playback at calculated position
|
|
self.audioPlayer.play_audio_file(startPosition=positionInFile)
|
|
else:
|
|
print(f"ERROR: Could not seek to playlist index {self.currentChapter}")
|
|
self.isPlaying = False
|
|
else:
|
|
# Single-file audiobook with chapter markers
|
|
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_to_cleanup = None
|
|
try:
|
|
# Generate audio
|
|
wavData_generated = self.ttsEngine.text_to_wav_data(paragraph)
|
|
_wavData_to_cleanup = wavData_generated
|
|
|
|
# Check if cancelled
|
|
if self.cancelBuffer:
|
|
del _wavData_to_cleanup
|
|
_wavData_to_cleanup = None
|
|
return
|
|
|
|
# Store buffered audio
|
|
with self.bufferLock:
|
|
if not self.cancelBuffer:
|
|
self.bufferedAudio = _wavData_to_cleanup
|
|
_wavData_to_cleanup = None # Transfer ownership
|
|
except Exception as e:
|
|
print(f"Error buffering paragraph: {e}")
|
|
with self.bufferLock:
|
|
self.bufferedAudio = None
|
|
if _wavData_to_cleanup is not None:
|
|
del _wavData_to_cleanup
|
|
_wavData_to_cleanup = None
|
|
finally:
|
|
# The variable is already set to None in all relevant paths
|
|
pass
|
|
|
|
# Clear any cancelled buffer state
|
|
with self.bufferLock:
|
|
self.cancelBuffer = False
|
|
|
|
# Clean up previous buffer thread reference before starting new one
|
|
if self.bufferThread and not self.bufferThread.is_alive():
|
|
self.bufferThread = None
|
|
|
|
# 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)
|
|
# Clear session from link metadata
|
|
if self.bookPath:
|
|
self.serverLinkManager.clear_session(str(self.bookPath))
|
|
self.sessionId = None
|
|
|
|
self._cancel_buffer()
|
|
# Terminate the TTS mpv subprocess if it's running
|
|
if self.ttsMpvProcess and self.ttsMpvProcess.poll() is None:
|
|
self.ttsMpvProcess.terminate()
|
|
self.ttsMpvProcess.wait(timeout=1)
|
|
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 restore from cached server link
|
|
from src.server_link_manager import ServerLinkManager
|
|
serverLinkManager = ServerLinkManager()
|
|
|
|
# Try to find cached server book metadata by server ID
|
|
# The bookPath in the link will be the stream URL from last session
|
|
cachedLink = None
|
|
for sidecarPath in (Path.home() / ".bookstorm" / "server_links").glob("*.json"):
|
|
try:
|
|
import json
|
|
with open(sidecarPath, 'r') as f:
|
|
linkData = json.load(f)
|
|
if linkData.get('server_id') == serverId:
|
|
cachedLink = linkData
|
|
break
|
|
except:
|
|
continue
|
|
|
|
if cachedLink and cachedLink.get('server_book'):
|
|
# We have cached metadata - try to resume using it
|
|
print(f"Found cached server book metadata, resuming stream...")
|
|
|
|
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():
|
|
# Use cached server book metadata instead of re-fetching
|
|
serverBook = cachedLink['server_book']
|
|
print(f"Restoring from cached metadata...")
|
|
|
|
try:
|
|
from src.speech_engine import SpeechEngine
|
|
speechEngine = SpeechEngine()
|
|
|
|
reader = BookReader(None, config)
|
|
reader.absClient = absClient
|
|
|
|
# Restore session ID if it was saved
|
|
savedSessionId = cachedLink.get('session_id')
|
|
if savedSessionId:
|
|
print(f"Restoring listening session: {savedSessionId}")
|
|
reader.sessionId = savedSessionId
|
|
|
|
reader._stream_audiobook(serverBook)
|
|
reader.run_interactive()
|
|
return 0
|
|
|
|
except Exception as e:
|
|
print(f"Error resuming stream: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
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")
|
|
else:
|
|
# No cached metadata - try fetching from server
|
|
print(f"No cached metadata, fetching from server...")
|
|
|
|
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())
|