Code cleanup and stability sprint. Also, more robust search for calibre.
This commit is contained in:
+108
-33
@@ -201,6 +201,9 @@ class BookReader:
|
|||||||
# Playback state
|
# Playback state
|
||||||
self.isRunning = False
|
self.isRunning = False
|
||||||
self.isPlaying = False
|
self.isPlaying = False
|
||||||
|
self.hasPendingSpeechCompletion = False
|
||||||
|
self.pendingSpeechCompletionToken = None
|
||||||
|
self.expectedSpeechCompletionToken = 0
|
||||||
|
|
||||||
# Sleep timer fade-out state
|
# Sleep timer fade-out state
|
||||||
self.isFadingOut = False
|
self.isFadingOut = False
|
||||||
@@ -902,23 +905,8 @@ class BookReader:
|
|||||||
self._handle_pygame_key(event)
|
self._handle_pygame_key(event)
|
||||||
|
|
||||||
elif event.type == SPEECH_FINISHED_EVENT:
|
elif event.type == SPEECH_FINISHED_EVENT:
|
||||||
# Callback-driven paragraph finished, advance to next
|
completionToken = getattr(event, 'completionToken', None)
|
||||||
# Don't auto-advance if in any menu
|
self._process_speech_completion(completionToken)
|
||||||
inAnyMenu = (self.optionsMenu.is_in_menu() or
|
|
||||||
self.bookSelector.is_in_browser() or
|
|
||||||
self.sleepTimerMenu.is_in_menu() or
|
|
||||||
self.helpMenu.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
|
# Explicitly delete event objects to help GC
|
||||||
del events
|
del events
|
||||||
@@ -956,12 +944,9 @@ class BookReader:
|
|||||||
isAudioBook = self.book and hasattr(self.book, 'isAudioBook') and self.book.isAudioBook
|
isAudioBook = self.book and hasattr(self.book, 'isAudioBook') and self.book.isAudioBook
|
||||||
readerEngine = self.config.get_reader_engine()
|
readerEngine = self.config.get_reader_engine()
|
||||||
# Don't auto-advance if in any menu
|
# Don't auto-advance if in any menu
|
||||||
inAnyMenu = (self.optionsMenu.is_in_menu() or
|
inAnyMenu = self._is_in_any_menu()
|
||||||
self.bookSelector.is_in_browser() or
|
|
||||||
self.sleepTimerMenu.is_in_menu() or
|
self._flush_pending_speech_completion()
|
||||||
self.helpMenu.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 self.isPlaying and not inAnyMenu and self.book:
|
||||||
if isAudioBook:
|
if isAudioBook:
|
||||||
@@ -1015,9 +1000,6 @@ class BookReader:
|
|||||||
# Every ~10 seconds (300 frames at 30 FPS) run GC
|
# Every ~10 seconds (300 frames at 30 FPS) run GC
|
||||||
gcCounter += 1
|
gcCounter += 1
|
||||||
if gcCounter >= 300:
|
if gcCounter >= 300:
|
||||||
# Clear any accumulated pygame events before GC
|
|
||||||
pygame.event.clear()
|
|
||||||
|
|
||||||
# Alternate between fast (gen 0) and full GC
|
# Alternate between fast (gen 0) and full GC
|
||||||
# At 300: gen 0 only (fast)
|
# At 300: gen 0 only (fast)
|
||||||
# At 600: full collection (all generations)
|
# At 600: full collection (all generations)
|
||||||
@@ -1284,18 +1266,23 @@ class BookReader:
|
|||||||
self.speechEngine.speak("No book loaded")
|
self.speechEngine.speak("No book loaded")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
isAudioBook = hasattr(self.book, 'isAudioBook') and self.book.isAudioBook
|
||||||
readerEngine = self.config.get_reader_engine()
|
readerEngine = self.config.get_reader_engine()
|
||||||
|
|
||||||
# Pause playback while saving
|
# Pause playback while saving
|
||||||
wasPaused = False
|
wasPaused = False
|
||||||
if readerEngine in ['speechd', 'screenreader']:
|
if isAudioBook:
|
||||||
|
wasPaused = self.audioPlayer.is_paused()
|
||||||
|
if not wasPaused and self.audioPlayer.is_audio_file_playing():
|
||||||
|
self.audioPlayer.pause_audio_file()
|
||||||
|
elif readerEngine in ['speechd', 'screenreader']:
|
||||||
wasPaused = self.readingEngine.is_reading_paused()
|
wasPaused = self.readingEngine.is_reading_paused()
|
||||||
if not wasPaused and self.readingEngine.is_reading_active():
|
if not wasPaused and self.readingEngine.is_reading_active():
|
||||||
self.readingEngine.pause_reading()
|
self.readingEngine.pause_reading()
|
||||||
else:
|
else:
|
||||||
wasPaused = self.audioPlayer.is_paused()
|
wasPaused = self.audioPlayer.is_paused()
|
||||||
if not wasPaused and self.audioPlayer.is_playing():
|
if not wasPaused and self.audioPlayer.is_audio_file_playing():
|
||||||
self.audioPlayer.pause()
|
self.audioPlayer.pause_audio_file()
|
||||||
|
|
||||||
# Re-enable auto-saving if it was disabled
|
# Re-enable auto-saving if it was disabled
|
||||||
self.bookmarkCleared = False
|
self.bookmarkCleared = False
|
||||||
@@ -1305,10 +1292,12 @@ class BookReader:
|
|||||||
|
|
||||||
# Resume playback
|
# Resume playback
|
||||||
if not wasPaused and self.isPlaying:
|
if not wasPaused and self.isPlaying:
|
||||||
if readerEngine in ['speechd', 'screenreader']:
|
if isAudioBook:
|
||||||
|
self.audioPlayer.resume_audio_file()
|
||||||
|
elif readerEngine in ['speechd', 'screenreader']:
|
||||||
self.readingEngine.resume_reading()
|
self.readingEngine.resume_reading()
|
||||||
else:
|
else:
|
||||||
self.audioPlayer.resume()
|
self.audioPlayer.resume_audio_file()
|
||||||
|
|
||||||
elif event.key == pygame.K_PAGEUP:
|
elif event.key == pygame.K_PAGEUP:
|
||||||
# Increase speech rate
|
# Increase speech rate
|
||||||
@@ -1672,6 +1661,73 @@ class BookReader:
|
|||||||
else:
|
else:
|
||||||
self.speechEngine.speak("End of chapter")
|
self.speechEngine.speak("End of chapter")
|
||||||
|
|
||||||
|
def _is_in_any_menu(self):
|
||||||
|
"""Return True when any modal menu/browser is active."""
|
||||||
|
return (
|
||||||
|
self.optionsMenu.is_in_menu() or
|
||||||
|
self.bookmarksMenu.is_in_menu() or
|
||||||
|
self.bookSelector.is_in_browser() or
|
||||||
|
self.sleepTimerMenu.is_in_menu() or
|
||||||
|
self.helpMenu.is_in_menu() or
|
||||||
|
self.recentBooksMenu.is_in_menu() or
|
||||||
|
(self.absMenu and self.absMenu.is_in_menu())
|
||||||
|
)
|
||||||
|
|
||||||
|
def _queue_pending_speech_completion(self, completionToken=None):
|
||||||
|
"""Store a completion event to process after menus close."""
|
||||||
|
self.hasPendingSpeechCompletion = True
|
||||||
|
self.pendingSpeechCompletionToken = completionToken
|
||||||
|
|
||||||
|
def _process_speech_completion(self, completionToken=None):
|
||||||
|
"""Advance paragraph/chapter for callback-driven completion events."""
|
||||||
|
if not (self.isPlaying and self.book):
|
||||||
|
return
|
||||||
|
if hasattr(self.book, 'isAudioBook') and self.book.isAudioBook:
|
||||||
|
return
|
||||||
|
|
||||||
|
readerEngine = self.config.get_reader_engine()
|
||||||
|
if readerEngine not in ['speechd', 'screenreader']:
|
||||||
|
return
|
||||||
|
|
||||||
|
if completionToken is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
tokenInt = int(completionToken)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
if tokenInt != self.expectedSpeechCompletionToken:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._is_in_any_menu():
|
||||||
|
self._queue_pending_speech_completion(tokenInt)
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.readingEngine and self.readingEngine.is_reading_paused():
|
||||||
|
return
|
||||||
|
if self.readingEngine:
|
||||||
|
tokenCheck = getattr(self.readingEngine, 'is_completion_token_current', None)
|
||||||
|
if callable(tokenCheck) and not tokenCheck(tokenInt):
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.next_paragraph():
|
||||||
|
self.displayText = "End of book reached"
|
||||||
|
self.isPlaying = False
|
||||||
|
self.save_bookmark(speakFeedback=False)
|
||||||
|
else:
|
||||||
|
self._start_paragraph_playback()
|
||||||
|
|
||||||
|
def _flush_pending_speech_completion(self):
|
||||||
|
"""Process a deferred completion event once menus are closed."""
|
||||||
|
if not self.hasPendingSpeechCompletion:
|
||||||
|
return
|
||||||
|
if self._is_in_any_menu():
|
||||||
|
return
|
||||||
|
|
||||||
|
completionToken = self.pendingSpeechCompletionToken
|
||||||
|
self.hasPendingSpeechCompletion = False
|
||||||
|
self.pendingSpeechCompletionToken = None
|
||||||
|
self._process_speech_completion(completionToken)
|
||||||
|
|
||||||
def _is_help_key(self, event):
|
def _is_help_key(self, event):
|
||||||
"""Return True when the key event should open or close help."""
|
"""Return True when the key event should open or close help."""
|
||||||
if event.key == pygame.K_F1:
|
if event.key == pygame.K_F1:
|
||||||
@@ -2166,6 +2222,11 @@ class BookReader:
|
|||||||
if self.book:
|
if self.book:
|
||||||
self.save_bookmark(speakFeedback=False)
|
self.save_bookmark(speakFeedback=False)
|
||||||
self._close_abs_session()
|
self._close_abs_session()
|
||||||
|
if self.readingEngine and hasattr(self.readingEngine, 'cancel_reading'):
|
||||||
|
self.readingEngine.cancel_reading()
|
||||||
|
self.expectedSpeechCompletionToken += 1
|
||||||
|
self.hasPendingSpeechCompletion = False
|
||||||
|
self.pendingSpeechCompletionToken = None
|
||||||
self.audioPlayer.stop()
|
self.audioPlayer.stop()
|
||||||
if self.audioPlayer.is_audio_file_loaded():
|
if self.audioPlayer.is_audio_file_loaded():
|
||||||
self.audioPlayer.stop_audio_file()
|
self.audioPlayer.stop_audio_file()
|
||||||
@@ -2549,6 +2610,11 @@ class BookReader:
|
|||||||
self._close_abs_session()
|
self._close_abs_session()
|
||||||
|
|
||||||
# Stop current playback
|
# Stop current playback
|
||||||
|
if self.readingEngine and hasattr(self.readingEngine, 'cancel_reading'):
|
||||||
|
self.readingEngine.cancel_reading()
|
||||||
|
self.expectedSpeechCompletionToken += 1
|
||||||
|
self.hasPendingSpeechCompletion = False
|
||||||
|
self.pendingSpeechCompletionToken = None
|
||||||
self.audioPlayer.stop()
|
self.audioPlayer.stop()
|
||||||
self._cancel_buffer()
|
self._cancel_buffer()
|
||||||
self.isPlaying = False
|
self.isPlaying = False
|
||||||
@@ -2636,14 +2702,23 @@ class BookReader:
|
|||||||
|
|
||||||
if readerEngine in ['speechd', 'screenreader']:
|
if readerEngine in ['speechd', 'screenreader']:
|
||||||
# Use callback-driven reading engines
|
# Use callback-driven reading engines
|
||||||
def on_speech_finished(finishType):
|
self.expectedSpeechCompletionToken += 1
|
||||||
|
paragraphToken = self.expectedSpeechCompletionToken
|
||||||
|
|
||||||
|
def on_speech_finished(finishType, completionToken=None):
|
||||||
"""
|
"""
|
||||||
Callback when a paragraph finishes speaking.
|
Callback when a paragraph finishes speaking.
|
||||||
Post pygame event instead of mutating state in callback thread.
|
Post pygame event instead of mutating state in callback thread.
|
||||||
"""
|
"""
|
||||||
if finishType == 'COMPLETED' and self.isPlaying:
|
if finishType == 'COMPLETED' and self.isPlaying:
|
||||||
# Post pygame event to handle in main loop
|
# Post pygame event to handle in main loop
|
||||||
pygame.event.post(pygame.event.Event(SPEECH_FINISHED_EVENT))
|
eventData = {'completionToken': paragraphToken}
|
||||||
|
if completionToken is not None:
|
||||||
|
try:
|
||||||
|
eventData['completionToken'] = int(completionToken)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
pygame.event.post(pygame.event.Event(SPEECH_FINISHED_EVENT, eventData))
|
||||||
|
|
||||||
self.readingEngine.speak_reading(paragraph, callback=on_speech_finished)
|
self.readingEngine.speak_reading(paragraph, callback=on_speech_finished)
|
||||||
else:
|
else:
|
||||||
|
|||||||
+53
-8
@@ -91,6 +91,20 @@ class BookmarkManager:
|
|||||||
bookPath = str(Path(bookPath).resolve())
|
bookPath = str(Path(bookPath).resolve())
|
||||||
return hashlib.sha256(bookPath.encode()).hexdigest()[:16]
|
return hashlib.sha256(bookPath.encode()).hexdigest()[:16]
|
||||||
|
|
||||||
|
def _get_unique_named_bookmark_name(self, cursor, bookId, baseName):
|
||||||
|
"""Return an unused bookmark name for a given book."""
|
||||||
|
candidateName = baseName
|
||||||
|
suffixIndex = 2
|
||||||
|
while True:
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT 1 FROM named_bookmarks
|
||||||
|
WHERE book_id = ? AND name = ?
|
||||||
|
''', (bookId, candidateName))
|
||||||
|
if cursor.fetchone() is None:
|
||||||
|
return candidateName
|
||||||
|
candidateName = f"{baseName} ({suffixIndex})"
|
||||||
|
suffixIndex += 1
|
||||||
|
|
||||||
def save_bookmark(self, bookPath, bookTitle, chapterIndex, paragraphIndex, sentenceIndex=0, audioPosition=0.0):
|
def save_bookmark(self, bookPath, bookTitle, chapterIndex, paragraphIndex, sentenceIndex=0, audioPosition=0.0):
|
||||||
"""
|
"""
|
||||||
Save bookmark for a book
|
Save bookmark for a book
|
||||||
@@ -322,13 +336,23 @@ class BookmarkManager:
|
|||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
if row:
|
if row:
|
||||||
bookmarkId = row[0]
|
bookmarkId = row[0]
|
||||||
cursor.execute('''
|
try:
|
||||||
UPDATE named_bookmarks
|
cursor.execute('''
|
||||||
SET name = ?, chapter_index = ?, paragraph_index = ?, audio_position = ?,
|
UPDATE named_bookmarks
|
||||||
"server_created_at" = ?, created_at = ?
|
SET name = ?, chapter_index = ?, paragraph_index = ?, audio_position = ?,
|
||||||
WHERE id = ?
|
"server_created_at" = ?, created_at = ?
|
||||||
''', (name, chapterIndex, paragraphIndex, audioPosition,
|
WHERE id = ?
|
||||||
serverCreatedAt, timestamp, bookmarkId))
|
''', (name, chapterIndex, paragraphIndex, audioPosition,
|
||||||
|
serverCreatedAt, timestamp, bookmarkId))
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
# Keep the existing local name if requested server name collides.
|
||||||
|
cursor.execute('''
|
||||||
|
UPDATE named_bookmarks
|
||||||
|
SET chapter_index = ?, paragraph_index = ?, audio_position = ?,
|
||||||
|
"server_created_at" = ?, created_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
''', (chapterIndex, paragraphIndex, audioPosition,
|
||||||
|
serverCreatedAt, timestamp, bookmarkId))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return bookmarkId
|
return bookmarkId
|
||||||
|
|
||||||
@@ -345,11 +369,32 @@ class BookmarkManager:
|
|||||||
return cursor.lastrowid
|
return cursor.lastrowid
|
||||||
|
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
SELECT id FROM named_bookmarks
|
SELECT id, server_library_item_id, server_time FROM named_bookmarks
|
||||||
WHERE book_id = ? AND name = ?
|
WHERE book_id = ? AND name = ?
|
||||||
''', (bookId, name))
|
''', (bookId, name))
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
bookmarkId = row[0] if row else None
|
bookmarkId = row[0] if row else None
|
||||||
|
existingServerItemId = row[1] if row else None
|
||||||
|
existingServerTime = row[2] if row else None
|
||||||
|
|
||||||
|
hasServerIdentity = serverLibraryItemId is not None and serverTime is not None
|
||||||
|
if bookmarkId and hasServerIdentity:
|
||||||
|
sameServerBookmark = (
|
||||||
|
existingServerItemId == serverLibraryItemId and
|
||||||
|
existingServerTime == serverTime
|
||||||
|
)
|
||||||
|
if not sameServerBookmark:
|
||||||
|
uniqueName = self._get_unique_named_bookmark_name(cursor, bookId, name)
|
||||||
|
cursor.execute('''
|
||||||
|
INSERT INTO named_bookmarks
|
||||||
|
(book_id, name, chapter_index, paragraph_index, audio_position, created_at,
|
||||||
|
server_library_item_id, server_time, server_created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
''', (bookId, uniqueName, chapterIndex, paragraphIndex, audioPosition, timestamp,
|
||||||
|
serverLibraryItemId, serverTime, serverCreatedAt))
|
||||||
|
conn.commit()
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
if bookmarkId:
|
if bookmarkId:
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
UPDATE named_bookmarks
|
UPDATE named_bookmarks
|
||||||
|
|||||||
+8
-2
@@ -29,7 +29,13 @@ class HelpMenu:
|
|||||||
Args:
|
Args:
|
||||||
helpLines: List of help lines to navigate
|
helpLines: List of help lines to navigate
|
||||||
"""
|
"""
|
||||||
self.helpLines = helpLines if isinstance(helpLines, list) else []
|
if isinstance(helpLines, str) or helpLines is None:
|
||||||
|
self.helpLines = []
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
self.helpLines = [str(line) for line in list(helpLines)]
|
||||||
|
except Exception:
|
||||||
|
self.helpLines = []
|
||||||
self.currentIndex = -1
|
self.currentIndex = -1
|
||||||
self.inMenu = True
|
self.inMenu = True
|
||||||
|
|
||||||
@@ -37,7 +43,7 @@ class HelpMenu:
|
|||||||
self.speechEngine.speak("Help opened. No help text available.")
|
self.speechEngine.speak("Help opened. No help text available.")
|
||||||
return
|
return
|
||||||
|
|
||||||
self.speechEngine.speak("Help opened. Press up or down arrows to navigate.")
|
self.speechEngine.speak("Help opened. Press down to hear the first line, then use up or down to navigate.")
|
||||||
|
|
||||||
def exit_menu(self):
|
def exit_menu(self):
|
||||||
"""Close help menu."""
|
"""Close help menu."""
|
||||||
|
|||||||
+33
-7
@@ -10,6 +10,7 @@ Uses Calibre's ebook-convert to convert MOBI to EPUB, then parses the EPUB.
|
|||||||
import tempfile
|
import tempfile
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from src.book import Book, Chapter
|
from src.book import Book, Chapter
|
||||||
@@ -24,6 +25,29 @@ class MobiParser:
|
|||||||
self.tempDir = None
|
self.tempDir = None
|
||||||
self.epubParser = None
|
self.epubParser = None
|
||||||
|
|
||||||
|
def _find_ebook_convert(self):
|
||||||
|
"""Locate Calibre's ebook-convert binary."""
|
||||||
|
overridePath = os.environ.get('BOOKSTORM_EBOOK_CONVERT') or os.environ.get('EBOOK_CONVERT')
|
||||||
|
if overridePath:
|
||||||
|
overrideCandidate = Path(overridePath).expanduser()
|
||||||
|
if overrideCandidate.exists() and os.access(overrideCandidate, os.X_OK):
|
||||||
|
return str(overrideCandidate)
|
||||||
|
|
||||||
|
pathCandidate = shutil.which('ebook-convert')
|
||||||
|
if pathCandidate:
|
||||||
|
return pathCandidate
|
||||||
|
|
||||||
|
commonCandidates = [
|
||||||
|
Path('/opt/calibre/ebook-convert'),
|
||||||
|
Path('/opt/calibre/bin/ebook-convert'),
|
||||||
|
Path('/opt/bin/ebook-convert'),
|
||||||
|
]
|
||||||
|
for binaryPath in commonCandidates:
|
||||||
|
if binaryPath.exists() and os.access(binaryPath, os.X_OK):
|
||||||
|
return str(binaryPath)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
def parse(self, mobiPath):
|
def parse(self, mobiPath):
|
||||||
"""
|
"""
|
||||||
Parse MOBI file
|
Parse MOBI file
|
||||||
@@ -47,8 +71,14 @@ class MobiParser:
|
|||||||
# Convert MOBI to EPUB using Calibre's ebook-convert
|
# Convert MOBI to EPUB using Calibre's ebook-convert
|
||||||
epubPath = tempPath / f"{mobiPath.stem}.epub"
|
epubPath = tempPath / f"{mobiPath.stem}.epub"
|
||||||
|
|
||||||
# Check if ebook-convert is available
|
convertBinary = self._find_ebook_convert()
|
||||||
convertCmd = ['ebook-convert', str(mobiPath), str(epubPath)]
|
if not convertBinary:
|
||||||
|
raise Exception(
|
||||||
|
"Calibre's ebook-convert tool is required for MOBI support, but it was not found in PATH or common /opt locations. "
|
||||||
|
"Set BOOKSTORM_EBOOK_CONVERT to the full path if needed."
|
||||||
|
)
|
||||||
|
|
||||||
|
convertCmd = [convertBinary, str(mobiPath), str(epubPath)]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Run ebook-convert with error capture
|
# Run ebook-convert with error capture
|
||||||
@@ -63,11 +93,7 @@ class MobiParser:
|
|||||||
raise Exception(f"ebook-convert failed: {result.stderr}")
|
raise Exception(f"ebook-convert failed: {result.stderr}")
|
||||||
|
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
raise Exception(
|
raise Exception("ebook-convert binary was found but could not be executed")
|
||||||
"Calibre's ebook-convert tool is required for MOBI support. "
|
|
||||||
"Install Calibre: sudo pacman -S calibre (Arch) or "
|
|
||||||
"sudo apt install calibre (Debian/Ubuntu)"
|
|
||||||
)
|
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
raise Exception("MOBI conversion timed out (>60 seconds)")
|
raise Exception("MOBI conversion timed out (>60 seconds)")
|
||||||
|
|
||||||
|
|||||||
+13
-6
@@ -55,7 +55,8 @@ class OptionsMenu:
|
|||||||
"""Return user-facing labels for available engine values."""
|
"""Return user-facing labels for available engine values."""
|
||||||
engineLabels = {
|
engineLabels = {
|
||||||
'piper': 'Piper-TTS',
|
'piper': 'Piper-TTS',
|
||||||
'speechd': 'Speech-Dispatcher'
|
'speechd': 'Speech-Dispatcher',
|
||||||
|
'screenreader': 'Screen Reader'
|
||||||
}
|
}
|
||||||
if self.screenReaderName:
|
if self.screenReaderName:
|
||||||
engineLabels['screenreader'] = self.screenReaderName
|
engineLabels['screenreader'] = self.screenReaderName
|
||||||
@@ -77,7 +78,7 @@ class OptionsMenu:
|
|||||||
"""
|
"""
|
||||||
readerEngine = self.config.get_reader_engine()
|
readerEngine = self.config.get_reader_engine()
|
||||||
engineLabels = self._get_engine_labels()
|
engineLabels = self._get_engine_labels()
|
||||||
readerEngineText = engineLabels.get(readerEngine, 'Piper-TTS')
|
readerEngineText = engineLabels.get(readerEngine, str(readerEngine))
|
||||||
|
|
||||||
menuItems = [
|
menuItems = [
|
||||||
{
|
{
|
||||||
@@ -180,17 +181,18 @@ class OptionsMenu:
|
|||||||
"""Cycle reader engine: piper-tts, speech-dispatcher, screen reader."""
|
"""Cycle reader engine: piper-tts, speech-dispatcher, screen reader."""
|
||||||
self._refresh_screen_reader_target()
|
self._refresh_screen_reader_target()
|
||||||
currentEngine = self.config.get_reader_engine()
|
currentEngine = self.config.get_reader_engine()
|
||||||
|
validEngines = {'piper', 'speechd', 'screenreader'}
|
||||||
|
oldEngine = currentEngine if currentEngine in validEngines else 'piper'
|
||||||
engineOrder = self._get_engine_cycle_order()
|
engineOrder = self._get_engine_cycle_order()
|
||||||
engineLabels = self._get_engine_labels()
|
engineLabels = self._get_engine_labels()
|
||||||
|
|
||||||
if currentEngine not in engineOrder:
|
if currentEngine not in engineOrder:
|
||||||
currentEngine = 'piper'
|
currentEngine = 'piper'
|
||||||
|
|
||||||
oldEngine = currentEngine
|
|
||||||
currentIndex = engineOrder.index(currentEngine)
|
currentIndex = engineOrder.index(currentEngine)
|
||||||
newEngine = engineOrder[(currentIndex + 1) % len(engineOrder)]
|
newEngine = engineOrder[(currentIndex + 1) % len(engineOrder)]
|
||||||
self.config.set_reader_engine(newEngine)
|
self.config.set_reader_engine(newEngine)
|
||||||
message = f"Reader engine: {engineLabels.get(newEngine, 'Piper-TTS')}."
|
message = f"Reader engine: {engineLabels.get(newEngine, newEngine)}."
|
||||||
|
|
||||||
# Reload TTS engine if callback available
|
# Reload TTS engine if callback available
|
||||||
needsRestart = False
|
needsRestart = False
|
||||||
@@ -231,6 +233,7 @@ class OptionsMenu:
|
|||||||
if readerEngine == 'speechd':
|
if readerEngine == 'speechd':
|
||||||
return self._select_speechd_voice()
|
return self._select_speechd_voice()
|
||||||
|
|
||||||
|
self._refresh_screen_reader_target()
|
||||||
readerName = self.screenReaderName if self.screenReaderName else "your active screen reader"
|
readerName = self.screenReaderName if self.screenReaderName else "your active screen reader"
|
||||||
self.speechEngine.speak(f"Voice selection is managed by {readerName}.")
|
self.speechEngine.speak(f"Voice selection is managed by {readerName}.")
|
||||||
return True
|
return True
|
||||||
@@ -466,7 +469,9 @@ class OptionsMenu:
|
|||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
else:
|
else:
|
||||||
# Cancel - revert engine change
|
# Cancel - revert engine change
|
||||||
self.config.set_reader_engine(self.previousEngine)
|
validEngines = {'piper', 'speechd', 'screenreader'}
|
||||||
|
revertEngine = self.previousEngine if self.previousEngine in validEngines else 'piper'
|
||||||
|
self.config.set_reader_engine(revertEngine)
|
||||||
self.inRestartMenu = False
|
self.inRestartMenu = False
|
||||||
self.speechEngine.speak("Cancelled. Engine change reverted.")
|
self.speechEngine.speak("Cancelled. Engine change reverted.")
|
||||||
# Speak current main menu item
|
# Speak current main menu item
|
||||||
@@ -477,7 +482,9 @@ class OptionsMenu:
|
|||||||
|
|
||||||
def exit_restart_menu(self):
|
def exit_restart_menu(self):
|
||||||
"""Exit restart confirmation menu (same as cancel)"""
|
"""Exit restart confirmation menu (same as cancel)"""
|
||||||
self.config.set_reader_engine(self.previousEngine)
|
validEngines = {'piper', 'speechd', 'screenreader'}
|
||||||
|
revertEngine = self.previousEngine if self.previousEngine in validEngines else 'piper'
|
||||||
|
self.config.set_reader_engine(revertEngine)
|
||||||
self.inRestartMenu = False
|
self.inRestartMenu = False
|
||||||
self.speechEngine.speak("Cancelled. Engine change reverted.")
|
self.speechEngine.speak("Cancelled. Engine change reverted.")
|
||||||
# Speak current main menu item
|
# Speak current main menu item
|
||||||
|
|||||||
+109
-43
@@ -21,6 +21,9 @@ except ImportError:
|
|||||||
from .text_validator import is_valid_text
|
from .text_validator import is_valid_text
|
||||||
|
|
||||||
|
|
||||||
|
DBUS_ENV_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
class ScreenReaderRemoteController:
|
class ScreenReaderRemoteController:
|
||||||
"""D-Bus helper for a single screen reader service."""
|
"""D-Bus helper for a single screen reader service."""
|
||||||
|
|
||||||
@@ -31,6 +34,8 @@ class ScreenReaderRemoteController:
|
|||||||
self.displayName = displayName
|
self.displayName = displayName
|
||||||
self.proxy = None
|
self.proxy = None
|
||||||
self.speechProxy = None
|
self.speechProxy = None
|
||||||
|
self.callLock = threading.Lock()
|
||||||
|
self.pendingCallThread = None
|
||||||
self.available = self._test_availability()
|
self.available = self._test_availability()
|
||||||
|
|
||||||
def _call_with_timeout(self, func, timeoutSeconds=2):
|
def _call_with_timeout(self, func, timeoutSeconds=2):
|
||||||
@@ -44,12 +49,24 @@ class ScreenReaderRemoteController:
|
|||||||
except Exception as error:
|
except Exception as error:
|
||||||
exception[0] = error
|
exception[0] = error
|
||||||
|
|
||||||
workerThread = threading.Thread(target=wrapper, daemon=True)
|
with self.callLock:
|
||||||
|
# Avoid unbounded daemon thread buildup if a previous call is stuck.
|
||||||
|
if self.pendingCallThread and self.pendingCallThread.is_alive():
|
||||||
|
return None
|
||||||
|
|
||||||
|
workerThread = threading.Thread(target=wrapper, daemon=True)
|
||||||
|
self.pendingCallThread = workerThread
|
||||||
|
|
||||||
workerThread.start()
|
workerThread.start()
|
||||||
workerThread.join(timeout=timeoutSeconds)
|
workerThread.join(timeout=timeoutSeconds)
|
||||||
|
|
||||||
if workerThread.is_alive():
|
if workerThread.is_alive():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
with self.callLock:
|
||||||
|
if self.pendingCallThread is workerThread:
|
||||||
|
self.pendingCallThread = None
|
||||||
|
|
||||||
if exception[0]:
|
if exception[0]:
|
||||||
raise exception[0]
|
raise exception[0]
|
||||||
return result[0]
|
return result[0]
|
||||||
@@ -103,22 +120,23 @@ class ScreenReaderRemoteController:
|
|||||||
if not busAddress:
|
if not busAddress:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
oldAddress = os.environ.get("DBUS_SESSION_BUS_ADDRESS")
|
with DBUS_ENV_LOCK:
|
||||||
try:
|
oldAddress = os.environ.get("DBUS_SESSION_BUS_ADDRESS")
|
||||||
os.environ["DBUS_SESSION_BUS_ADDRESS"] = busAddress
|
try:
|
||||||
bus = SessionMessageBus()
|
os.environ["DBUS_SESSION_BUS_ADDRESS"] = busAddress
|
||||||
self.proxy = bus.get_proxy(self.serviceName, self.mainPath)
|
bus = SessionMessageBus()
|
||||||
self.proxy.ListCommands()
|
self.proxy = bus.get_proxy(self.serviceName, self.mainPath)
|
||||||
self.speechProxy = bus.get_proxy(
|
self.proxy.ListCommands()
|
||||||
self.serviceName,
|
self.speechProxy = bus.get_proxy(
|
||||||
f"{self.mainPath}/SpeechAndVerbosityManager"
|
self.serviceName,
|
||||||
)
|
f"{self.mainPath}/SpeechAndVerbosityManager"
|
||||||
return True
|
)
|
||||||
finally:
|
return True
|
||||||
if oldAddress is not None:
|
finally:
|
||||||
os.environ["DBUS_SESSION_BUS_ADDRESS"] = oldAddress
|
if oldAddress is not None:
|
||||||
elif "DBUS_SESSION_BUS_ADDRESS" in os.environ:
|
os.environ["DBUS_SESSION_BUS_ADDRESS"] = oldAddress
|
||||||
del os.environ["DBUS_SESSION_BUS_ADDRESS"]
|
elif "DBUS_SESSION_BUS_ADDRESS" in os.environ:
|
||||||
|
del os.environ["DBUS_SESSION_BUS_ADDRESS"]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return bool(self._call_with_timeout(test_connection, timeoutSeconds=2))
|
return bool(self._call_with_timeout(test_connection, timeoutSeconds=2))
|
||||||
@@ -131,9 +149,7 @@ class ScreenReaderRemoteController:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = self._call_with_timeout(lambda: self.proxy.PresentMessage(str(message)), timeoutSeconds=2)
|
result = self.proxy.PresentMessage(str(message))
|
||||||
if result is None:
|
|
||||||
return False
|
|
||||||
return bool(result) if isinstance(result, bool) else True
|
return bool(result) if isinstance(result, bool) else True
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
@@ -145,24 +161,27 @@ class ScreenReaderRemoteController:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
if self.speechProxy:
|
if self.speechProxy:
|
||||||
result = self._call_with_timeout(
|
result = self.speechProxy.ExecuteCommand("InterruptSpeech", False)
|
||||||
lambda: self.speechProxy.ExecuteCommand("InterruptSpeech", False),
|
|
||||||
timeoutSeconds=2
|
|
||||||
)
|
|
||||||
if result is None:
|
|
||||||
return False
|
|
||||||
return bool(result) if isinstance(result, bool) else True
|
return bool(result) if isinstance(result, bool) else True
|
||||||
|
|
||||||
if self.proxy:
|
if self.proxy:
|
||||||
result = self._call_with_timeout(lambda: self.proxy.ExecuteCommand("InterruptSpeech", False), timeoutSeconds=2)
|
result = self.proxy.ExecuteCommand("InterruptSpeech", False)
|
||||||
if result is None:
|
|
||||||
return False
|
|
||||||
return bool(result) if isinstance(result, bool) else True
|
return bool(result) if isinstance(result, bool) else True
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def is_responsive(self):
|
||||||
|
"""Return True if the controller still responds to a benign command."""
|
||||||
|
if not self.available or not self.proxy:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
commands = self._call_with_timeout(lambda: self.proxy.ListCommands(), timeoutSeconds=1)
|
||||||
|
return commands is not None
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class ScreenReaderEngine:
|
class ScreenReaderEngine:
|
||||||
"""Book reading engine that speaks via active screen reader D-Bus APIs."""
|
"""Book reading engine that speaks via active screen reader D-Bus APIs."""
|
||||||
@@ -288,7 +307,7 @@ class ScreenReaderEngine:
|
|||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
"""Release resources and stop in-flight reading state."""
|
"""Release resources and stop in-flight reading state."""
|
||||||
self.cancel_reading()
|
self.cancel_reading(interrupt=False)
|
||||||
|
|
||||||
def cleanup(self):
|
def cleanup(self):
|
||||||
"""Cleanup resources - alias for close()."""
|
"""Cleanup resources - alias for close()."""
|
||||||
@@ -297,11 +316,12 @@ class ScreenReaderEngine:
|
|||||||
def _estimate_duration_seconds(self, text):
|
def _estimate_duration_seconds(self, text):
|
||||||
"""Estimate speech duration for callback timing."""
|
"""Estimate speech duration for callback timing."""
|
||||||
words = max(1, len(str(text).split()))
|
words = max(1, len(str(text).split()))
|
||||||
adjustedWpm = max(90.0, min(500.0, 180.0 + (float(self.speechRate) * 2.5)))
|
adjustedWpm = max(70.0, min(360.0, 150.0 + (float(self.speechRate) * 1.8)))
|
||||||
wordsDuration = (words / adjustedWpm) * 60.0
|
wordsDuration = (words / adjustedWpm) * 60.0
|
||||||
punctuationCount = str(text).count(".") + str(text).count("!") + str(text).count("?")
|
punctuationCount = str(text).count(".") + str(text).count("!") + str(text).count("?")
|
||||||
punctuationPause = punctuationCount * 0.12
|
punctuationPause = punctuationCount * 0.2
|
||||||
return max(0.8, wordsDuration + punctuationPause)
|
estimatedDuration = wordsDuration + punctuationPause + 0.25
|
||||||
|
return max(1.0, estimatedDuration * 1.2)
|
||||||
|
|
||||||
def _start_completion_timer(self, readingGeneration, text):
|
def _start_completion_timer(self, readingGeneration, text):
|
||||||
"""Start background completion timer and invoke callback on completion."""
|
"""Start background completion timer and invoke callback on completion."""
|
||||||
@@ -314,8 +334,9 @@ class ScreenReaderEngine:
|
|||||||
while elapsed < duration:
|
while elapsed < duration:
|
||||||
time.sleep(interval)
|
time.sleep(interval)
|
||||||
elapsed += interval
|
elapsed += interval
|
||||||
if readingGeneration != self.readingGeneration:
|
with self.speechLock:
|
||||||
return
|
if readingGeneration != self.readingGeneration:
|
||||||
|
return
|
||||||
|
|
||||||
callback = None
|
callback = None
|
||||||
with self.speechLock:
|
with self.speechLock:
|
||||||
@@ -326,7 +347,18 @@ class ScreenReaderEngine:
|
|||||||
callback = self.readingCallback
|
callback = self.readingCallback
|
||||||
|
|
||||||
if callback:
|
if callback:
|
||||||
callback('COMPLETED')
|
try:
|
||||||
|
callback('COMPLETED', readingGeneration)
|
||||||
|
except TypeError as error:
|
||||||
|
errorText = str(error)
|
||||||
|
signatureMismatch = (
|
||||||
|
"positional argument" in errorText or
|
||||||
|
("takes" in errorText and "given" in errorText)
|
||||||
|
)
|
||||||
|
if signatureMismatch:
|
||||||
|
callback('COMPLETED')
|
||||||
|
else:
|
||||||
|
print(f"Screen reader completion callback error: {error}")
|
||||||
|
|
||||||
workerThread = threading.Thread(target=completion_thread, daemon=True)
|
workerThread = threading.Thread(target=completion_thread, daemon=True)
|
||||||
workerThread.start()
|
workerThread.start()
|
||||||
@@ -335,16 +367,32 @@ class ScreenReaderEngine:
|
|||||||
"""Try to switch to another available controller."""
|
"""Try to switch to another available controller."""
|
||||||
if not self.availableControllers:
|
if not self.availableControllers:
|
||||||
return False
|
return False
|
||||||
if not self.activeController:
|
|
||||||
self.activeController = self.availableControllers[0]
|
|
||||||
return True
|
|
||||||
|
|
||||||
for controller in self.availableControllers:
|
previousController = self.activeController
|
||||||
if controller is self.activeController:
|
candidates = []
|
||||||
|
if not self.activeController:
|
||||||
|
candidates = list(self.availableControllers)
|
||||||
|
else:
|
||||||
|
candidates = [
|
||||||
|
controller for controller in self.availableControllers
|
||||||
|
if controller is not self.activeController
|
||||||
|
]
|
||||||
|
|
||||||
|
for controller in candidates:
|
||||||
|
if not self._is_process_running(controller.processName):
|
||||||
|
continue
|
||||||
|
if not controller.is_responsive():
|
||||||
continue
|
continue
|
||||||
self.activeController = controller
|
self.activeController = controller
|
||||||
|
self.isAvailable = True
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
# Re-discover controllers in case the active reader restarted.
|
||||||
|
self._initialize_controllers()
|
||||||
|
if self.activeController and self.activeController is not previousController:
|
||||||
|
return True
|
||||||
|
|
||||||
|
self.isAvailable = self.activeController is not None
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def speak(self, text, interrupt=True):
|
def speak(self, text, interrupt=True):
|
||||||
@@ -413,6 +461,15 @@ class ScreenReaderEngine:
|
|||||||
self.isReading = False
|
self.isReading = False
|
||||||
return
|
return
|
||||||
|
|
||||||
|
with self.speechLock:
|
||||||
|
generationStillCurrent = (
|
||||||
|
currentGeneration == self.readingGeneration and
|
||||||
|
self.isReading and
|
||||||
|
not self.isPausedReading
|
||||||
|
)
|
||||||
|
if not generationStillCurrent:
|
||||||
|
return
|
||||||
|
|
||||||
self._start_completion_timer(currentGeneration, textStr)
|
self._start_completion_timer(currentGeneration, textStr)
|
||||||
|
|
||||||
def pause_reading(self):
|
def pause_reading(self):
|
||||||
@@ -438,7 +495,7 @@ class ScreenReaderEngine:
|
|||||||
|
|
||||||
self.speak_reading(textToResume, callback=callback)
|
self.speak_reading(textToResume, callback=callback)
|
||||||
|
|
||||||
def cancel_reading(self):
|
def cancel_reading(self, interrupt=True):
|
||||||
"""Cancel current reading."""
|
"""Cancel current reading."""
|
||||||
with self.speechLock:
|
with self.speechLock:
|
||||||
self.readingGeneration += 1
|
self.readingGeneration += 1
|
||||||
@@ -446,7 +503,7 @@ class ScreenReaderEngine:
|
|||||||
self.isPausedReading = False
|
self.isPausedReading = False
|
||||||
self.readingCallback = None
|
self.readingCallback = None
|
||||||
|
|
||||||
if self.activeController:
|
if interrupt and self.activeController:
|
||||||
self.activeController.interrupt_speech()
|
self.activeController.interrupt_speech()
|
||||||
|
|
||||||
def is_reading_active(self):
|
def is_reading_active(self):
|
||||||
@@ -457,6 +514,15 @@ class ScreenReaderEngine:
|
|||||||
"""Check if reading is paused."""
|
"""Check if reading is paused."""
|
||||||
return self.isPausedReading
|
return self.isPausedReading
|
||||||
|
|
||||||
|
def is_completion_token_current(self, tokenValue):
|
||||||
|
"""Return True when completion token still matches active generation."""
|
||||||
|
try:
|
||||||
|
tokenInt = int(tokenValue)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
with self.speechLock:
|
||||||
|
return tokenInt == self.readingGeneration
|
||||||
|
|
||||||
def set_rate(self, rate):
|
def set_rate(self, rate):
|
||||||
"""Store virtual speech rate used for completion timing estimation."""
|
"""Store virtual speech rate used for completion timing estimation."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
Reference in New Issue
Block a user