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
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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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: