Help is now navigable using the arrow keys instead of being one spoken continuous text. Keybinding moved from h to ? or f1. Braille is now handled through Cthulhu or Orca depending on the detected screen reader. This hands off Braille to an already implemented robust system and is less work for me.
This commit is contained in:
@@ -5,7 +5,7 @@ BookStorm is a fully accessible book reader for electronic books and audiobooks,
|
||||
## Features
|
||||
|
||||
- **Multiple Book Formats**: DAISY 2.02, DAISY 3, EPUB, PDF, TXT, and audio formats (M4B, M4A, MP3)
|
||||
- **Text-to-Speech**: High-quality narration using piper-tts or system voices via speech-dispatcher
|
||||
- **Text-to-Speech**: High-quality narration using piper-tts, system voices via speech-dispatcher, or active screen reader output
|
||||
- **Audio Book Playback**: Native playback with chapter navigation and bookmarks
|
||||
- **Audiobookshelf Integration**: Browse, stream, and download audiobooks from your Audiobookshelf server
|
||||
- **Full Keyboard Control**: No mouse required - navigate with simple keyboard shortcuts
|
||||
@@ -15,7 +15,7 @@ BookStorm is a fully accessible book reader for electronic books and audiobooks,
|
||||
- **Library Browser**: Browse your book collection with configurable library directory
|
||||
- **Recent Books**: Quick access to your 10 most recently read books
|
||||
- **WAV Export**: Convert text books to audio files split by chapter
|
||||
- **Screen Reader Friendly**: All feedback provided via speech-dispatcher for full accessibility
|
||||
- **Screen Reader Friendly**: Full keyboard control with spoken feedback and optional screen-reader reading mode
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -47,6 +47,8 @@ sudo apt install --no-install-recommends ffmpeg mpv python3 python3-pip python3-
|
||||
└── en_US-hfc_female-medium.onnx.json
|
||||
```
|
||||
|
||||
If you want screen-reader reading mode, install `dasbus` (`python-dasbus` system package on Arch, or `pip install dasbus`).
|
||||
|
||||
## Usage
|
||||
|
||||
### Interactive Reading
|
||||
@@ -94,7 +96,7 @@ python bookstorm.py mybook.epub --wav --output-dir ./audiobooks
|
||||
|
||||
- **o** - Options menu (TTS engine, voice selection, display settings)
|
||||
- **t** - Time remaining (if sleep timer active)
|
||||
- **h** - Help (displays keyboard shortcuts)
|
||||
- **F1** or **?** - Help (displays keyboard shortcuts)
|
||||
- **q or ESC** - Quit / Sleep timer menu
|
||||
|
||||
### Book Browser Controls
|
||||
@@ -145,7 +147,7 @@ Settings are stored in `~/.config/stormux/bookstorm/settings.ini` and can be mod
|
||||
[TTS]
|
||||
voice_model = /usr/share/piper-voices/en/en_US/hfc_male/medium/en_US-hfc_male-medium.onnx
|
||||
voice_dir = /usr/share/piper-voices/en/en_US
|
||||
reader_engine = piper # or 'speechd'
|
||||
reader_engine = piper # 'piper', 'speechd', or 'screenreader'
|
||||
speechd_voice = # speech-dispatcher voice name
|
||||
speechd_output_module = # e.g., espeak-ng
|
||||
speech_rate = 0 # -100 to 100
|
||||
@@ -254,16 +256,17 @@ Install piper-tts and download voice models to `/usr/share/piper-voices/en/en_US
|
||||
|
||||
```bash
|
||||
# Install missing Python packages
|
||||
pip install pygame beautifulsoup4 lxml python-speechd mutagen
|
||||
pip install pygame beautifulsoup4 lxml python-speechd mutagen dasbus
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
BookStorm uses a dual TTS system for text books:
|
||||
BookStorm uses multiple reading engines for text books:
|
||||
|
||||
- **UI Feedback**: speech-dispatcher (instant feedback for all actions)
|
||||
- **Book Reading (Piper-TTS mode)**: piper-tts → WAV → pygame.mixer (high quality)
|
||||
- **Book Reading (Speech-Dispatcher mode)**: Separate speech-dispatcher session (faster, system voices)
|
||||
- **Book Reading (Screen Reader mode)**: Orca/Cthulhu `PresentMessage` via D-Bus (speech + braille from your active screen reader)
|
||||
|
||||
|
||||
For issues and feature requests, please visit https://git.stormux.org/storm/bookstorm
|
||||
|
||||
+223
-173
@@ -45,8 +45,10 @@ from src.voice_selector import VoiceSelector
|
||||
from src.book_selector import BookSelector
|
||||
from src.mpv_player import MpvPlayer
|
||||
from src.speech_engine import SpeechEngine
|
||||
from src.screen_reader_engine import ScreenReaderEngine
|
||||
from src.options_menu import OptionsMenu
|
||||
from src.sleep_timer_menu import SleepTimerMenu
|
||||
from src.help_menu import HelpMenu
|
||||
from src.recent_books_menu import RecentBooksMenu
|
||||
from src.audiobookshelf_client import AudiobookshelfClient
|
||||
from src.audiobookshelf_menu import AudiobookshelfMenu
|
||||
@@ -54,8 +56,6 @@ from src.server_link_manager import ServerLinkManager
|
||||
from src.audiobookshelf_sync import AudiobookshelfSync
|
||||
from src.bookmarks_menu import BookmarksMenu
|
||||
from src.wav_exporter import WavExporter
|
||||
from src.braille_output import BrailleOutput
|
||||
from src.braille_menu import BrailleMenu
|
||||
from src.ui import get_input
|
||||
|
||||
|
||||
@@ -90,20 +90,6 @@ class BookReader:
|
||||
speechRate = self.config.get_speech_rate()
|
||||
self.speechEngine.set_rate(speechRate)
|
||||
|
||||
# Initialize Braille output (before options menu)
|
||||
brailleEnabled = self.config.get_braille_enabled()
|
||||
brailleTable = self.config.get_braille_translation_table()
|
||||
brailleSyncTts = self.config.get_braille_sync_with_tts()
|
||||
brailleShowStatus = self.config.get_braille_show_status()
|
||||
brailleMuteVoice = self.config.get_braille_mute_voice()
|
||||
self.brailleOutput = BrailleOutput(
|
||||
enabled=brailleEnabled,
|
||||
translationTable=brailleTable,
|
||||
syncWithTts=brailleSyncTts,
|
||||
showStatus=brailleShowStatus,
|
||||
muteVoice=brailleMuteVoice
|
||||
)
|
||||
|
||||
# Initialize options menu
|
||||
voiceSelector = VoiceSelector(self.config.get_voice_dir())
|
||||
# Create callback reference for TTS engine reloading
|
||||
@@ -113,8 +99,7 @@ class BookReader:
|
||||
self.speechEngine,
|
||||
voiceSelector,
|
||||
self.audioPlayer,
|
||||
ttsReloadCallback=reloadCallback,
|
||||
brailleOutput=self.brailleOutput
|
||||
ttsReloadCallback=reloadCallback
|
||||
)
|
||||
|
||||
# Initialize book selector
|
||||
@@ -130,6 +115,9 @@ class BookReader:
|
||||
# Initialize sleep timer menu
|
||||
self.sleepTimerMenu = SleepTimerMenu(self.speechEngine)
|
||||
|
||||
# Initialize help menu
|
||||
self.helpMenu = HelpMenu(self.speechEngine)
|
||||
|
||||
# Initialize recent books menu
|
||||
self.recentBooksMenu = RecentBooksMenu(self.bookmarkManager, self.speechEngine)
|
||||
|
||||
@@ -165,6 +153,30 @@ class BookReader:
|
||||
|
||||
# Apply speech rate to reading engine
|
||||
self.readingEngine.set_rate(speechRate)
|
||||
elif readerEngine == 'screenreader':
|
||||
# Use active desktop screen reader via D-Bus
|
||||
self.ttsEngine = None
|
||||
self.readingEngine = ScreenReaderEngine()
|
||||
self.readingEngine.set_rate(speechRate)
|
||||
|
||||
if not self.readingEngine.is_available():
|
||||
message = "Warning: No supported screen reader detected. Falling back to speech-dispatcher."
|
||||
print(message)
|
||||
self.speechEngine.speak(message)
|
||||
|
||||
# Switch to speech-dispatcher mode
|
||||
self.readingEngine = SpeechEngine()
|
||||
|
||||
savedModule = self.config.get_speechd_output_module()
|
||||
if savedModule:
|
||||
self.readingEngine.set_output_module(savedModule)
|
||||
|
||||
savedVoice = self.config.get_speechd_voice()
|
||||
if savedVoice:
|
||||
self.readingEngine.set_voice(savedVoice)
|
||||
|
||||
self.readingEngine.set_rate(speechRate)
|
||||
self.config.set_reader_engine('speechd')
|
||||
else:
|
||||
# Use piper-tts (check if available first)
|
||||
if not TtsEngine.is_available():
|
||||
@@ -324,20 +336,14 @@ class BookReader:
|
||||
print(f"[Paragraph {self.currentParagraph + 1}/{chapter.get_total_paragraphs()}]")
|
||||
print(f"\n{paragraph}\n")
|
||||
|
||||
# Show on Braille display
|
||||
if self.brailleOutput.enabled:
|
||||
self.brailleOutput.show_paragraph(paragraph)
|
||||
|
||||
# Generate and play audio (unless muted for Braille-only mode)
|
||||
if not self.brailleOutput.muteVoice:
|
||||
try:
|
||||
wavData = self.ttsEngine.text_to_wav_data(paragraph)
|
||||
if wavData:
|
||||
completed = self.audioPlayer.play_wav_data(wavData, blocking=True)
|
||||
return completed
|
||||
except Exception as e:
|
||||
print(f"Error during playback: {e}")
|
||||
return False
|
||||
try:
|
||||
wavData = self.ttsEngine.text_to_wav_data(paragraph)
|
||||
if wavData:
|
||||
completed = self.audioPlayer.play_wav_data(wavData, blocking=True)
|
||||
return completed
|
||||
except Exception as e:
|
||||
print(f"Error during playback: {e}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@@ -709,6 +715,33 @@ class BookReader:
|
||||
message = "Speech-dispatcher settings reloaded successfully"
|
||||
print(message)
|
||||
self.speechEngine.speak(message)
|
||||
elif readerEngine == 'screenreader':
|
||||
# Route reading through active screen reader via D-Bus
|
||||
self.ttsEngine = None
|
||||
self.readingEngine = ScreenReaderEngine()
|
||||
speechRate = self.config.get_speech_rate()
|
||||
self.readingEngine.set_rate(speechRate)
|
||||
|
||||
if not self.readingEngine.is_available():
|
||||
message = "Warning: No supported screen reader detected. Falling back to speech-dispatcher."
|
||||
print(message)
|
||||
self.speechEngine.speak(message)
|
||||
|
||||
self.readingEngine = SpeechEngine()
|
||||
savedModule = self.config.get_speechd_output_module()
|
||||
if savedModule:
|
||||
self.readingEngine.set_output_module(savedModule)
|
||||
|
||||
savedVoice = self.config.get_speechd_voice()
|
||||
if savedVoice:
|
||||
self.readingEngine.set_voice(savedVoice)
|
||||
|
||||
self.readingEngine.set_rate(speechRate)
|
||||
self.config.set_reader_engine('speechd')
|
||||
else:
|
||||
message = f"Screen reader engine active: {self.readingEngine.get_active_reader_name()}"
|
||||
print(message)
|
||||
self.speechEngine.speak(message)
|
||||
else:
|
||||
# Reload piper-tts with new voice (check if available first)
|
||||
if not TtsEngine.is_available():
|
||||
@@ -772,7 +805,7 @@ class BookReader:
|
||||
# Determine book type for message
|
||||
isAudioBook = hasattr(self.book, 'isAudioBook') and self.book.isAudioBook
|
||||
bookType = "Audio book" if isAudioBook else "Book"
|
||||
self.speechEngine.speak(f"{bookType} loaded, press h for help.")
|
||||
self.speechEngine.speak(f"{bookType} loaded, press F1 or question mark for help.")
|
||||
else:
|
||||
self.displayText = "No book loaded"
|
||||
self.statusText = "Press A for Audiobookshelf, B for local books, R for recent books"
|
||||
@@ -870,11 +903,12 @@ class BookReader:
|
||||
self._handle_pygame_key(event)
|
||||
|
||||
elif event.type == SPEECH_FINISHED_EVENT:
|
||||
# Speech-dispatcher paragraph finished, advance to next
|
||||
# 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()))
|
||||
|
||||
@@ -887,12 +921,6 @@ class BookReader:
|
||||
# Start next paragraph
|
||||
self._start_paragraph_playback()
|
||||
|
||||
# Check for Braille display key presses (panning, routing, etc.)
|
||||
if self.brailleOutput.enabled:
|
||||
brailleKey = self.brailleOutput.read_key()
|
||||
if brailleKey:
|
||||
self._handle_braille_key(brailleKey)
|
||||
|
||||
# Explicitly delete event objects to help GC
|
||||
del events
|
||||
|
||||
@@ -925,13 +953,14 @@ class BookReader:
|
||||
self.isPlaying = False
|
||||
|
||||
# Check if we need to advance to next paragraph/chapter
|
||||
# Speech-dispatcher uses callbacks for auto-advance
|
||||
# Callback-driven engines use SPEECH_FINISHED_EVENT for auto-advance
|
||||
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()))
|
||||
|
||||
@@ -1070,8 +1099,9 @@ class BookReader:
|
||||
|
||||
# Stop playback
|
||||
readerEngine = self.config.get_reader_engine()
|
||||
if readerEngine == 'speechd':
|
||||
self.readingEngine.cancel_reading()
|
||||
if readerEngine in ['speechd', 'screenreader']:
|
||||
if self.readingEngine:
|
||||
self.readingEngine.cancel_reading()
|
||||
else:
|
||||
# Stop audio player (handles both TTS and audio books)
|
||||
self.audioPlayer.stop()
|
||||
@@ -1081,7 +1111,7 @@ class BookReader:
|
||||
# Clean up speech engines
|
||||
if self.speechEngine:
|
||||
self.speechEngine.close()
|
||||
if readerEngine == 'speechd' and self.readingEngine:
|
||||
if readerEngine in ['speechd', 'screenreader'] and self.readingEngine:
|
||||
self.readingEngine.close()
|
||||
|
||||
# Clean up audio player
|
||||
@@ -1095,45 +1125,6 @@ class BookReader:
|
||||
self.cachedSurfaces.clear()
|
||||
pygame.quit()
|
||||
|
||||
def _handle_braille_key(self, keyCode):
|
||||
"""
|
||||
Handle key presses from Braille display.
|
||||
|
||||
Args:
|
||||
keyCode: Key code from brltty
|
||||
"""
|
||||
# BrlAPI key codes (common ones)
|
||||
# Forward panning: typically CMD_FWINRT or specific device codes
|
||||
# Backward panning: typically CMD_FWINLT
|
||||
# The exact codes depend on the device, so we check the command type
|
||||
|
||||
try:
|
||||
# Convert key code to command
|
||||
# BrlAPI provides key codes as integers
|
||||
# Common commands (from brltty documentation):
|
||||
# FWINRT (forward) and FWINLT (backward) for panning
|
||||
|
||||
# Check if it's a panning command
|
||||
# The key format varies by device, but we can check ranges
|
||||
# Typically: 0x04000000 range for panning commands
|
||||
|
||||
# Try to parse as BrlAPI command
|
||||
command = keyCode & 0x00FFFFFF # Extract command part
|
||||
|
||||
# Common commands (approximate values, device-specific)
|
||||
CMD_FWINRT = 0x0001 # Pan forward/right
|
||||
CMD_FWINLT = 0x0002 # Pan backward/left
|
||||
|
||||
if command == CMD_FWINRT or (keyCode & 0xFF) == 0x01:
|
||||
# Forward panning
|
||||
self.brailleOutput.pan_forward()
|
||||
elif command == CMD_FWINLT or (keyCode & 0xFF) == 0x02:
|
||||
# Backward panning
|
||||
self.brailleOutput.pan_backward()
|
||||
|
||||
except:
|
||||
pass
|
||||
|
||||
def _handle_pygame_key(self, event):
|
||||
"""Handle pygame key event"""
|
||||
# Check if in Audiobookshelf menu
|
||||
@@ -1161,6 +1152,11 @@ class BookReader:
|
||||
self._handle_sleep_timer_key(event)
|
||||
return
|
||||
|
||||
# Check if in help menu
|
||||
if self.helpMenu.is_in_menu():
|
||||
self._handle_help_menu_key(event)
|
||||
return
|
||||
|
||||
# Check if in options menu
|
||||
if self.optionsMenu.is_in_menu():
|
||||
self._handle_menu_key(event)
|
||||
@@ -1201,8 +1197,8 @@ class BookReader:
|
||||
self.audioPlayer.pause_audio_file()
|
||||
if self.isStreaming and self.absSync:
|
||||
self.absSync.mark_progress_checkpoint(self._get_current_audio_position())
|
||||
elif readerEngine == 'speechd':
|
||||
# Handle speech-dispatcher pause/resume
|
||||
elif readerEngine in ['speechd', 'screenreader']:
|
||||
# Handle callback-driven text reading pause/resume
|
||||
if self.readingEngine.is_reading_paused():
|
||||
self.readingEngine.resume_reading()
|
||||
else:
|
||||
@@ -1293,7 +1289,7 @@ class BookReader:
|
||||
|
||||
# Pause playback while saving
|
||||
wasPaused = False
|
||||
if readerEngine == 'speechd':
|
||||
if readerEngine in ['speechd', 'screenreader']:
|
||||
wasPaused = self.readingEngine.is_reading_paused()
|
||||
if not wasPaused and self.readingEngine.is_reading_active():
|
||||
self.readingEngine.pause_reading()
|
||||
@@ -1310,7 +1306,7 @@ class BookReader:
|
||||
|
||||
# Resume playback
|
||||
if not wasPaused and self.isPlaying:
|
||||
if readerEngine == 'speechd':
|
||||
if readerEngine in ['speechd', 'screenreader']:
|
||||
self.readingEngine.resume_reading()
|
||||
else:
|
||||
self.audioPlayer.resume()
|
||||
@@ -1323,7 +1319,7 @@ class BookReader:
|
||||
self.config.set_speech_rate(newRate)
|
||||
self.speechEngine.set_rate(newRate)
|
||||
# Apply to reading engine as well
|
||||
if readerEngine == 'speechd':
|
||||
if readerEngine in ['speechd', 'screenreader']:
|
||||
self.readingEngine.set_rate(newRate)
|
||||
# If currently reading, restart paragraph to apply new rate immediately
|
||||
if self.isPlaying and self.readingEngine.is_reading_active():
|
||||
@@ -1338,7 +1334,7 @@ class BookReader:
|
||||
self.config.set_speech_rate(newRate)
|
||||
self.speechEngine.set_rate(newRate)
|
||||
# Apply to reading engine as well
|
||||
if readerEngine == 'speechd':
|
||||
if readerEngine in ['speechd', 'screenreader']:
|
||||
self.readingEngine.set_rate(newRate)
|
||||
# If currently reading, restart paragraph to apply new rate immediately
|
||||
if self.isPlaying and self.readingEngine.is_reading_active():
|
||||
@@ -1372,18 +1368,12 @@ class BookReader:
|
||||
# Open options menu
|
||||
self.optionsMenu.enter_menu()
|
||||
|
||||
elif event.key == pygame.K_h:
|
||||
# Help
|
||||
if self.book and hasattr(self.book, 'isAudioBook') and self.book.isAudioBook:
|
||||
# Audio book help
|
||||
self.speechEngine.speak("SPACE: play pause. n: next chapter. p: previous chapter. LEFT RIGHT arrows: seek 5 seconds. UP DOWN arrows: seek 1 minute. SHIFT LEFT RIGHT: seek 30 seconds. SHIFT UP DOWN: seek 5 minutes. s: save bookmark. k: bookmarks menu. r: recent books. b: browse books. a: audiobookshelf. o: options menu. i: current info. Right bracket: increase playback speed. Left bracket: decrease playback speed. Backspace: reset playback speed. 0: increase volume. 9: decrease volume. SHIFT HOME: clear bookmark and jump to beginning. CONTROL: stop speech. t: time remaining. h: help. q: quit or sleep timer")
|
||||
else:
|
||||
# Text book help
|
||||
self.speechEngine.speak("SPACE: play pause. n: next paragraph. p: previous paragraph. Shift N: next chapter. Shift P: previous chapter. LEFT RIGHT arrows: previous next paragraph. SHIFT LEFT RIGHT: previous next chapter. UP DOWN arrows: skip 5 paragraphs. SHIFT UP DOWN: first last paragraph. s: save bookmark. k: bookmarks menu. r: recent books. b: browse books. a: audiobookshelf. o: options menu. i: current info. Page Up Down: adjust speech rate. Right bracket: increase playback speed. Left bracket: decrease playback speed. Backspace: reset playback speed. SHIFT HOME: clear bookmark and jump to beginning. CONTROL: stop speech. t: time remaining. h: help. q: quit or sleep timer")
|
||||
elif self._is_help_key(event):
|
||||
self.helpMenu.enter_menu(self._get_help_lines())
|
||||
|
||||
elif event.key == pygame.K_i:
|
||||
if not self.book:
|
||||
self.speechEngine.speak("No book loaded. Press H for help.")
|
||||
self.speechEngine.speak("No book loaded. Press F1 or question mark for help.")
|
||||
return
|
||||
|
||||
# Speak current position info
|
||||
@@ -1683,6 +1673,83 @@ class BookReader:
|
||||
else:
|
||||
self.speechEngine.speak("End of chapter")
|
||||
|
||||
def _is_help_key(self, event):
|
||||
"""Return True when the key event should open or close help."""
|
||||
if event.key == pygame.K_F1:
|
||||
return True
|
||||
return getattr(event, 'unicode', '') == '?'
|
||||
|
||||
def _get_help_lines(self):
|
||||
"""Return help lines for the current reading mode."""
|
||||
isAudioBook = bool(self.book and hasattr(self.book, 'isAudioBook') and self.book.isAudioBook)
|
||||
|
||||
if isAudioBook:
|
||||
return [
|
||||
"Space: play or pause.",
|
||||
"N: next chapter.",
|
||||
"P: previous chapter.",
|
||||
"Left or right arrow: seek 5 seconds.",
|
||||
"Shift left or right arrow: seek 30 seconds.",
|
||||
"Up or down arrow: seek 1 minute.",
|
||||
"Shift up or down arrow: seek 5 minutes.",
|
||||
"S: save bookmark.",
|
||||
"K: bookmarks menu.",
|
||||
"R: recent books.",
|
||||
"B: browse local books.",
|
||||
"A: Audiobookshelf browser.",
|
||||
"O: options menu.",
|
||||
"I: current info.",
|
||||
"Right bracket: increase playback speed.",
|
||||
"Left bracket: decrease playback speed.",
|
||||
"Backspace: reset playback speed.",
|
||||
"0: increase volume.",
|
||||
"9: decrease volume.",
|
||||
"Shift home: clear bookmark and restart at beginning.",
|
||||
"Control: stop speech.",
|
||||
"T: time remaining.",
|
||||
"F1 or question mark: help.",
|
||||
"Q or escape: quit or sleep timer."
|
||||
]
|
||||
|
||||
return [
|
||||
"Space: play or pause.",
|
||||
"N: next paragraph.",
|
||||
"P: previous paragraph.",
|
||||
"Shift N: next chapter.",
|
||||
"Shift P: previous chapter.",
|
||||
"Left or right arrow: previous or next paragraph.",
|
||||
"Shift left or right arrow: previous or next chapter.",
|
||||
"Up or down arrow: skip 5 paragraphs.",
|
||||
"Shift up: first paragraph.",
|
||||
"Shift down: last paragraph.",
|
||||
"S: save bookmark.",
|
||||
"K: bookmarks menu.",
|
||||
"R: recent books.",
|
||||
"B: browse local books.",
|
||||
"A: Audiobookshelf browser.",
|
||||
"O: options menu.",
|
||||
"I: current info.",
|
||||
"Page up or page down: adjust speech rate.",
|
||||
"Right bracket: increase playback speed.",
|
||||
"Left bracket: decrease playback speed.",
|
||||
"Backspace: reset playback speed.",
|
||||
"Shift home: clear bookmark and restart at beginning.",
|
||||
"Control: stop speech.",
|
||||
"T: time remaining.",
|
||||
"F1 or question mark: help.",
|
||||
"Q or escape: quit or sleep timer."
|
||||
]
|
||||
|
||||
def _handle_help_menu_key(self, event):
|
||||
"""Handle key events when in help menu."""
|
||||
if event.key == pygame.K_UP:
|
||||
self.helpMenu.navigate_menu('up')
|
||||
elif event.key == pygame.K_DOWN:
|
||||
self.helpMenu.navigate_menu('down')
|
||||
elif event.key == pygame.K_ESCAPE or event.key == pygame.K_RETURN or self._is_help_key(event):
|
||||
self.helpMenu.exit_menu()
|
||||
self.speechEngine.speak("Help closed.")
|
||||
|
||||
def _handle_recent_books_key(self, event):
|
||||
"""Handle key events when in recent books menu"""
|
||||
if event.key == pygame.K_UP:
|
||||
@@ -2511,7 +2578,7 @@ class BookReader:
|
||||
self.speechEngine.speak(message)
|
||||
|
||||
def _stop_playback(self):
|
||||
"""Stop current playback (audio books, piper-tts, and speech-dispatcher)"""
|
||||
"""Stop current playback (audio books, piper-tts, and callback-driven engines)."""
|
||||
# Handle case where no book is loaded
|
||||
if not self.book:
|
||||
return
|
||||
@@ -2527,16 +2594,15 @@ class BookReader:
|
||||
self.audioPlayer.stop_audio_file()
|
||||
else:
|
||||
self.audioPlayer.stop()
|
||||
elif readerEngine == 'speechd':
|
||||
# Cancel speech-dispatcher reading
|
||||
elif readerEngine in ['speechd', 'screenreader']:
|
||||
# Cancel callback-driven text reading
|
||||
self.readingEngine.cancel_reading()
|
||||
|
||||
def _restart_current_paragraph(self):
|
||||
"""
|
||||
Restart current paragraph playback (for speech-dispatcher rate changes)
|
||||
Restart current paragraph playback after speech-rate changes.
|
||||
|
||||
This is needed because speech-dispatcher only applies rate changes
|
||||
to the next speech utterance, not to currently playing speech.
|
||||
Callback-driven engines apply rate changes only to the next utterance.
|
||||
"""
|
||||
# Cancel current speech
|
||||
self.readingEngine.cancel_reading()
|
||||
@@ -2569,89 +2635,76 @@ class BookReader:
|
||||
# Use configured reader engine
|
||||
readerEngine = self.config.get_reader_engine()
|
||||
|
||||
if readerEngine == 'speechd':
|
||||
# Use speech-dispatcher for reading with callback
|
||||
if readerEngine in ['speechd', 'screenreader']:
|
||||
# Use callback-driven reading engines
|
||||
def on_speech_finished(finishType):
|
||||
"""
|
||||
Callback when speech-dispatcher finishes speaking.
|
||||
Must not call speechd commands directly (causes deadlock).
|
||||
Post pygame event instead.
|
||||
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))
|
||||
|
||||
# Show on Braille display (unless muted for Braille-only mode)
|
||||
if self.brailleOutput.enabled:
|
||||
self.brailleOutput.show_paragraph(paragraph)
|
||||
|
||||
# Speak (unless muted for Braille-only mode)
|
||||
if not self.brailleOutput.muteVoice:
|
||||
self.readingEngine.speak_reading(paragraph, callback=on_speech_finished)
|
||||
self.readingEngine.speak_reading(paragraph, callback=on_speech_finished)
|
||||
else:
|
||||
# Use piper-tts for reading with buffering
|
||||
|
||||
# Show on Braille display
|
||||
if self.brailleOutput.enabled:
|
||||
self.brailleOutput.show_paragraph(paragraph)
|
||||
|
||||
# Only generate/play audio if voice not muted
|
||||
if not self.brailleOutput.muteVoice:
|
||||
wavData = None
|
||||
try:
|
||||
# Check if we have buffered audio ready for THIS paragraph
|
||||
with self.bufferLock:
|
||||
if (self.bufferedAudio is not None and
|
||||
self.bufferedChapter == self.currentChapter and
|
||||
self.bufferedParagraph == self.currentParagraph):
|
||||
# Use pre-generated audio (matches current position)
|
||||
wavData = self.bufferedAudio
|
||||
wavData = None
|
||||
try:
|
||||
# Check if we have buffered audio ready for THIS paragraph
|
||||
with self.bufferLock:
|
||||
if (self.bufferedAudio is not None and
|
||||
self.bufferedChapter == self.currentChapter and
|
||||
self.bufferedParagraph == self.currentParagraph):
|
||||
# Use pre-generated audio (matches current position)
|
||||
wavData = self.bufferedAudio
|
||||
self.bufferedAudio = None
|
||||
self.bufferedChapter = -1
|
||||
self.bufferedParagraph = -1
|
||||
else:
|
||||
# Discard stale buffer if present (wrong paragraph)
|
||||
if self.bufferedAudio is not None:
|
||||
print(f"Discarding stale buffer (expected ch{self.currentChapter}p{self.currentParagraph}, got ch{self.bufferedChapter}p{self.bufferedParagraph})")
|
||||
del self.bufferedAudio
|
||||
self.bufferedAudio = None
|
||||
self.bufferedChapter = -1
|
||||
self.bufferedParagraph = -1
|
||||
else:
|
||||
# Discard stale buffer if present (wrong paragraph)
|
||||
if self.bufferedAudio is not None:
|
||||
print(f"Discarding stale buffer (expected ch{self.currentChapter}p{self.currentParagraph}, got ch{self.bufferedChapter}p{self.bufferedParagraph})")
|
||||
del self.bufferedAudio
|
||||
self.bufferedAudio = None
|
||||
self.bufferedChapter = -1
|
||||
self.bufferedParagraph = -1
|
||||
# Force GC to reclaim buffer memory immediately
|
||||
gc.collect()
|
||||
# Generate audio now (first paragraph or after navigation)
|
||||
wavData = self.ttsEngine.text_to_wav_data(paragraph)
|
||||
# Force GC to reclaim buffer memory immediately
|
||||
gc.collect()
|
||||
# Generate audio now (first paragraph or after navigation)
|
||||
wavData = self.ttsEngine.text_to_wav_data(paragraph)
|
||||
|
||||
if wavData:
|
||||
# Stop any existing audio file (playing, paused, or idle)
|
||||
# This ensures temp files are cleaned up immediately
|
||||
if self.audioPlayer.is_audio_file_loaded():
|
||||
self.audioPlayer.stop_audio_file()
|
||||
if wavData:
|
||||
# Stop any existing audio file (playing, paused, or idle)
|
||||
# This ensures temp files are cleaned up immediately
|
||||
if self.audioPlayer.is_audio_file_loaded():
|
||||
self.audioPlayer.stop_audio_file()
|
||||
|
||||
# Get current playback speed from config
|
||||
playbackSpeed = self.config.get_playback_speed()
|
||||
# Get current playback speed from config
|
||||
playbackSpeed = self.config.get_playback_speed()
|
||||
|
||||
# Play WAV data through MpvPlayer (which supports pause/resume)
|
||||
if self.audioPlayer.play_wav_data(wavData, playbackSpeed=playbackSpeed):
|
||||
# Start buffering next paragraph in background
|
||||
self._buffer_next_paragraph()
|
||||
else:
|
||||
print("Error: Failed to start TTS playback")
|
||||
self.isPlaying = False
|
||||
|
||||
# Explicitly delete wavData after playback starts to free memory
|
||||
del wavData
|
||||
wavData = None
|
||||
# Play WAV data through MpvPlayer (which supports pause/resume)
|
||||
if self.audioPlayer.play_wav_data(wavData, playbackSpeed=playbackSpeed):
|
||||
# Start buffering next paragraph in background
|
||||
self._buffer_next_paragraph()
|
||||
else:
|
||||
print("Warning: No audio data generated")
|
||||
except Exception as e:
|
||||
print(f"Error during playback: {e}")
|
||||
# Stop playback on error to prevent infinite error loop
|
||||
self.isPlaying = False
|
||||
raise
|
||||
finally:
|
||||
# The variable is already set to None in all relevant paths
|
||||
pass
|
||||
print("Error: Failed to start TTS playback")
|
||||
self.isPlaying = False
|
||||
|
||||
# Explicitly delete wavData after playback starts to free memory
|
||||
del wavData
|
||||
wavData = None
|
||||
else:
|
||||
print("Warning: No audio data generated")
|
||||
except Exception as e:
|
||||
print(f"Error during playback: {e}")
|
||||
# Stop playback on error to prevent infinite error loop
|
||||
self.isPlaying = False
|
||||
raise
|
||||
finally:
|
||||
# The variable is already set to None in all relevant paths
|
||||
pass
|
||||
|
||||
def _start_audio_chapter_playback(self, chapter):
|
||||
"""Start playing audio book chapter"""
|
||||
@@ -2856,9 +2909,6 @@ class BookReader:
|
||||
self.readingEngine.cleanup()
|
||||
if self.parser:
|
||||
self.parser.cleanup()
|
||||
# Cleanup Braille display
|
||||
if self.brailleOutput:
|
||||
self.brailleOutput.close()
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
+1
-7
@@ -8,14 +8,8 @@ mpv
|
||||
requests>=2.25.0
|
||||
setproctitle>=1.3.0
|
||||
|
||||
# Braille display support (optional)
|
||||
# Note: These are system packages, not pip packages
|
||||
# Install with: sudo pacman -S brltty liblouis python-brlapi python-louis (Arch)
|
||||
# or: sudo apt install brltty liblouis-bin python3-brlapi python3-louis (Debian/Ubuntu)
|
||||
|
||||
# Optional dependencies
|
||||
# piper-tts: Install separately with voice models to /usr/share/piper-voices/
|
||||
# ffmpeg: Install via system package manager for M4B/M4A support
|
||||
# calibre: Install via system package manager for MOBI support (provides ebook-convert)
|
||||
# brltty: System daemon for Braille display hardware
|
||||
# liblouis: Braille translation library
|
||||
# dasbus: Optional screen reader integration (Orca/Cthulhu over D-Bus)
|
||||
|
||||
@@ -1,285 +0,0 @@
|
||||
"""
|
||||
Braille settings menu for BookStorm.
|
||||
|
||||
Provides a submenu for configuring Braille display settings including:
|
||||
- Enable/disable Braille output
|
||||
- Translation table selection (Grade 1, Grade 2, UEB)
|
||||
- Sync mode (sync with TTS or manual)
|
||||
- Status message display toggle
|
||||
"""
|
||||
|
||||
import pygame
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BrailleMenu:
|
||||
"""Interactive menu for Braille display settings."""
|
||||
|
||||
def __init__(self, screen, speechEngine, brailleOutput, configManager):
|
||||
"""
|
||||
Initialize Braille menu.
|
||||
|
||||
Args:
|
||||
screen: Pygame display surface
|
||||
speechEngine: Speech engine for menu feedback
|
||||
brailleOutput: BrailleOutput instance to configure
|
||||
configManager: ConfigManager for saving settings
|
||||
"""
|
||||
self.screen = screen
|
||||
self.speechEngine = speechEngine
|
||||
self.brailleOutput = brailleOutput
|
||||
self.configManager = configManager
|
||||
self.menuItems = []
|
||||
self.currentIndex = 0
|
||||
self.running = False
|
||||
|
||||
# Build menu items
|
||||
self._build_menu()
|
||||
|
||||
def _build_menu(self):
|
||||
"""Build menu items based on current settings."""
|
||||
self.menuItems = [
|
||||
{
|
||||
"label": f"Braille Output: {'Enabled' if self.brailleOutput.enabled else 'Disabled'}",
|
||||
"action": self._toggle_enabled
|
||||
},
|
||||
{
|
||||
"label": f"Translation Table: {self._get_table_display_name()}",
|
||||
"action": self._cycle_table
|
||||
},
|
||||
{
|
||||
"label": f"Sync with TTS: {'Yes' if self.brailleOutput.syncWithTts else 'No'}",
|
||||
"action": self._toggle_sync
|
||||
},
|
||||
{
|
||||
"label": f"Show Status Messages: {'Yes' if self.brailleOutput.showStatus else 'No'}",
|
||||
"action": self._toggle_status
|
||||
},
|
||||
{
|
||||
"label": f"Mute Voice (Braille Only): {'Yes' if self.brailleOutput.muteVoice else 'No'}",
|
||||
"action": self._toggle_mute_voice
|
||||
},
|
||||
{
|
||||
"label": "Back to Options",
|
||||
"action": self._exit_menu
|
||||
}
|
||||
]
|
||||
|
||||
def _get_table_display_name(self):
|
||||
"""Get display name for current translation table."""
|
||||
tableNames = {
|
||||
"grade1": "Grade 1",
|
||||
"grade2": "Grade 2",
|
||||
"ueb": "UEB"
|
||||
}
|
||||
return tableNames.get(self.brailleOutput.translationTable, "Unknown")
|
||||
|
||||
def _toggle_enabled(self):
|
||||
"""Toggle Braille output enabled/disabled."""
|
||||
# Note: Toggling requires re-initialization
|
||||
newState = not self.brailleOutput.enabled
|
||||
|
||||
if newState and not self.brailleOutput.brl:
|
||||
# Try to initialize
|
||||
self.brailleOutput.enabled = True
|
||||
self.brailleOutput._initialize_braille()
|
||||
|
||||
if not self.brailleOutput.brl:
|
||||
self.speechEngine.speak("Braille initialization failed. Check brltty daemon.")
|
||||
self.brailleOutput.enabled = False
|
||||
else:
|
||||
self.speechEngine.speak("Braille output enabled")
|
||||
elif not newState:
|
||||
# Disable
|
||||
self.brailleOutput.close()
|
||||
self.speechEngine.speak("Braille output disabled")
|
||||
|
||||
# Save to config - save ALL settings when enabling to ensure config is complete
|
||||
self.configManager.set("Braille", "enabled", str(self.brailleOutput.enabled))
|
||||
if self.brailleOutput.enabled:
|
||||
# Save all settings to ensure config is complete
|
||||
self.configManager.set("Braille", "translation_table", self.brailleOutput.translationTable)
|
||||
self.configManager.set("Braille", "sync_with_tts", str(self.brailleOutput.syncWithTts))
|
||||
self.configManager.set("Braille", "show_status", str(self.brailleOutput.showStatus))
|
||||
self.configManager.set("Braille", "mute_voice", str(self.brailleOutput.muteVoice))
|
||||
self.configManager.save_settings()
|
||||
|
||||
# Rebuild menu to update labels
|
||||
self._build_menu()
|
||||
|
||||
def _cycle_table(self):
|
||||
"""Cycle through translation tables."""
|
||||
tables = ["grade1", "grade2", "ueb"]
|
||||
currentIdx = tables.index(self.brailleOutput.translationTable)
|
||||
nextIdx = (currentIdx + 1) % len(tables)
|
||||
newTable = tables[nextIdx]
|
||||
|
||||
self.brailleOutput.set_table(newTable)
|
||||
self.speechEngine.speak(f"Translation table: {self._get_table_display_name()}")
|
||||
|
||||
# Save to config
|
||||
self.configManager.set("Braille", "translation_table", newTable)
|
||||
self.configManager.save_settings()
|
||||
|
||||
# Rebuild menu
|
||||
self._build_menu()
|
||||
|
||||
def _toggle_sync(self):
|
||||
"""Toggle sync with TTS mode."""
|
||||
newSync = not self.brailleOutput.syncWithTts
|
||||
self.brailleOutput.set_sync_mode(newSync)
|
||||
|
||||
self.speechEngine.speak(f"Sync with TTS: {'enabled' if newSync else 'disabled'}")
|
||||
|
||||
# Save to config
|
||||
self.configManager.set("Braille", "sync_with_tts", str(newSync))
|
||||
self.configManager.save_settings()
|
||||
|
||||
# Rebuild menu
|
||||
self._build_menu()
|
||||
|
||||
def _toggle_status(self):
|
||||
"""Toggle status message display."""
|
||||
newStatus = not self.brailleOutput.showStatus
|
||||
self.brailleOutput.set_show_status(newStatus)
|
||||
|
||||
self.speechEngine.speak(f"Status messages: {'enabled' if newStatus else 'disabled'}")
|
||||
|
||||
# Save to config
|
||||
self.configManager.set("Braille", "show_status", str(newStatus))
|
||||
self.configManager.save_settings()
|
||||
|
||||
# Rebuild menu
|
||||
self._build_menu()
|
||||
|
||||
def _toggle_mute_voice(self):
|
||||
"""Toggle mute voice (Braille-only mode)."""
|
||||
newMute = not self.brailleOutput.muteVoice
|
||||
self.brailleOutput.set_mute_voice(newMute)
|
||||
|
||||
self.speechEngine.speak(f"Braille-only mode: {'enabled' if newMute else 'disabled'}")
|
||||
|
||||
# Save to config
|
||||
self.configManager.set("Braille", "mute_voice", str(newMute))
|
||||
self.configManager.save_settings()
|
||||
|
||||
# Rebuild menu
|
||||
self._build_menu()
|
||||
|
||||
def _exit_menu(self):
|
||||
"""Exit the Braille settings menu."""
|
||||
self.running = False
|
||||
|
||||
def show(self):
|
||||
"""Display and run the Braille settings menu."""
|
||||
self.running = True
|
||||
self.currentIndex = 0
|
||||
|
||||
# Announce menu
|
||||
self.speechEngine.speak("Braille Settings")
|
||||
|
||||
# Show first item
|
||||
if self.menuItems:
|
||||
self._speak_current_item()
|
||||
|
||||
# Event loop
|
||||
clock = pygame.time.Clock()
|
||||
while self.running:
|
||||
for event in pygame.event.get():
|
||||
if event.type == pygame.QUIT:
|
||||
self.running = False
|
||||
return False # Signal app should quit
|
||||
|
||||
elif event.type == pygame.KEYDOWN:
|
||||
if event.key == pygame.K_ESCAPE:
|
||||
self._exit_menu()
|
||||
|
||||
elif event.key == pygame.K_UP:
|
||||
self._navigate_up()
|
||||
|
||||
elif event.key == pygame.K_DOWN:
|
||||
self._navigate_down()
|
||||
|
||||
elif event.key == pygame.K_RETURN or event.key == pygame.K_SPACE:
|
||||
self._activate_current()
|
||||
|
||||
# Render menu
|
||||
self._render()
|
||||
clock.tick(30)
|
||||
|
||||
return True # Menu exited normally
|
||||
|
||||
def _navigate_up(self):
|
||||
"""Navigate to previous menu item."""
|
||||
if self.menuItems:
|
||||
self.currentIndex = (self.currentIndex - 1) % len(self.menuItems)
|
||||
self._speak_current_item()
|
||||
|
||||
def _navigate_down(self):
|
||||
"""Navigate to next menu item."""
|
||||
if self.menuItems:
|
||||
self.currentIndex = (self.currentIndex + 1) % len(self.menuItems)
|
||||
self._speak_current_item()
|
||||
|
||||
def _activate_current(self):
|
||||
"""Activate the current menu item."""
|
||||
if self.menuItems and 0 <= self.currentIndex < len(self.menuItems):
|
||||
item = self.menuItems[self.currentIndex]
|
||||
if "action" in item:
|
||||
item["action"]()
|
||||
|
||||
def _speak_current_item(self):
|
||||
"""Speak and display the current menu item."""
|
||||
if self.menuItems and 0 <= self.currentIndex < len(self.menuItems):
|
||||
item = self.menuItems[self.currentIndex]
|
||||
self.speechEngine.speak(item["label"])
|
||||
|
||||
# Show on Braille display
|
||||
if self.brailleOutput.enabled:
|
||||
self.brailleOutput.show_menu_item(
|
||||
item["label"],
|
||||
self.currentIndex + 1,
|
||||
len(self.menuItems)
|
||||
)
|
||||
|
||||
def _render(self):
|
||||
"""Render the menu on screen."""
|
||||
# Clear screen
|
||||
self.screen.fill((0, 0, 0))
|
||||
|
||||
# Get screen dimensions
|
||||
width, height = self.screen.get_size()
|
||||
|
||||
# Font setup
|
||||
try:
|
||||
menuFont = pygame.font.Font(None, 36)
|
||||
titleFont = pygame.font.Font(None, 48)
|
||||
except:
|
||||
menuFont = pygame.font.SysFont('dejavu', 36)
|
||||
titleFont = pygame.font.SysFont('dejavu', 48)
|
||||
|
||||
# Draw title
|
||||
titleSurface = titleFont.render("Braille Settings", True, (255, 255, 255))
|
||||
titleRect = titleSurface.get_rect(center=(width // 2, 50))
|
||||
self.screen.blit(titleSurface, titleRect)
|
||||
|
||||
# Draw menu items
|
||||
yOffset = 150
|
||||
for i, item in enumerate(self.menuItems):
|
||||
color = (255, 255, 0) if i == self.currentIndex else (255, 255, 255)
|
||||
itemSurface = menuFont.render(item["label"], True, color)
|
||||
itemRect = itemSurface.get_rect(center=(width // 2, yOffset))
|
||||
self.screen.blit(itemSurface, itemRect)
|
||||
yOffset += 80
|
||||
|
||||
# Draw instructions
|
||||
instructionsFont = pygame.font.Font(None, 24)
|
||||
instructions = "UP/DOWN: Navigate | ENTER: Select | ESC: Back"
|
||||
instrSurface = instructionsFont.render(instructions, True, (128, 128, 128))
|
||||
instrRect = instrSurface.get_rect(center=(width // 2, height - 30))
|
||||
self.screen.blit(instrSurface, instrRect)
|
||||
|
||||
pygame.display.flip()
|
||||
@@ -1,352 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
+3
-59
@@ -77,14 +77,6 @@ class ConfigManager:
|
||||
'stream_cache_limit': '500'
|
||||
}
|
||||
|
||||
self.config['Braille'] = {
|
||||
'enabled': 'false',
|
||||
'translation_table': 'grade2',
|
||||
'sync_with_tts': 'true',
|
||||
'show_status': 'true',
|
||||
'mute_voice': 'false'
|
||||
}
|
||||
|
||||
self.save()
|
||||
|
||||
def get(self, section, key, fallback=None):
|
||||
@@ -186,12 +178,12 @@ class ConfigManager:
|
||||
return self.get_bool('Reading', 'auto_save_bookmark', True)
|
||||
|
||||
def get_reader_engine(self):
|
||||
"""Get reader engine (piper or speechd)"""
|
||||
"""Get reader engine (piper, speechd, or screenreader)"""
|
||||
return self.get('TTS', 'reader_engine', 'piper')
|
||||
|
||||
def set_reader_engine(self, engine):
|
||||
"""Set reader engine (piper or speechd)"""
|
||||
if engine in ['piper', 'speechd']:
|
||||
"""Set reader engine (piper, speechd, or screenreader)"""
|
||||
if engine in ['piper', 'speechd', 'screenreader']:
|
||||
self.set('TTS', 'reader_engine', engine)
|
||||
self.save()
|
||||
|
||||
@@ -352,54 +344,6 @@ class ConfigManager:
|
||||
self.set('Audio', 'volume', str(volume))
|
||||
self.save()
|
||||
|
||||
# Braille settings
|
||||
|
||||
def get_braille_enabled(self):
|
||||
"""Get Braille output enabled setting"""
|
||||
return self.get_bool('Braille', 'enabled', False)
|
||||
|
||||
def set_braille_enabled(self, enabled):
|
||||
"""Set Braille output enabled setting"""
|
||||
self.set('Braille', 'enabled', str(enabled).lower())
|
||||
self.save()
|
||||
|
||||
def get_braille_translation_table(self):
|
||||
"""Get Braille translation table (grade1/grade2/ueb)"""
|
||||
return self.get('Braille', 'translation_table', 'grade2')
|
||||
|
||||
def set_braille_translation_table(self, table):
|
||||
"""Set Braille translation table"""
|
||||
if table in ['grade1', 'grade2', 'ueb']:
|
||||
self.set('Braille', 'translation_table', table)
|
||||
self.save()
|
||||
|
||||
def get_braille_sync_with_tts(self):
|
||||
"""Get Braille sync with TTS setting"""
|
||||
return self.get_bool('Braille', 'sync_with_tts', True)
|
||||
|
||||
def set_braille_sync_with_tts(self, enabled):
|
||||
"""Set Braille sync with TTS setting"""
|
||||
self.set('Braille', 'sync_with_tts', str(enabled).lower())
|
||||
self.save()
|
||||
|
||||
def get_braille_show_status(self):
|
||||
"""Get Braille show status messages setting"""
|
||||
return self.get_bool('Braille', 'show_status', True)
|
||||
|
||||
def set_braille_show_status(self, enabled):
|
||||
"""Set Braille show status messages setting"""
|
||||
self.set('Braille', 'show_status', str(enabled).lower())
|
||||
self.save()
|
||||
|
||||
def get_braille_mute_voice(self):
|
||||
"""Get Braille mute voice (Braille-only mode) setting"""
|
||||
return self.get_bool('Braille', 'mute_voice', False)
|
||||
|
||||
def set_braille_mute_voice(self, enabled):
|
||||
"""Set Braille mute voice setting"""
|
||||
self.set('Braille', 'mute_voice', str(enabled).lower())
|
||||
self.save()
|
||||
|
||||
def save_settings(self):
|
||||
"""Alias for save() - for backward compatibility"""
|
||||
self.save()
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Help Menu
|
||||
|
||||
Provides navigable help text with line-by-line speech output.
|
||||
"""
|
||||
|
||||
|
||||
class HelpMenu:
|
||||
"""Interactive help menu."""
|
||||
|
||||
def __init__(self, speechEngine):
|
||||
"""
|
||||
Initialize help menu.
|
||||
|
||||
Args:
|
||||
speechEngine: SpeechEngine instance for UI feedback
|
||||
"""
|
||||
self.speechEngine = speechEngine
|
||||
self.inMenu = False
|
||||
self.helpLines = []
|
||||
self.currentIndex = -1
|
||||
|
||||
def enter_menu(self, helpLines):
|
||||
"""
|
||||
Open help menu with provided lines.
|
||||
|
||||
Args:
|
||||
helpLines: List of help lines to navigate
|
||||
"""
|
||||
self.helpLines = helpLines if isinstance(helpLines, list) else []
|
||||
self.currentIndex = -1
|
||||
self.inMenu = True
|
||||
|
||||
if not self.helpLines:
|
||||
self.speechEngine.speak("Help opened. No help text available.")
|
||||
return
|
||||
|
||||
self.speechEngine.speak("Help opened. Press up or down arrows to navigate.")
|
||||
|
||||
def exit_menu(self):
|
||||
"""Close help menu."""
|
||||
self.inMenu = False
|
||||
|
||||
def is_in_menu(self):
|
||||
"""Return True when help menu is active."""
|
||||
return self.inMenu
|
||||
|
||||
def navigate_menu(self, direction):
|
||||
"""
|
||||
Navigate help text and speak one line.
|
||||
|
||||
Args:
|
||||
direction: 'up' or 'down'
|
||||
"""
|
||||
if not self.helpLines:
|
||||
self.speechEngine.speak("No help text available.")
|
||||
return
|
||||
|
||||
if direction == 'down':
|
||||
nextIndex = self.currentIndex + 1
|
||||
if nextIndex >= len(self.helpLines):
|
||||
self.speechEngine.speak("End of help.")
|
||||
return
|
||||
|
||||
self.currentIndex = nextIndex
|
||||
self.speechEngine.speak(self.helpLines[self.currentIndex])
|
||||
return
|
||||
|
||||
if direction == 'up':
|
||||
if self.currentIndex <= 0:
|
||||
self.speechEngine.speak("Beginning of help.")
|
||||
return
|
||||
|
||||
self.currentIndex -= 1
|
||||
self.speechEngine.speak(self.helpLines[self.currentIndex])
|
||||
+29
-58
@@ -14,7 +14,7 @@ from src.tts_engine import TtsEngine
|
||||
class OptionsMenu:
|
||||
"""Options menu for configuring BookStorm settings"""
|
||||
|
||||
def __init__(self, config, speechEngine, voiceSelector, audioPlayer, ttsReloadCallback=None, brailleOutput=None):
|
||||
def __init__(self, config, speechEngine, voiceSelector, audioPlayer, ttsReloadCallback=None):
|
||||
"""
|
||||
Initialize options menu
|
||||
|
||||
@@ -24,14 +24,12 @@ class OptionsMenu:
|
||||
voiceSelector: VoiceSelector instance
|
||||
audioPlayer: MpvPlayer instance
|
||||
ttsReloadCallback: Optional callback to reload TTS engine
|
||||
brailleOutput: Optional BrailleOutput instance
|
||||
"""
|
||||
self.config = config
|
||||
self.speechEngine = speechEngine
|
||||
self.voiceSelector = voiceSelector
|
||||
self.audioPlayer = audioPlayer
|
||||
self.ttsReloadCallback = ttsReloadCallback
|
||||
self.brailleOutput = brailleOutput
|
||||
self.currentSelection = 0
|
||||
self.inMenu = False
|
||||
|
||||
@@ -43,7 +41,12 @@ class OptionsMenu:
|
||||
Menu items as list of dicts
|
||||
"""
|
||||
readerEngine = self.config.get_reader_engine()
|
||||
readerEngineText = "Piper-TTS" if readerEngine == "piper" else "Speech-Dispatcher"
|
||||
engineLabels = {
|
||||
'piper': 'Piper-TTS',
|
||||
'speechd': 'Speech-Dispatcher',
|
||||
'screenreader': 'Screen Reader'
|
||||
}
|
||||
readerEngineText = engineLabels.get(readerEngine, 'Piper-TTS')
|
||||
|
||||
menuItems = [
|
||||
{
|
||||
@@ -80,12 +83,6 @@ class OptionsMenu:
|
||||
'label': "Speech Rate Settings",
|
||||
'action': 'speech_rate'
|
||||
},
|
||||
# TEMPORARILY DISABLED: Braille support disabled until BrlTTY issues resolved
|
||||
# To re-enable: Uncomment the following block
|
||||
# {
|
||||
# 'label': "Braille Settings",
|
||||
# 'action': 'braille_settings'
|
||||
# },
|
||||
{
|
||||
'label': absLabel,
|
||||
'action': 'audiobookshelf_setup'
|
||||
@@ -140,8 +137,6 @@ class OptionsMenu:
|
||||
return self._toggle_show_text()
|
||||
elif action == 'speech_rate':
|
||||
return self._speech_rate_info()
|
||||
elif action == 'braille_settings':
|
||||
return self._braille_settings()
|
||||
elif action == 'audiobookshelf_setup':
|
||||
return self._audiobookshelf_setup()
|
||||
elif action == 'back':
|
||||
@@ -151,19 +146,23 @@ class OptionsMenu:
|
||||
return True
|
||||
|
||||
def _toggle_reader_engine(self):
|
||||
"""Toggle between piper-tts and speech-dispatcher"""
|
||||
"""Cycle reader engine: piper-tts, speech-dispatcher, screen reader."""
|
||||
currentEngine = self.config.get_reader_engine()
|
||||
engineOrder = ['piper', 'speechd', 'screenreader']
|
||||
engineLabels = {
|
||||
'piper': 'Piper-TTS',
|
||||
'speechd': 'Speech-Dispatcher',
|
||||
'screenreader': 'Screen Reader'
|
||||
}
|
||||
|
||||
if currentEngine == 'piper':
|
||||
newEngine = 'speechd'
|
||||
oldEngine = 'piper'
|
||||
self.config.set_reader_engine('speechd')
|
||||
message = "Reader engine: Speech-Dispatcher."
|
||||
else:
|
||||
newEngine = 'piper'
|
||||
oldEngine = 'speechd'
|
||||
self.config.set_reader_engine('piper')
|
||||
message = "Reader engine: Piper-TTS."
|
||||
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')}."
|
||||
|
||||
# Reload TTS engine if callback available
|
||||
needsRestart = False
|
||||
@@ -187,6 +186,9 @@ class OptionsMenu:
|
||||
# Speak first option
|
||||
self.speechEngine.speak("Restart now")
|
||||
else:
|
||||
finalEngine = self.config.get_reader_engine()
|
||||
if finalEngine in engineLabels:
|
||||
message = f"Reader engine: {engineLabels[finalEngine]}."
|
||||
self.speechEngine.speak(message)
|
||||
print(message)
|
||||
|
||||
@@ -198,9 +200,12 @@ class OptionsMenu:
|
||||
|
||||
if readerEngine == 'piper':
|
||||
return self._select_piper_voice()
|
||||
else:
|
||||
if readerEngine == 'speechd':
|
||||
return self._select_speechd_voice()
|
||||
|
||||
self.speechEngine.speak("Voice selection is managed by your active screen reader.")
|
||||
return True
|
||||
|
||||
def _select_piper_voice(self):
|
||||
"""Select piper-tts voice"""
|
||||
self.speechEngine.speak("Selecting piper voice. Use arrow keys to browse, enter to select, escape to cancel.")
|
||||
@@ -503,40 +508,6 @@ class OptionsMenu:
|
||||
if menuItems and self.currentSelection < len(menuItems):
|
||||
self.speechEngine.speak(menuItems[self.currentSelection]['label'])
|
||||
|
||||
def _braille_settings(self):
|
||||
"""Open Braille settings submenu"""
|
||||
if not self.brailleOutput:
|
||||
self.speechEngine.speak("Braille output not initialized")
|
||||
return True
|
||||
|
||||
# Import here to avoid circular dependency
|
||||
from src.braille_menu import BrailleMenu
|
||||
import pygame
|
||||
|
||||
# Create Braille menu
|
||||
# Get pygame screen if available
|
||||
screen = pygame.display.get_surface()
|
||||
if not screen:
|
||||
# Create minimal display for menu
|
||||
screen = pygame.display.set_mode((1600, 900))
|
||||
|
||||
brailleMenu = BrailleMenu(screen, self.speechEngine, self.brailleOutput, self.config)
|
||||
|
||||
# Show Braille menu (blocks until user exits)
|
||||
continueRunning = brailleMenu.show()
|
||||
|
||||
# Return to options menu
|
||||
if continueRunning:
|
||||
self.speechEngine.speak("Back to options menu")
|
||||
# Speak current menu item
|
||||
menuItems = self.show_main_menu()
|
||||
if menuItems and self.currentSelection < len(menuItems):
|
||||
self.speechEngine.speak(menuItems[self.currentSelection]['label'])
|
||||
return True
|
||||
else:
|
||||
# User closed the application
|
||||
return False
|
||||
|
||||
def _audiobookshelf_setup(self):
|
||||
"""Setup Audiobookshelf server connection"""
|
||||
from src.ui import get_input
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
#!/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
|
||||
+1
-1
@@ -125,7 +125,7 @@ class WavExporter:
|
||||
TtsEngine instance or None if not available
|
||||
"""
|
||||
readerEngine = self.config.get_reader_engine()
|
||||
if readerEngine == 'speechd':
|
||||
if readerEngine != 'piper':
|
||||
print("Error: WAV export requires piper-tts. Set reader_engine=piper in config.")
|
||||
return None
|
||||
|
||||
|
||||
Reference in New Issue
Block a user