diff --git a/bookstorm.py b/bookstorm.py index da1adbc..d879587 100755 --- a/bookstorm.py +++ b/bookstorm.py @@ -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: diff --git a/src/bookmark_manager.py b/src/bookmark_manager.py index 8ca6599..0ffe899 100644 --- a/src/bookmark_manager.py +++ b/src/bookmark_manager.py @@ -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 diff --git a/src/help_menu.py b/src/help_menu.py index 0d278d8..c6196d5 100644 --- a/src/help_menu.py +++ b/src/help_menu.py @@ -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.""" diff --git a/src/mobi_parser.py b/src/mobi_parser.py index 5a2fa3f..e635c93 100644 --- a/src/mobi_parser.py +++ b/src/mobi_parser.py @@ -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)") diff --git a/src/options_menu.py b/src/options_menu.py index 2ece649..97a35c6 100644 --- a/src/options_menu.py +++ b/src/options_menu.py @@ -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 diff --git a/src/screen_reader_engine.py b/src/screen_reader_engine.py index 875f1f1..443b8db 100644 --- a/src/screen_reader_engine.py +++ b/src/screen_reader_engine.py @@ -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: