479 lines
16 KiB
Python
479 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
Screen Reader Engine
|
|
|
|
Routes reading output through an active screen reader (Orca or Cthulhu)
|
|
using python-dasbus.
|
|
"""
|
|
|
|
import os
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
|
|
try:
|
|
from dasbus.connection import SessionMessageBus
|
|
HAS_DASBUS = True
|
|
except ImportError:
|
|
HAS_DASBUS = False
|
|
|
|
from .text_validator import is_valid_text
|
|
|
|
|
|
class ScreenReaderRemoteController:
|
|
"""D-Bus helper for a single screen reader service."""
|
|
|
|
def __init__(self, serviceName, mainPath, processName, displayName):
|
|
self.serviceName = serviceName
|
|
self.mainPath = mainPath
|
|
self.processName = processName
|
|
self.displayName = displayName
|
|
self.proxy = None
|
|
self.speechProxy = None
|
|
self.available = self._test_availability()
|
|
|
|
def _call_with_timeout(self, func, timeoutSeconds=2):
|
|
"""Run a call with timeout to avoid blocking the app."""
|
|
result = [None]
|
|
exception = [None]
|
|
|
|
def wrapper():
|
|
try:
|
|
result[0] = func()
|
|
except Exception as error:
|
|
exception[0] = error
|
|
|
|
workerThread = threading.Thread(target=wrapper, daemon=True)
|
|
workerThread.start()
|
|
workerThread.join(timeout=timeoutSeconds)
|
|
|
|
if workerThread.is_alive():
|
|
return None
|
|
if exception[0]:
|
|
raise exception[0]
|
|
return result[0]
|
|
|
|
def _get_process_bus_address(self):
|
|
"""Read DBUS_SESSION_BUS_ADDRESS from the target process environment."""
|
|
try:
|
|
result = subprocess.run(
|
|
["pgrep", "-x", self.processName],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
timeout=1
|
|
)
|
|
if result.returncode != 0:
|
|
return None
|
|
|
|
processIds = [pid.strip() for pid in result.stdout.splitlines() if pid.strip()]
|
|
for processId in processIds:
|
|
try:
|
|
with open(f"/proc/{processId}/environ", "rb") as envFile:
|
|
rawData = envFile.read()
|
|
decodedData = rawData.decode("utf-8", errors="ignore")
|
|
for environmentVariable in decodedData.split("\0"):
|
|
if environmentVariable.startswith("DBUS_SESSION_BUS_ADDRESS="):
|
|
return environmentVariable.split("=", 1)[1]
|
|
except Exception:
|
|
continue
|
|
except Exception:
|
|
pass
|
|
|
|
return None
|
|
|
|
def _test_availability(self):
|
|
"""Check if the remote D-Bus interface is reachable."""
|
|
if not HAS_DASBUS:
|
|
return False
|
|
|
|
def test_connection():
|
|
try:
|
|
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
|
|
except Exception:
|
|
busAddress = self._get_process_bus_address()
|
|
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"]
|
|
|
|
try:
|
|
return bool(self._call_with_timeout(test_connection, timeoutSeconds=2))
|
|
except Exception:
|
|
return False
|
|
|
|
def present_message(self, message):
|
|
"""Send a message to the screen reader for speech/braille presentation."""
|
|
if not self.available or not self.proxy:
|
|
return False
|
|
|
|
try:
|
|
result = self._call_with_timeout(lambda: self.proxy.PresentMessage(str(message)), timeoutSeconds=2)
|
|
if result is None:
|
|
return False
|
|
return bool(result) if isinstance(result, bool) else True
|
|
except Exception:
|
|
return False
|
|
|
|
def interrupt_speech(self):
|
|
"""Interrupt current speech output."""
|
|
if not self.available:
|
|
return False
|
|
|
|
try:
|
|
if self.speechProxy:
|
|
result = self._call_with_timeout(
|
|
lambda: self.speechProxy.ExecuteCommand("InterruptSpeech", False),
|
|
timeoutSeconds=2
|
|
)
|
|
if result is None:
|
|
return 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
|
|
return bool(result) if isinstance(result, bool) else True
|
|
except Exception:
|
|
pass
|
|
|
|
return False
|
|
|
|
|
|
class ScreenReaderEngine:
|
|
"""Book reading engine that speaks via active screen reader D-Bus APIs."""
|
|
|
|
def __init__(self):
|
|
self.speechLock = threading.Lock()
|
|
self.isAvailable = False
|
|
self.activeController = None
|
|
self.availableControllers = []
|
|
|
|
self.isReading = False
|
|
self.isPausedReading = False
|
|
self.readingCallback = None
|
|
self.currentText = ""
|
|
self.speechRate = 0
|
|
self.readingGeneration = 0
|
|
|
|
self._initialize_controllers()
|
|
|
|
def _initialize_controllers(self):
|
|
"""Discover available screen reader remote controllers."""
|
|
if not HAS_DASBUS:
|
|
print("Warning: python-dasbus not installed. Screen reader engine unavailable.")
|
|
return
|
|
|
|
controllerCandidates = [
|
|
ScreenReaderRemoteController(
|
|
serviceName="org.gnome.Orca.Service",
|
|
mainPath="/org/gnome/Orca/Service",
|
|
processName="orca",
|
|
displayName="Orca"
|
|
),
|
|
ScreenReaderRemoteController(
|
|
serviceName="org.stormux.Cthulhu.Service",
|
|
mainPath="/org/stormux/Cthulhu/Service",
|
|
processName="cthulhu",
|
|
displayName="Cthulhu"
|
|
)
|
|
]
|
|
|
|
self.availableControllers = [controller for controller in controllerCandidates if controller.available]
|
|
self.activeController = self._select_active_controller()
|
|
self.isAvailable = self.activeController is not None
|
|
|
|
if self.activeController:
|
|
print(f"Screen reader engine connected to {self.activeController.displayName}.")
|
|
else:
|
|
print("Warning: No supported screen reader D-Bus service detected (Orca/Cthulhu).")
|
|
|
|
def _is_process_running(self, processName):
|
|
"""Return True when the process name is running."""
|
|
try:
|
|
result = subprocess.run(
|
|
["pgrep", "-x", processName],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
timeout=1
|
|
)
|
|
return result.returncode == 0 and bool(result.stdout.strip())
|
|
except Exception:
|
|
return False
|
|
|
|
def _get_newest_running_pid(self, processName):
|
|
"""Return highest PID for a running process name, or -1."""
|
|
try:
|
|
result = subprocess.run(
|
|
["pgrep", "-x", processName],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
timeout=1
|
|
)
|
|
if result.returncode != 0:
|
|
return -1
|
|
pidValues = [int(pid.strip()) for pid in result.stdout.splitlines() if pid.strip().isdigit()]
|
|
return max(pidValues) if pidValues else -1
|
|
except Exception:
|
|
return -1
|
|
|
|
def _select_active_controller(self):
|
|
"""
|
|
Pick the best controller:
|
|
1) running service with newest PID
|
|
2) any available service
|
|
"""
|
|
if not self.availableControllers:
|
|
return None
|
|
|
|
runningControllers = [
|
|
controller for controller in self.availableControllers
|
|
if self._is_process_running(controller.processName)
|
|
]
|
|
|
|
if len(runningControllers) == 1:
|
|
return runningControllers[0]
|
|
|
|
if len(runningControllers) > 1:
|
|
newestController = None
|
|
newestPid = -1
|
|
for controller in runningControllers:
|
|
controllerPid = self._get_newest_running_pid(controller.processName)
|
|
if controllerPid > newestPid:
|
|
newestPid = controllerPid
|
|
newestController = controller
|
|
if newestController:
|
|
return newestController
|
|
|
|
return self.availableControllers[0]
|
|
|
|
def get_active_reader_name(self):
|
|
"""Return active screen reader display name."""
|
|
if self.activeController:
|
|
return self.activeController.displayName
|
|
return "Unavailable"
|
|
|
|
def is_available(self):
|
|
"""Check if screen reader engine is available."""
|
|
return self.isAvailable
|
|
|
|
def close(self):
|
|
"""Release resources and stop in-flight reading state."""
|
|
self.cancel_reading()
|
|
|
|
def cleanup(self):
|
|
"""Cleanup resources - alias for close()."""
|
|
self.close()
|
|
|
|
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)))
|
|
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)
|
|
|
|
def _start_completion_timer(self, readingGeneration, text):
|
|
"""Start background completion timer and invoke callback on completion."""
|
|
duration = self._estimate_duration_seconds(text)
|
|
|
|
def completion_thread():
|
|
elapsed = 0.0
|
|
interval = 0.1
|
|
|
|
while elapsed < duration:
|
|
time.sleep(interval)
|
|
elapsed += interval
|
|
if readingGeneration != self.readingGeneration:
|
|
return
|
|
|
|
callback = None
|
|
with self.speechLock:
|
|
if readingGeneration != self.readingGeneration:
|
|
return
|
|
self.isReading = False
|
|
self.isPausedReading = False
|
|
callback = self.readingCallback
|
|
|
|
if callback:
|
|
callback('COMPLETED')
|
|
|
|
workerThread = threading.Thread(target=completion_thread, daemon=True)
|
|
workerThread.start()
|
|
|
|
def _try_switch_controller(self):
|
|
"""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:
|
|
continue
|
|
self.activeController = controller
|
|
return True
|
|
|
|
return False
|
|
|
|
def speak(self, text, interrupt=True):
|
|
"""
|
|
Present text via active screen reader.
|
|
|
|
Args:
|
|
text: text to present
|
|
interrupt: interrupt current speech first
|
|
"""
|
|
if not self.isAvailable or not is_valid_text(text):
|
|
return False
|
|
|
|
with self.speechLock:
|
|
activeController = self.activeController
|
|
if not activeController:
|
|
return False
|
|
|
|
if interrupt:
|
|
activeController.interrupt_speech()
|
|
|
|
if activeController.present_message(str(text)):
|
|
return True
|
|
|
|
if self._try_switch_controller():
|
|
activeController = self.activeController
|
|
if activeController:
|
|
if interrupt:
|
|
activeController.interrupt_speech()
|
|
if activeController.present_message(str(text)):
|
|
return True
|
|
|
|
return False
|
|
|
|
def stop(self):
|
|
"""Stop current speech output."""
|
|
self.cancel_reading()
|
|
|
|
def speak_reading(self, text, callback=None):
|
|
"""
|
|
Present reading text with completion callback.
|
|
|
|
Args:
|
|
text: text to read
|
|
callback: called with 'COMPLETED' when reading is estimated done
|
|
"""
|
|
if not self.isAvailable or not is_valid_text(text):
|
|
return
|
|
|
|
textStr = str(text).replace('\n', ' ').replace('\r', ' ')
|
|
textStr = " ".join(textStr.split()).strip()
|
|
if not textStr:
|
|
return
|
|
|
|
with self.speechLock:
|
|
self.readingGeneration += 1
|
|
currentGeneration = self.readingGeneration
|
|
self.currentText = textStr
|
|
self.readingCallback = callback
|
|
self.isReading = True
|
|
self.isPausedReading = False
|
|
|
|
if not self.speak(textStr, interrupt=True):
|
|
with self.speechLock:
|
|
if currentGeneration == self.readingGeneration:
|
|
self.isReading = False
|
|
return
|
|
|
|
self._start_completion_timer(currentGeneration, textStr)
|
|
|
|
def pause_reading(self):
|
|
"""Pause reading by interrupting speech and marking paused state."""
|
|
with self.speechLock:
|
|
if not self.isReading:
|
|
return
|
|
self.readingGeneration += 1
|
|
self.isReading = False
|
|
self.isPausedReading = True
|
|
|
|
if self.activeController:
|
|
self.activeController.interrupt_speech()
|
|
|
|
def resume_reading(self):
|
|
"""Resume reading from paragraph start."""
|
|
with self.speechLock:
|
|
if not self.isPausedReading or not self.currentText:
|
|
return
|
|
callback = self.readingCallback
|
|
textToResume = self.currentText
|
|
self.isPausedReading = False
|
|
|
|
self.speak_reading(textToResume, callback=callback)
|
|
|
|
def cancel_reading(self):
|
|
"""Cancel current reading."""
|
|
with self.speechLock:
|
|
self.readingGeneration += 1
|
|
self.isReading = False
|
|
self.isPausedReading = False
|
|
self.readingCallback = None
|
|
|
|
if self.activeController:
|
|
self.activeController.interrupt_speech()
|
|
|
|
def is_reading_active(self):
|
|
"""Check if currently reading (not paused)."""
|
|
return self.isReading and not self.isPausedReading
|
|
|
|
def is_reading_paused(self):
|
|
"""Check if reading is paused."""
|
|
return self.isPausedReading
|
|
|
|
def set_rate(self, rate):
|
|
"""Store virtual speech rate used for completion timing estimation."""
|
|
try:
|
|
self.speechRate = max(-100, min(100, int(rate)))
|
|
except Exception:
|
|
self.speechRate = 0
|
|
|
|
def set_voice(self, voiceName):
|
|
"""Compatibility no-op for shared engine API."""
|
|
_ = voiceName
|
|
|
|
def list_voices(self):
|
|
"""Compatibility no-op for shared engine API."""
|
|
return []
|
|
|
|
def list_output_modules(self):
|
|
"""Compatibility no-op for shared engine API."""
|
|
return []
|
|
|
|
def set_output_module(self, moduleName):
|
|
"""Compatibility no-op for shared engine API."""
|
|
_ = moduleName
|