Fixed bookmarking with audio books.

This commit is contained in:
Storm Dragon
2025-10-16 16:30:58 -04:00
parent 542764be2a
commit 0bdc5bdf17
9 changed files with 648 additions and 178 deletions

View File

@@ -139,10 +139,25 @@ class BookReader:
# 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)
# Use piper-tts (check if available first)
if not TtsEngine.is_available():
# piper-tts not found, fall back to speech-dispatcher
message = "Warning: piper-tts not found. Falling back to speech-dispatcher."
print(message)
self.speechEngine.speak(message)
# Switch to speech-dispatcher mode
self.ttsEngine = None
self.readingEngine = SpeechEngine()
self.readingEngine.set_rate(speechRate)
# Update config to remember this fallback
self.config.set_reader_engine('speechd')
else:
# piper-tts is available
self.readingEngine = None
voiceModel = self.config.get_voice_model()
self.ttsEngine = TtsEngine(voiceModel)
# Playback state
self.isRunning = False
@@ -156,6 +171,7 @@ class BookReader:
# Audio bookmark state
self.savedAudioPosition = 0.0 # Saved audio position for resume
self.bookmarkCleared = False # Track if user explicitly cleared bookmark
def load_book(self):
"""Load and parse the book"""
@@ -276,6 +292,8 @@ class BookReader:
print(f"Resuming from chapter {self.currentChapter + 1}, paragraph {self.currentParagraph + 1}")
else:
print("Starting from beginning")
self.currentChapter = 0
self.currentParagraph = 0
self.savedAudioPosition = 0.0
def read_current_paragraph(self):
@@ -361,14 +379,21 @@ class BookReader:
if not self.book:
return
# Don't save if user explicitly cleared bookmark
if self.bookmarkCleared:
return
# For multi-file audiobooks, sync currentChapter with mpv playlist position FIRST
# This prevents saving bookmarks with stale chapter indices
if hasattr(self.book, 'isMultiFile') and self.book.isMultiFile:
if self.audioPlayer.is_audio_file_loaded():
playlistIndex = self.audioPlayer.get_current_playlist_index()
if playlistIndex != self.currentChapter:
self.currentChapter = playlistIndex
# 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'):
@@ -482,13 +507,29 @@ class BookReader:
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)
# Reload piper-tts with new voice (check if available first)
if not TtsEngine.is_available():
# piper-tts not found, fall back to speech-dispatcher
message = "Warning: piper-tts not found. Falling back to speech-dispatcher."
print(message)
self.speechEngine.speak(message)
# Switch to speech-dispatcher mode
self.ttsEngine = None
self.readingEngine = SpeechEngine()
speechRate = self.config.get_speech_rate()
self.readingEngine.set_rate(speechRate)
# Update config to remember this fallback
self.config.set_reader_engine('speechd')
else:
# piper-tts is available
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"""
@@ -665,13 +706,32 @@ class BookReader:
if self.isPlaying and not inAnyMenu and self.book:
if isAudioBook:
# For multi-file audiobooks, sync playlist position periodically
# This keeps currentChapter in sync with mpv's actual position
if hasattr(self.book, 'isMultiFile') and self.book.isMultiFile:
if self.audioPlayer.is_audio_file_loaded():
playlistIndex = self.audioPlayer.get_current_playlist_index()
if playlistIndex != self.currentChapter:
self.currentChapter = playlistIndex
# Update status display with new chapter info
chapter = self.book.get_chapter(self.currentChapter)
if chapter:
self.statusText = f"Ch {self.currentChapter + 1}/{self.book.get_total_chapters()}: {chapter.title}"
# 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"
# Book finished - restart from beginning
self.displayText = "End of book reached - restarting from chapter 1"
self.speechEngine.speak("Book finished. Restarting from chapter 1.")
self.isPlaying = False
self.currentChapter = 0
self.currentParagraph = 0
self.savedAudioPosition = 0.0
self.save_bookmark(speakFeedback=False)
# Stop playback completely - user must press SPACE to restart
self.audioPlayer.stop_audio_file()
else:
# Start next chapter
self._start_paragraph_playback()
@@ -749,6 +809,17 @@ class BookReader:
else:
self.audioPlayer.stop()
# Clean up TTS mpv subprocess if it's still running
if self.ttsMpvProcess and self.ttsMpvProcess.poll() is None:
try:
self.ttsMpvProcess.terminate()
self.ttsMpvProcess.wait(timeout=2)
except subprocess.TimeoutExpired:
print("Warning: TTS subprocess didn't terminate, force killing")
self.ttsMpvProcess.kill()
except Exception as e:
print(f"Error cleaning up TTS subprocess: {e}")
# Close Audiobookshelf session if active
if self.sessionId and self.absClient:
try:
@@ -762,6 +833,13 @@ class BookReader:
if readerEngine == 'speechd' and self.readingEngine:
self.readingEngine.close()
# Clean up audio player
if self.audioPlayer:
try:
self.audioPlayer.cleanup()
except Exception as e:
print(f"Error cleaning up audio player: {e}")
# Clear pygame resources
self.cachedSurfaces.clear()
pygame.quit()
@@ -802,6 +880,12 @@ class BookReader:
mods = pygame.key.get_mods()
shiftPressed = mods & pygame.KMOD_SHIFT
# Control key by itself: Stop speech-dispatcher (interrupt speech)
if event.key == pygame.K_LCTRL or event.key == pygame.K_RCTRL:
if self.speechEngine:
self.speechEngine.stop()
return
if event.key == pygame.K_SPACE:
# Toggle play/pause (only if book is loaded)
if not self.book:
@@ -852,11 +936,20 @@ class BookReader:
if shiftPressed or isAudioBook:
# Next chapter (or for audio books, always go to next chapter)
self._stop_playback()
wasPlaying = self.isPlaying
# For audio books, just pause - don't stop
if isAudioBook:
if self.audioPlayer.is_audio_file_playing():
self.audioPlayer.pause_audio_file()
else:
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:
# Just say "Next chapter" without title to avoid confusion
self.speechEngine.speak("Next chapter")
if wasPlaying:
self.isPlaying = True
self._start_paragraph_playback()
else:
self.speechEngine.speak("No next chapter")
@@ -881,11 +974,20 @@ class BookReader:
if shiftPressed or isAudioBook:
# Previous chapter (or for audio books, always go to previous chapter)
self._stop_playback()
wasPlaying = self.isPlaying
# For audio books, just pause - don't stop
if isAudioBook:
if self.audioPlayer.is_audio_file_playing():
self.audioPlayer.pause_audio_file()
else:
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:
# Just say "Previous chapter" without title to avoid confusion
self.speechEngine.speak("Previous chapter")
if wasPlaying:
self.isPlaying = True
self._start_paragraph_playback()
else:
self.speechEngine.speak("No previous chapter")
@@ -917,6 +1019,9 @@ class BookReader:
if not wasPaused and self.audioPlayer.is_playing():
self.audioPlayer.pause()
# Re-enable auto-saving if it was disabled
self.bookmarkCleared = False
# Speak feedback (safe with separate sessions)
self.save_bookmark(speakFeedback=True)
@@ -937,6 +1042,9 @@ class BookReader:
# Apply to reading engine as well
if readerEngine == 'speechd':
self.readingEngine.set_rate(newRate)
# If currently reading, restart paragraph to apply new rate immediately
if self.isPlaying and self.readingEngine.is_reading_active():
self._restart_current_paragraph()
self.speechEngine.speak(f"Speech rate: {newRate}")
elif event.key == pygame.K_PAGEDOWN:
@@ -949,6 +1057,9 @@ class BookReader:
# Apply to reading engine as well
if readerEngine == 'speechd':
self.readingEngine.set_rate(newRate)
# If currently reading, restart paragraph to apply new rate immediately
if self.isPlaying and self.readingEngine.is_reading_active():
self._restart_current_paragraph()
self.speechEngine.speak(f"Speech rate: {newRate}")
elif event.key == pygame.K_b:
@@ -979,7 +1090,12 @@ class BookReader:
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")
if self.book and hasattr(self.book, 'isAudioBook') and self.book.isAudioBook:
# Audio book help
self.speechEngine.speak("SPACE: play pause. n: next chapter. p: previous chapter. LEFT RIGHT arrows: seek 5 seconds. UP DOWN arrows: seek 1 minute. SHIFT LEFT RIGHT: seek 30 seconds. SHIFT UP DOWN: seek 5 minutes. s: save bookmark. k: bookmarks menu. r: recent books. b: browse books. a: audiobookshelf. o: options menu. i: current info. Right bracket: increase playback speed. Left bracket: decrease playback speed. Backspace: reset playback speed. SHIFT HOME: clear bookmark and jump to beginning. CONTROL: stop speech. t: time remaining. h: help. q: quit or sleep timer")
else:
# Text book help
self.speechEngine.speak("SPACE: play pause. n: next paragraph. p: previous paragraph. Shift N: next chapter. Shift P: previous chapter. LEFT RIGHT arrows: previous next paragraph. SHIFT LEFT RIGHT: previous next chapter. UP DOWN arrows: skip 5 paragraphs. SHIFT UP DOWN: first last paragraph. 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. SHIFT HOME: clear bookmark and jump to beginning. CONTROL: stop speech. t: time remaining. h: help. q: quit or sleep timer")
elif event.key == pygame.K_i:
if not self.book:
@@ -1042,6 +1158,194 @@ class BookReader:
else:
self.speechEngine.speak("No book loaded")
elif event.key == pygame.K_HOME and shiftPressed:
# Shift+Home: Clear bookmark and jump to beginning of book
if not self.book:
self.speechEngine.speak("No book loaded")
else:
# Stop current playback
wasPlaying = self.isPlaying
self.isPlaying = False
self._stop_playback()
# Delete bookmark for current book
self.bookmarkManager.delete_bookmark(self.bookPath)
self.bookmarkCleared = True # Mark that bookmark was explicitly cleared
# Jump to beginning
self.currentChapter = 0
self.currentParagraph = 0
self.savedAudioPosition = 0.0
# For audio books, seek to beginning
if hasattr(self.book, 'isAudioBook') and self.book.isAudioBook:
if hasattr(self.book, 'isMultiFile') and self.book.isMultiFile:
# Multi-file: seek to first file in playlist
if self.audioPlayer.is_audio_file_loaded():
self.audioPlayer.seek_to_playlist_index(0)
self.audioPlayer.seek_audio(0.0)
else:
# Single-file: seek to time 0
if self.audioPlayer.is_audio_file_loaded():
self.audioPlayer.seek_audio(0.0)
self.speechEngine.speak("Bookmark cleared. Jumped to beginning of book.")
# Resume playback if it was playing
if wasPlaying:
self.isPlaying = True
self._start_paragraph_playback()
elif event.key == pygame.K_LEFT:
# Left arrow: Seek backward (audio books) or previous paragraph (text books)
if not self.book:
self.speechEngine.speak("No book loaded")
return
isAudioBook = hasattr(self.book, 'isAudioBook') and self.book.isAudioBook
if isAudioBook:
# Audio book: Seek backward by 5 or 30 seconds
seekTime = -30 if shiftPressed else -5
self._seek_audio_by_time(seekTime)
else:
# Text book: Previous paragraph or chapter
if shiftPressed:
# Shift+Left: Previous chapter
self._stop_playback()
if self.previous_chapter():
self.speechEngine.speak("Previous chapter")
if self.isPlaying:
self._start_paragraph_playback()
else:
self.speechEngine.speak("No previous chapter")
else:
# Left: Previous paragraph
self._stop_playback()
if self.previous_paragraph():
self.speechEngine.speak(f"Paragraph {self.currentParagraph + 1}")
if self.isPlaying:
self._start_paragraph_playback()
else:
self.speechEngine.speak("Beginning of book")
elif event.key == pygame.K_RIGHT:
# Right arrow: Seek forward (audio books) or next paragraph (text books)
if not self.book:
self.speechEngine.speak("No book loaded")
return
isAudioBook = hasattr(self.book, 'isAudioBook') and self.book.isAudioBook
if isAudioBook:
# Audio book: Seek forward by 5 or 30 seconds
seekTime = 30 if shiftPressed else 5
self._seek_audio_by_time(seekTime)
else:
# Text book: Next paragraph or chapter
if shiftPressed:
# Shift+Right: Next chapter
self._stop_playback()
if self.next_chapter():
self.speechEngine.speak("Next chapter")
if self.isPlaying:
self._start_paragraph_playback()
else:
self.speechEngine.speak("No next chapter")
self.isPlaying = False
else:
# Right: Next paragraph
self._stop_playback()
if self.next_paragraph():
self.speechEngine.speak(f"Paragraph {self.currentParagraph + 1}")
if self.isPlaying:
self._start_paragraph_playback()
else:
self.speechEngine.speak("End of book")
self.isPlaying = False
elif event.key == pygame.K_UP:
# Up arrow: Seek backward (audio books) or skip paragraphs backward (text books)
if not self.book:
self.speechEngine.speak("No book loaded")
return
isAudioBook = hasattr(self.book, 'isAudioBook') and self.book.isAudioBook
if isAudioBook:
# Audio book: Seek backward by 1 or 5 minutes
seekTime = -300 if shiftPressed else -60
self._seek_audio_by_time(seekTime)
else:
# Text book: Jump to first paragraph of chapter (Shift) or skip back 5 paragraphs
if shiftPressed:
# Shift+Up: Jump to first paragraph of chapter
if self.currentParagraph > 0:
self._stop_playback()
self.currentParagraph = 0
self.speechEngine.speak(f"First paragraph")
if self.isPlaying:
self._start_paragraph_playback()
else:
self.speechEngine.speak("Already at first paragraph")
else:
# Up: Skip back 5 paragraphs
self._stop_playback()
targetParagraph = max(0, self.currentParagraph - 5)
moved = self.currentParagraph != targetParagraph
self.currentParagraph = targetParagraph
if moved:
self.speechEngine.speak(f"Paragraph {self.currentParagraph + 1}")
if self.isPlaying:
self._start_paragraph_playback()
else:
self.speechEngine.speak("Beginning of chapter")
elif event.key == pygame.K_DOWN:
# Down arrow: Seek forward (audio books) or skip paragraphs forward (text books)
if not self.book:
self.speechEngine.speak("No book loaded")
return
isAudioBook = hasattr(self.book, 'isAudioBook') and self.book.isAudioBook
if isAudioBook:
# Audio book: Seek forward by 1 or 5 minutes
seekTime = 300 if shiftPressed else 60
self._seek_audio_by_time(seekTime)
else:
# Text book: Jump to last paragraph of chapter (Shift) or skip forward 5 paragraphs
chapter = self.book.get_chapter(self.currentChapter)
if not chapter:
return
if shiftPressed:
# Shift+Down: Jump to last paragraph of chapter
lastParagraph = chapter.get_total_paragraphs() - 1
if self.currentParagraph < lastParagraph:
self._stop_playback()
self.currentParagraph = lastParagraph
self.speechEngine.speak(f"Last paragraph")
if self.isPlaying:
self._start_paragraph_playback()
else:
self.speechEngine.speak("Already at last paragraph")
else:
# Down: Skip forward 5 paragraphs
self._stop_playback()
maxParagraph = chapter.get_total_paragraphs() - 1
targetParagraph = min(maxParagraph, self.currentParagraph + 5)
moved = self.currentParagraph != targetParagraph
self.currentParagraph = targetParagraph
if moved:
self.speechEngine.speak(f"Paragraph {self.currentParagraph + 1}")
if self.isPlaying:
self._start_paragraph_playback()
else:
self.speechEngine.speak("End of chapter")
def _handle_recent_books_key(self, event):
"""Handle key events when in recent books menu"""
if event.key == pygame.K_UP:
@@ -1121,9 +1425,17 @@ class BookReader:
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)
# For multi-file audiobooks, seek to correct file in playlist
if hasattr(self.book, 'isMultiFile') and self.book.isMultiFile:
if self.audioPlayer.is_audio_file_loaded():
if self.audioPlayer.seek_to_playlist_index(self.currentChapter):
# Seek to position within the file
self.audioPlayer.seek_audio(positionInChapter)
else:
# Single-file audiobook: seek to absolute position
if self.audioPlayer.is_audio_file_loaded():
self.audioPlayer.seek_audio(audioPosition)
break
# Speak feedback
@@ -1154,6 +1466,11 @@ class BookReader:
# Calculate audio position if audio book
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
chapter = self.book.get_chapter(self.currentChapter)
if chapter and hasattr(chapter, 'startTime'):
chapterStartTime = chapter.startTime
@@ -1684,6 +2001,50 @@ class BookReader:
speedPercent = int(newSpeed * 100)
self.speechEngine.speak(f"Speed {speedPercent} percent")
def _seek_audio_by_time(self, seconds):
"""
Seek audio forward/backward by specified seconds (audio books only)
Args:
seconds: Number of seconds to seek (positive = forward, negative = backward)
Returns:
True if seek successful, False otherwise
"""
# Only works for audio books
if not self.book:
return False
isAudioBook = hasattr(self.book, 'isAudioBook') and self.book.isAudioBook
if not isAudioBook:
return False
# Get current position
currentPos = self.audioPlayer.get_audio_position()
# Calculate new position
newPos = max(0.0, currentPos + seconds)
# Seek to new position
if self.audioPlayer.seek_audio(newPos):
# Format time for speech feedback
absSeconds = abs(seconds)
if absSeconds >= 60:
minutes = int(absSeconds // 60)
secs = int(absSeconds % 60)
if secs > 0:
timeStr = f"{minutes} minutes {secs} seconds"
else:
timeStr = f"{minutes} minutes"
else:
timeStr = f"{int(absSeconds)} seconds"
direction = "forward" if seconds > 0 else "backward"
self.speechEngine.speak(f"Seek {timeStr} {direction}")
return True
return False
def _load_new_book(self, bookPath):
"""
Load a new book from file path
@@ -1738,10 +2099,31 @@ class BookReader:
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)
try:
self.ttsMpvProcess.terminate()
self.ttsMpvProcess.wait(timeout=1)
except subprocess.TimeoutExpired:
# Force kill if it doesn't terminate gracefully
print("Warning: TTS subprocess didn't terminate, force killing")
self.ttsMpvProcess.kill()
self.ttsMpvProcess.wait(timeout=1)
except Exception as e:
print(f"Error terminating TTS subprocess: {e}")
self.audioPlayer.stop()
def _restart_current_paragraph(self):
"""
Restart current paragraph playback (for speech-dispatcher rate changes)
This is needed because speech-dispatcher only applies rate changes
to the next speech utterance, not to currently playing speech.
"""
# Cancel current speech
self.readingEngine.cancel_reading()
# Restart from current paragraph
self._start_paragraph_playback()
def _start_paragraph_playback(self):
"""Start playing current paragraph"""
chapter = self.book.get_chapter(self.currentChapter)
@@ -1899,6 +2281,14 @@ class BookReader:
if isMultiFile:
# For multi-file audiobooks, seek to the correct file in playlist
# Chapter index = file index (each file is a chapter)
# Validate chapter index is within bounds
totalChapters = self.book.get_total_chapters()
if self.currentChapter < 0 or self.currentChapter >= totalChapters:
print(f"ERROR: Invalid chapter index {self.currentChapter} (total chapters: {totalChapters})")
self.currentChapter = 0
print(f"Reset to chapter 0")
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