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
|
||||
self.isRunning = False
|
||||
self.isPlaying = False
|
||||
self.hasPendingSpeechCompletion = False
|
||||
self.pendingSpeechCompletionToken = None
|
||||
self.expectedSpeechCompletionToken = 0
|
||||
|
||||
# Sleep timer fade-out state
|
||||
self.isFadingOut = False
|
||||
@@ -902,23 +905,8 @@ class BookReader:
|
||||
self._handle_pygame_key(event)
|
||||
|
||||
elif event.type == SPEECH_FINISHED_EVENT:
|
||||
# Callback-driven 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.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()
|
||||
completionToken = getattr(event, 'completionToken', None)
|
||||
self._process_speech_completion(completionToken)
|
||||
|
||||
# Explicitly delete event objects to help GC
|
||||
del events
|
||||
@@ -956,12 +944,9 @@ class BookReader:
|
||||
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.helpMenu.is_in_menu() or
|
||||
self.recentBooksMenu.is_in_menu() or
|
||||
(self.absMenu and self.absMenu.is_in_menu()))
|
||||
inAnyMenu = self._is_in_any_menu()
|
||||
|
||||
self._flush_pending_speech_completion()
|
||||
|
||||
if self.isPlaying and not inAnyMenu and self.book:
|
||||
if isAudioBook:
|
||||
@@ -1015,9 +1000,6 @@ class BookReader:
|
||||
# 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
|
||||
# At 300: gen 0 only (fast)
|
||||
# At 600: full collection (all generations)
|
||||
@@ -1284,18 +1266,23 @@ class BookReader:
|
||||
self.speechEngine.speak("No book loaded")
|
||||
return
|
||||
|
||||
isAudioBook = hasattr(self.book, 'isAudioBook') and self.book.isAudioBook
|
||||
readerEngine = self.config.get_reader_engine()
|
||||
|
||||
# Pause playback while saving
|
||||
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()
|
||||
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()
|
||||
if not wasPaused and self.audioPlayer.is_audio_file_playing():
|
||||
self.audioPlayer.pause_audio_file()
|
||||
|
||||
# Re-enable auto-saving if it was disabled
|
||||
self.bookmarkCleared = False
|
||||
@@ -1305,10 +1292,12 @@ class BookReader:
|
||||
|
||||
# Resume playback
|
||||
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()
|
||||
else:
|
||||
self.audioPlayer.resume()
|
||||
self.audioPlayer.resume_audio_file()
|
||||
|
||||
elif event.key == pygame.K_PAGEUP:
|
||||
# Increase speech rate
|
||||
@@ -1672,6 +1661,73 @@ class BookReader:
|
||||
else:
|
||||
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):
|
||||
"""Return True when the key event should open or close help."""
|
||||
if event.key == pygame.K_F1:
|
||||
@@ -2166,6 +2222,11 @@ class BookReader:
|
||||
if self.book:
|
||||
self.save_bookmark(speakFeedback=False)
|
||||
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()
|
||||
if self.audioPlayer.is_audio_file_loaded():
|
||||
self.audioPlayer.stop_audio_file()
|
||||
@@ -2549,6 +2610,11 @@ class BookReader:
|
||||
self._close_abs_session()
|
||||
|
||||
# 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._cancel_buffer()
|
||||
self.isPlaying = False
|
||||
@@ -2636,14 +2702,23 @@ class BookReader:
|
||||
|
||||
if readerEngine in ['speechd', 'screenreader']:
|
||||
# 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.
|
||||
Post pygame event instead of mutating state in callback thread.
|
||||
"""
|
||||
if finishType == 'COMPLETED' and self.isPlaying:
|
||||
# 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)
|
||||
else:
|
||||
|
||||
+53
-8
@@ -91,6 +91,20 @@ class BookmarkManager:
|
||||
bookPath = str(Path(bookPath).resolve())
|
||||
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):
|
||||
"""
|
||||
Save bookmark for a book
|
||||
@@ -322,13 +336,23 @@ class BookmarkManager:
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
bookmarkId = row[0]
|
||||
cursor.execute('''
|
||||
UPDATE named_bookmarks
|
||||
SET name = ?, chapter_index = ?, paragraph_index = ?, audio_position = ?,
|
||||
"server_created_at" = ?, created_at = ?
|
||||
WHERE id = ?
|
||||
''', (name, chapterIndex, paragraphIndex, audioPosition,
|
||||
serverCreatedAt, timestamp, bookmarkId))
|
||||
try:
|
||||
cursor.execute('''
|
||||
UPDATE named_bookmarks
|
||||
SET name = ?, chapter_index = ?, paragraph_index = ?, audio_position = ?,
|
||||
"server_created_at" = ?, created_at = ?
|
||||
WHERE id = ?
|
||||
''', (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()
|
||||
return bookmarkId
|
||||
|
||||
@@ -345,11 +369,32 @@ class BookmarkManager:
|
||||
return cursor.lastrowid
|
||||
|
||||
cursor.execute('''
|
||||
SELECT id FROM named_bookmarks
|
||||
SELECT id, server_library_item_id, server_time FROM named_bookmarks
|
||||
WHERE book_id = ? AND name = ?
|
||||
''', (bookId, name))
|
||||
row = cursor.fetchone()
|
||||
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:
|
||||
cursor.execute('''
|
||||
UPDATE named_bookmarks
|
||||
|
||||
+8
-2
@@ -29,7 +29,13 @@ class HelpMenu:
|
||||
Args:
|
||||
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.inMenu = True
|
||||
|
||||
@@ -37,7 +43,7 @@ class HelpMenu:
|
||||
self.speechEngine.speak("Help opened. No help text available.")
|
||||
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):
|
||||
"""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 shutil
|
||||
import subprocess
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from src.book import Book, Chapter
|
||||
@@ -24,6 +25,29 @@ class MobiParser:
|
||||
self.tempDir = 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):
|
||||
"""
|
||||
Parse MOBI file
|
||||
@@ -47,8 +71,14 @@ class MobiParser:
|
||||
# Convert MOBI to EPUB using Calibre's ebook-convert
|
||||
epubPath = tempPath / f"{mobiPath.stem}.epub"
|
||||
|
||||
# Check if ebook-convert is available
|
||||
convertCmd = ['ebook-convert', str(mobiPath), str(epubPath)]
|
||||
convertBinary = self._find_ebook_convert()
|
||||
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:
|
||||
# Run ebook-convert with error capture
|
||||
@@ -63,11 +93,7 @@ class MobiParser:
|
||||
raise Exception(f"ebook-convert failed: {result.stderr}")
|
||||
|
||||
except FileNotFoundError:
|
||||
raise Exception(
|
||||
"Calibre's ebook-convert tool is required for MOBI support. "
|
||||
"Install Calibre: sudo pacman -S calibre (Arch) or "
|
||||
"sudo apt install calibre (Debian/Ubuntu)"
|
||||
)
|
||||
raise Exception("ebook-convert binary was found but could not be executed")
|
||||
except subprocess.TimeoutExpired:
|
||||
raise Exception("MOBI conversion timed out (>60 seconds)")
|
||||
|
||||
|
||||
+13
-6
@@ -55,7 +55,8 @@ class OptionsMenu:
|
||||
"""Return user-facing labels for available engine values."""
|
||||
engineLabels = {
|
||||
'piper': 'Piper-TTS',
|
||||
'speechd': 'Speech-Dispatcher'
|
||||
'speechd': 'Speech-Dispatcher',
|
||||
'screenreader': 'Screen Reader'
|
||||
}
|
||||
if self.screenReaderName:
|
||||
engineLabels['screenreader'] = self.screenReaderName
|
||||
@@ -77,7 +78,7 @@ class OptionsMenu:
|
||||
"""
|
||||
readerEngine = self.config.get_reader_engine()
|
||||
engineLabels = self._get_engine_labels()
|
||||
readerEngineText = engineLabels.get(readerEngine, 'Piper-TTS')
|
||||
readerEngineText = engineLabels.get(readerEngine, str(readerEngine))
|
||||
|
||||
menuItems = [
|
||||
{
|
||||
@@ -180,17 +181,18 @@ class OptionsMenu:
|
||||
"""Cycle reader engine: piper-tts, speech-dispatcher, screen reader."""
|
||||
self._refresh_screen_reader_target()
|
||||
currentEngine = self.config.get_reader_engine()
|
||||
validEngines = {'piper', 'speechd', 'screenreader'}
|
||||
oldEngine = currentEngine if currentEngine in validEngines else 'piper'
|
||||
engineOrder = self._get_engine_cycle_order()
|
||||
engineLabels = self._get_engine_labels()
|
||||
|
||||
if currentEngine not in engineOrder:
|
||||
currentEngine = 'piper'
|
||||
|
||||
oldEngine = currentEngine
|
||||
currentIndex = engineOrder.index(currentEngine)
|
||||
newEngine = engineOrder[(currentIndex + 1) % len(engineOrder)]
|
||||
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
|
||||
needsRestart = False
|
||||
@@ -231,6 +233,7 @@ class OptionsMenu:
|
||||
if readerEngine == 'speechd':
|
||||
return self._select_speechd_voice()
|
||||
|
||||
self._refresh_screen_reader_target()
|
||||
readerName = self.screenReaderName if self.screenReaderName else "your active screen reader"
|
||||
self.speechEngine.speak(f"Voice selection is managed by {readerName}.")
|
||||
return True
|
||||
@@ -466,7 +469,9 @@ class OptionsMenu:
|
||||
sys.exit(0)
|
||||
else:
|
||||
# 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.speechEngine.speak("Cancelled. Engine change reverted.")
|
||||
# Speak current main menu item
|
||||
@@ -477,7 +482,9 @@ class OptionsMenu:
|
||||
|
||||
def exit_restart_menu(self):
|
||||
"""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.speechEngine.speak("Cancelled. Engine change reverted.")
|
||||
# Speak current main menu item
|
||||
|
||||
+109
-43
@@ -21,6 +21,9 @@ except ImportError:
|
||||
from .text_validator import is_valid_text
|
||||
|
||||
|
||||
DBUS_ENV_LOCK = threading.Lock()
|
||||
|
||||
|
||||
class ScreenReaderRemoteController:
|
||||
"""D-Bus helper for a single screen reader service."""
|
||||
|
||||
@@ -31,6 +34,8 @@ class ScreenReaderRemoteController:
|
||||
self.displayName = displayName
|
||||
self.proxy = None
|
||||
self.speechProxy = None
|
||||
self.callLock = threading.Lock()
|
||||
self.pendingCallThread = None
|
||||
self.available = self._test_availability()
|
||||
|
||||
def _call_with_timeout(self, func, timeoutSeconds=2):
|
||||
@@ -44,12 +49,24 @@ class ScreenReaderRemoteController:
|
||||
except Exception as 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.join(timeout=timeoutSeconds)
|
||||
|
||||
if workerThread.is_alive():
|
||||
return None
|
||||
|
||||
with self.callLock:
|
||||
if self.pendingCallThread is workerThread:
|
||||
self.pendingCallThread = None
|
||||
|
||||
if exception[0]:
|
||||
raise exception[0]
|
||||
return result[0]
|
||||
@@ -103,22 +120,23 @@ class ScreenReaderRemoteController:
|
||||
if not busAddress:
|
||||
return False
|
||||
|
||||
oldAddress = os.environ.get("DBUS_SESSION_BUS_ADDRESS")
|
||||
try:
|
||||
os.environ["DBUS_SESSION_BUS_ADDRESS"] = busAddress
|
||||
bus = SessionMessageBus()
|
||||
self.proxy = bus.get_proxy(self.serviceName, self.mainPath)
|
||||
self.proxy.ListCommands()
|
||||
self.speechProxy = bus.get_proxy(
|
||||
self.serviceName,
|
||||
f"{self.mainPath}/SpeechAndVerbosityManager"
|
||||
)
|
||||
return True
|
||||
finally:
|
||||
if oldAddress is not None:
|
||||
os.environ["DBUS_SESSION_BUS_ADDRESS"] = oldAddress
|
||||
elif "DBUS_SESSION_BUS_ADDRESS" in os.environ:
|
||||
del os.environ["DBUS_SESSION_BUS_ADDRESS"]
|
||||
with DBUS_ENV_LOCK:
|
||||
oldAddress = os.environ.get("DBUS_SESSION_BUS_ADDRESS")
|
||||
try:
|
||||
os.environ["DBUS_SESSION_BUS_ADDRESS"] = busAddress
|
||||
bus = SessionMessageBus()
|
||||
self.proxy = bus.get_proxy(self.serviceName, self.mainPath)
|
||||
self.proxy.ListCommands()
|
||||
self.speechProxy = bus.get_proxy(
|
||||
self.serviceName,
|
||||
f"{self.mainPath}/SpeechAndVerbosityManager"
|
||||
)
|
||||
return True
|
||||
finally:
|
||||
if oldAddress is not None:
|
||||
os.environ["DBUS_SESSION_BUS_ADDRESS"] = oldAddress
|
||||
elif "DBUS_SESSION_BUS_ADDRESS" in os.environ:
|
||||
del os.environ["DBUS_SESSION_BUS_ADDRESS"]
|
||||
|
||||
try:
|
||||
return bool(self._call_with_timeout(test_connection, timeoutSeconds=2))
|
||||
@@ -131,9 +149,7 @@ class ScreenReaderRemoteController:
|
||||
return False
|
||||
|
||||
try:
|
||||
result = self._call_with_timeout(lambda: self.proxy.PresentMessage(str(message)), timeoutSeconds=2)
|
||||
if result is None:
|
||||
return False
|
||||
result = self.proxy.PresentMessage(str(message))
|
||||
return bool(result) if isinstance(result, bool) else True
|
||||
except Exception:
|
||||
return False
|
||||
@@ -145,24 +161,27 @@ class ScreenReaderRemoteController:
|
||||
|
||||
try:
|
||||
if self.speechProxy:
|
||||
result = self._call_with_timeout(
|
||||
lambda: self.speechProxy.ExecuteCommand("InterruptSpeech", False),
|
||||
timeoutSeconds=2
|
||||
)
|
||||
if result is None:
|
||||
return False
|
||||
result = self.speechProxy.ExecuteCommand("InterruptSpeech", False)
|
||||
return bool(result) if isinstance(result, bool) else True
|
||||
|
||||
if self.proxy:
|
||||
result = self._call_with_timeout(lambda: self.proxy.ExecuteCommand("InterruptSpeech", False), timeoutSeconds=2)
|
||||
if result is None:
|
||||
return False
|
||||
result = self.proxy.ExecuteCommand("InterruptSpeech", False)
|
||||
return bool(result) if isinstance(result, bool) else True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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:
|
||||
"""Book reading engine that speaks via active screen reader D-Bus APIs."""
|
||||
@@ -288,7 +307,7 @@ class ScreenReaderEngine:
|
||||
|
||||
def close(self):
|
||||
"""Release resources and stop in-flight reading state."""
|
||||
self.cancel_reading()
|
||||
self.cancel_reading(interrupt=False)
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup resources - alias for close()."""
|
||||
@@ -297,11 +316,12 @@ class ScreenReaderEngine:
|
||||
def _estimate_duration_seconds(self, text):
|
||||
"""Estimate speech duration for callback timing."""
|
||||
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
|
||||
punctuationCount = str(text).count(".") + str(text).count("!") + str(text).count("?")
|
||||
punctuationPause = punctuationCount * 0.12
|
||||
return max(0.8, wordsDuration + punctuationPause)
|
||||
punctuationPause = punctuationCount * 0.2
|
||||
estimatedDuration = wordsDuration + punctuationPause + 0.25
|
||||
return max(1.0, estimatedDuration * 1.2)
|
||||
|
||||
def _start_completion_timer(self, readingGeneration, text):
|
||||
"""Start background completion timer and invoke callback on completion."""
|
||||
@@ -314,8 +334,9 @@ class ScreenReaderEngine:
|
||||
while elapsed < duration:
|
||||
time.sleep(interval)
|
||||
elapsed += interval
|
||||
if readingGeneration != self.readingGeneration:
|
||||
return
|
||||
with self.speechLock:
|
||||
if readingGeneration != self.readingGeneration:
|
||||
return
|
||||
|
||||
callback = None
|
||||
with self.speechLock:
|
||||
@@ -326,7 +347,18 @@ class ScreenReaderEngine:
|
||||
callback = self.readingCallback
|
||||
|
||||
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.start()
|
||||
@@ -335,16 +367,32 @@ class ScreenReaderEngine:
|
||||
"""Try to switch to another available controller."""
|
||||
if not self.availableControllers:
|
||||
return False
|
||||
if not self.activeController:
|
||||
self.activeController = self.availableControllers[0]
|
||||
return True
|
||||
|
||||
for controller in self.availableControllers:
|
||||
if controller is self.activeController:
|
||||
previousController = 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
|
||||
self.activeController = controller
|
||||
self.isAvailable = 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
|
||||
|
||||
def speak(self, text, interrupt=True):
|
||||
@@ -413,6 +461,15 @@ class ScreenReaderEngine:
|
||||
self.isReading = False
|
||||
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)
|
||||
|
||||
def pause_reading(self):
|
||||
@@ -438,7 +495,7 @@ class ScreenReaderEngine:
|
||||
|
||||
self.speak_reading(textToResume, callback=callback)
|
||||
|
||||
def cancel_reading(self):
|
||||
def cancel_reading(self, interrupt=True):
|
||||
"""Cancel current reading."""
|
||||
with self.speechLock:
|
||||
self.readingGeneration += 1
|
||||
@@ -446,7 +503,7 @@ class ScreenReaderEngine:
|
||||
self.isPausedReading = False
|
||||
self.readingCallback = None
|
||||
|
||||
if self.activeController:
|
||||
if interrupt and self.activeController:
|
||||
self.activeController.interrupt_speech()
|
||||
|
||||
def is_reading_active(self):
|
||||
@@ -457,6 +514,15 @@ class ScreenReaderEngine:
|
||||
"""Check if reading is paused."""
|
||||
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):
|
||||
"""Store virtual speech rate used for completion timing estimation."""
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user