Experimental Braille support added.
This commit is contained in:
352
src/braille_output.py
Normal file
352
src/braille_output.py
Normal file
@@ -0,0 +1,352 @@
|
||||
"""
|
||||
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()
|
||||
Reference in New Issue
Block a user