Code cleanup and stability sprint. Also, more robust search for calibre.

This commit is contained in:
Storm Dragon
2026-02-27 13:50:58 -05:00
parent 2cfb01549b
commit b5f1ec4bed
6 changed files with 324 additions and 99 deletions
+108 -33
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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: