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:
Storm Dragon
2026-02-27 11:27:30 -05:00
parent badab833df
commit 7967c63684
10 changed files with 821 additions and 941 deletions
+9 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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)
-285
View File
@@ -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()
-352
View File
@@ -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
View File
@@ -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()
+77
View File
@@ -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
View File
@@ -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
+478
View File
@@ -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
View File
@@ -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