Files
bookstorm/src/braille_output.py
2025-10-19 18:02:34 -04:00

353 lines
12 KiB
Python

"""
Braille display output using brltty and liblouis.
This module provides Braille display support for BookStorm, allowing users
to read book content on refreshable Braille displays. It uses:
- brltty (via BrlAPI): Hardware communication layer
- liblouis: Text-to-Braille translation
Supports Grade 1, Grade 2, and UEB Braille tables.
"""
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class BrailleOutput:
"""Manages Braille display output via brltty and liblouis."""
# Translation table mappings
TABLES = {
"grade1": "en-us-g1.ctb",
"grade2": "en-us-g2.ctb",
"ueb": "en-ueb-g2.ctb"
}
def __init__(self, enabled=False, translationTable="grade2", syncWithTts=True, showStatus=True, muteVoice=False):
"""
Initialize Braille output.
Args:
enabled: Whether Braille output is enabled
translationTable: Translation table name (grade1/grade2/ueb)
syncWithTts: Whether to sync Braille with TTS playback
showStatus: Whether to show status messages on Braille display
muteVoice: Whether to mute TTS when Braille is active (Braille-only mode)
"""
self.enabled = enabled
self.translationTable = translationTable
self.syncWithTts = syncWithTts
self.showStatus = showStatus
self.muteVoice = muteVoice
self.brl = None
self.displaySize = (0, 0)
self.currentText = ""
self.panOffset = 0
# Import libraries only if enabled
if self.enabled:
self._initialize_braille()
def _initialize_braille(self):
"""Initialize brltty connection and liblouis."""
try:
import brlapi
import louis
self.brlapi = brlapi
self.louis = louis
print("DEBUG: Attempting to connect to brltty...")
logger.info("Attempting to connect to brltty daemon")
# Connect to brltty daemon
self.brl = brlapi.Connection()
self.displaySize = self.brl.displaySize
print(f"DEBUG: Braille display connected! Size: {self.displaySize[0]} x {self.displaySize[1]} cells")
print(f"DEBUG: Driver: {self.brl.driverName}")
logger.info(f"Braille display connected: {self.displaySize[0]} x {self.displaySize[1]} cells")
# Enter TTY mode - this takes exclusive control from screen reader
print("DEBUG: Entering TTY mode (taking control from screen reader)...")
self.brl.enterTtyMode()
print("DEBUG: TTY mode entered successfully")
logger.info("Entered TTY mode - have exclusive control of Braille display")
# Verify translation table exists
tablePath = self.TABLES.get(self.translationTable, self.TABLES["grade2"])
try:
# Test translation
testResult = self.louis.translateString([tablePath], "test")
print(f"DEBUG: Braille translation working. Table: {tablePath}")
logger.info(f"Using Braille table: {tablePath}")
except Exception as e:
print(f"DEBUG: Translation table error: {e}, falling back to grade2")
logger.warning(f"Translation table error: {e}, falling back to grade2")
self.translationTable = "grade2"
except ImportError as e:
print(f"DEBUG: ImportError - BrlAPI or liblouis not installed: {e}")
logger.error("BrlAPI or liblouis not installed. Install python3-brlapi and python3-louis")
self.enabled = False
self.brl = None
except Exception as e:
print(f"DEBUG: Could not connect to brltty: {e}")
print("DEBUG: Is brltty daemon running? Check with: systemctl status brltty")
logger.warning(f"Could not connect to brltty: {e}")
self.enabled = False
self.brl = None
def show_text(self, text, cursorPos=0):
"""
Display text on Braille display.
Args:
text: Text to display (will be translated to Braille)
cursorPos: Cursor position (0-based)
"""
if not self.enabled or not self.brl:
return
try:
# Store current text for panning
self.currentText = text
self.panOffset = 0
# Translate to Braille
tablePath = self.TABLES.get(self.translationTable, self.TABLES["grade2"])
brailleText = self.louis.translateString([tablePath], text)
# Handle text longer than display width
displayWidth = self.displaySize[0]
if len(brailleText) > displayWidth:
# Show first portion
brailleText = brailleText[:displayWidth]
# Send to display
self.brl.writeText(brailleText, cursorPos)
except Exception as e:
logger.error(f"Error displaying Braille text: {e}")
def show_paragraph(self, paragraph, offset=0):
"""
Display a paragraph with panning support.
Args:
paragraph: Full paragraph text
offset: Character offset for panning
"""
if not self.enabled:
print("DEBUG: Braille output disabled, skipping")
return
if not self.brl:
print("DEBUG: No Braille connection, skipping")
return
try:
# Store for manual panning
self.currentText = paragraph
self.panOffset = offset
print(f"DEBUG: Translating paragraph (length {len(paragraph)}) to Braille...")
# Translate entire paragraph
tablePath = self.TABLES.get(self.translationTable, self.TABLES["grade2"])
brailleText = self.louis.translateString([tablePath], paragraph)
print(f"DEBUG: Braille text length: {len(brailleText)}, Display width: {self.displaySize[0]}")
# Extract window based on offset
displayWidth = self.displaySize[0]
window = brailleText[offset:offset + displayWidth]
# Pad if necessary
if len(window) < displayWidth:
window = window + " " * (displayWidth - len(window))
print(f"DEBUG: Sending {len(window)} cells to Braille display...")
self.brl.writeText(window)
print("DEBUG: Braille text sent successfully")
except Exception as e:
print(f"DEBUG: Error displaying paragraph: {e}")
logger.error(f"Error displaying paragraph: {e}")
def show_status(self, message):
"""
Display a status message on Braille display.
Args:
message: Status message to display
"""
if not self.enabled or not self.brl or not self.showStatus:
return
try:
# Translate and display
tablePath = self.TABLES.get(self.translationTable, self.TABLES["grade2"])
brailleText = self.louis.translateString([tablePath], message)
# Truncate if too long
displayWidth = self.displaySize[0]
if len(brailleText) > displayWidth:
brailleText = brailleText[:displayWidth]
self.brl.writeText(brailleText)
except Exception as e:
logger.error(f"Error displaying status: {e}")
def show_menu_item(self, itemText, position=None, total=None):
"""
Display a menu item on Braille display.
Args:
itemText: Menu item text
position: Current position (1-based), optional
total: Total items, optional
"""
if not self.enabled or not self.brl:
return
try:
# Format with position if provided
if position is not None and total is not None:
displayText = f"{position}/{total}: {itemText}"
else:
displayText = itemText
self.show_text(displayText)
except Exception as e:
logger.error(f"Error displaying menu item: {e}")
def pan_forward(self):
"""Pan Braille display forward (right)."""
if not self.enabled or not self.brl or not self.currentText:
return
# Translate full text
tablePath = self.TABLES.get(self.translationTable, self.TABLES["grade2"])
brailleText = self.louis.translateString([tablePath], self.currentText)
displayWidth = self.displaySize[0]
maxOffset = max(0, len(brailleText) - displayWidth)
# Advance by display width
self.panOffset = min(self.panOffset + displayWidth, maxOffset)
# Show new window
self.show_paragraph(self.currentText, self.panOffset)
def pan_backward(self):
"""Pan Braille display backward (left)."""
if not self.enabled or not self.brl or not self.currentText:
return
displayWidth = self.displaySize[0]
# Go back by display width
self.panOffset = max(0, self.panOffset - displayWidth)
# Show new window
self.show_paragraph(self.currentText, self.panOffset)
def read_key(self, timeout=0):
"""
Read a key press from the Braille display.
Args:
timeout: Timeout in milliseconds (0 = non-blocking)
Returns:
Key code or None if no key pressed
"""
if not self.enabled or not self.brl:
return None
try:
# Read key with timeout (non-blocking if timeout=0)
keyCode = self.brl.readKey(wait=False)
return keyCode
except Exception as e:
# No key pressed or error
return None
def clear(self):
"""Clear the Braille display."""
if not self.enabled or not self.brl:
return
try:
self.brl.writeText("")
except Exception as e:
logger.error(f"Error clearing display: {e}")
def set_table(self, tableName):
"""
Change the Braille translation table.
Args:
tableName: Table name (grade1/grade2/ueb)
"""
if tableName in self.TABLES:
self.translationTable = tableName
logger.info(f"Switched to Braille table: {tableName}")
else:
logger.warning(f"Unknown table: {tableName}, keeping {self.translationTable}")
def set_sync_mode(self, syncWithTts):
"""
Set sync mode.
Args:
syncWithTts: True to sync with TTS, False for manual only
"""
self.syncWithTts = syncWithTts
logger.info(f"Braille sync mode: {'TTS sync' if syncWithTts else 'manual'}")
def set_show_status(self, showStatus):
"""
Set whether to show status messages.
Args:
showStatus: True to show status messages
"""
self.showStatus = showStatus
logger.info(f"Braille status messages: {'enabled' if showStatus else 'disabled'}")
def set_mute_voice(self, muteVoice):
"""
Set whether to mute TTS when Braille is active.
Args:
muteVoice: True to mute TTS (Braille-only mode)
"""
self.muteVoice = muteVoice
logger.info(f"Braille-only mode: {'enabled' if muteVoice else 'disabled (dual output)'}")
def close(self):
"""Close brltty connection and release display."""
if self.brl:
try:
print("DEBUG: Leaving TTY mode and closing Braille connection...")
# Leave TTY mode first to release control back to screen reader
self.brl.leaveTtyMode()
self.brl.closeConnection()
print("DEBUG: Braille display connection closed")
logger.info("Braille display connection closed")
except Exception as e:
print(f"DEBUG: Error closing Braille connection: {e}")
logger.error(f"Error closing Braille connection: {e}")
finally:
self.brl = None
self.enabled = False
def __del__(self):
"""Ensure connection is closed on cleanup."""
self.close()