From d19c90e69a71c798307bc512b6ceec38db4b3989 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Wed, 8 Oct 2025 19:33:29 -0400 Subject: [PATCH] Conversion to mpv for playback mostly complete. --- bookstorm.py | 465 +++++++++++++++++++++++++++------ src/config_manager.py | 20 ++ src/mpv_player.py | 294 +++++++++++++++++++++ src/options_menu.py | 2 +- src/pygame_player.py | 519 ------------------------------------- src/server_link_manager.py | 47 +++- src/speech_engine.py | 1 + src/voice_selector.py | 4 +- 8 files changed, 744 insertions(+), 608 deletions(-) create mode 100644 src/mpv_player.py delete mode 100644 src/pygame_player.py diff --git a/bookstorm.py b/bookstorm.py index b60a99a..41b8909 100755 --- a/bookstorm.py +++ b/bookstorm.py @@ -13,6 +13,7 @@ import threading import gc import os from pathlib import Path +import subprocess try: from setproctitle import setproctitle @@ -39,7 +40,7 @@ from src.tts_engine import TtsEngine from src.config_manager import ConfigManager from src.voice_selector import VoiceSelector from src.book_selector import BookSelector -from src.pygame_player import PygamePlayer +from src.mpv_player import MpvPlayer from src.speech_engine import SpeechEngine from src.options_menu import OptionsMenu from src.sleep_timer_menu import SleepTimerMenu @@ -71,7 +72,8 @@ class BookReader: self.parser = None # Will be set based on file type self.bookmarkManager = BookmarkManager() self.speechEngine = SpeechEngine() # UI feedback - self.audioPlayer = PygamePlayer() + self.audioPlayer = MpvPlayer() + self.ttsMpvProcess = None # For direct mpv subprocess for TTS # Configure speech engine from saved settings speechRate = self.config.get_speech_rate() @@ -187,7 +189,9 @@ class BookReader: # If it's an audio book, load it into the player if hasattr(self.book, 'isAudioBook') and self.book.isAudioBook: - if not self.audioPlayer.load_audio_file(self.book.audioPath): + # Get saved playback speed from config + playbackSpeed = self.config.get_playback_speed() + if not self.audioPlayer.load_audio_file(self.book.audioPath, playbackSpeed=playbackSpeed): raise Exception("Failed to load audio file") # Inform user about navigation capabilities @@ -199,23 +203,61 @@ class BookReader: print(f"\nChapter navigation: Enabled ({self.book.get_total_chapters()} chapters)") self.speechEngine.speak(f"Audio book loaded with {self.book.get_total_chapters()} chapters. Chapter navigation enabled.") - # Load bookmark if exists (but don't announce it) - bookmark = self.bookmarkManager.get_bookmark(self.bookPath) - if bookmark: - self.currentChapter = bookmark['chapterIndex'] - self.currentParagraph = bookmark['paragraphIndex'] - self.savedAudioPosition = bookmark.get('audioPosition', 0.0) + # Check if this book is linked to Audiobookshelf server + # If so, prioritize server progress over local bookmark + serverLink = self.serverLinkManager.get_link(str(self.bookPath)) + serverProgressLoaded = False - # For audio books, show resume position - if hasattr(self.book, 'isAudioBook') and self.book.isAudioBook and self.savedAudioPosition > 0: - minutes = int(self.savedAudioPosition // 60) - seconds = int(self.savedAudioPosition % 60) - print(f"Resuming from chapter {self.currentChapter + 1} at {minutes}m {seconds}s") + if serverLink and self.absClient and self.absClient.is_authenticated(): + serverId = serverLink.get('server_id') + if serverId: + try: + serverProgress = self.absClient.get_progress(serverId) + if serverProgress: + progressTime = serverProgress.get('currentTime', 0.0) + if progressTime > 0: + minutes = int(progressTime // 60) + seconds = int(progressTime % 60) + print(f"Resuming from server progress: {minutes}m {seconds}s") + + # For audio books, save exact position + if hasattr(self.book, 'isAudioBook') and self.book.isAudioBook: + self.savedAudioPosition = progressTime + + # Find chapter that contains this time + for i, chap in enumerate(self.book.chapters): + if hasattr(chap, 'startTime'): + chapterEnd = chap.startTime + chap.duration + if chap.startTime <= progressTime < chapterEnd: + self.currentChapter = i + break + else: + # Text book - use chapter/paragraph from server if available + # (Audiobookshelf doesn't track paragraph, so we'd need to enhance this) + pass + + serverProgressLoaded = True + except Exception as e: + print(f"Could not load server progress: {e}") + + # Fall back to local bookmark if no server progress + if not serverProgressLoaded: + bookmark = self.bookmarkManager.get_bookmark(self.bookPath) + if bookmark: + self.currentChapter = bookmark['chapterIndex'] + self.currentParagraph = bookmark['paragraphIndex'] + self.savedAudioPosition = bookmark.get('audioPosition', 0.0) + + # For audio books, show resume position + if hasattr(self.book, 'isAudioBook') and self.book.isAudioBook and self.savedAudioPosition > 0: + minutes = int(self.savedAudioPosition // 60) + seconds = int(self.savedAudioPosition % 60) + print(f"Resuming from local bookmark: chapter {self.currentChapter + 1} at {minutes}m {seconds}s") + else: + print(f"Resuming from chapter {self.currentChapter + 1}, paragraph {self.currentParagraph + 1}") else: - print(f"Resuming from chapter {self.currentChapter + 1}, paragraph {self.currentParagraph + 1}") - else: - print("Starting from beginning") - self.savedAudioPosition = 0.0 + print("Starting from beginning") + self.savedAudioPosition = 0.0 def read_current_paragraph(self): """Read the current paragraph aloud""" @@ -370,11 +412,24 @@ class BookReader: # Upload progress to server success = self.absClient.update_progress(serverId, currentTime, duration, progress) if success: - print(f"Progress synced to server: {int(progress * 100)}%") + print(f"Progress synced to server: {progress * 100:.1f}%") # Also sync session if active if self.sessionId: - self.absClient.sync_session(self.sessionId, currentTime, duration, progress) + syncSuccess = self.absClient.sync_session(self.sessionId, currentTime, duration, progress) + if syncSuccess: + # Update session in server link to persist it + if self.bookPath: + self.serverLinkManager.update_session(str(self.bookPath), self.sessionId) + else: + # Session sync failed - might be expired, create new one + print(f"Session sync failed, creating new session...") + newSessionId = self.absClient.create_session(serverId) + if newSessionId: + self.sessionId = newSessionId + if self.bookPath: + self.serverLinkManager.update_session(str(self.bookPath), self.sessionId) + print(f"Created new session: {self.sessionId}") def reload_tts_engine(self): """Reload TTS engine with current config settings""" @@ -597,8 +652,16 @@ class BookReader: # Start next chapter self._start_paragraph_playback() elif readerEngine == 'piper': - # Check piper-tts / pygame player state - playbackFinished = not self.audioPlayer.is_playing() and not self.audioPlayer.is_paused() + # Check piper-tts subprocess state + # The TTS mpv process runs independently, so we need to check if it's still running + playbackFinished = False + if self.ttsMpvProcess: + # Check if the mpv subprocess has finished + if self.ttsMpvProcess.poll() is not None: + playbackFinished = True + else: + # No process exists, consider playback finished + playbackFinished = True if playbackFinished: # Current paragraph finished, advance @@ -633,6 +696,7 @@ class BookReader: # Debug: Print memory usage every 10 seconds try: import resource + # pylint: disable=no-member memUsage = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024 # MB print(f"DEBUG: Memory usage: {memUsage:.1f} MB") @@ -651,6 +715,9 @@ class BookReader: except KeyboardInterrupt: print("\n\nInterrupted") finally: + # Save bookmark BEFORE stopping (so we can get current position) + self.save_bookmark(speakFeedback=False) + # Stop playback readerEngine = self.config.get_reader_engine() if readerEngine == 'speechd': @@ -658,9 +725,6 @@ class BookReader: else: self.audioPlayer.stop() - # Save bookmark - self.save_bookmark(speakFeedback=False) - # Close Audiobookshelf session if active if self.sessionId and self.absClient: try: @@ -891,7 +955,7 @@ class BookReader: elif event.key == pygame.K_h: # Help - self.speechEngine.speak("SPACE: play pause. n: next paragraph. p: previous paragraph. Shift N: next chapter. Shift P: previous chapter. 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. t: time remaining. h: help. q: quit or sleep timer") + self.speechEngine.speak("SPACE: play pause. n: next paragraph. p: previous paragraph. Shift N: next chapter. Shift P: previous chapter. 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. t: time remaining. h: help. q: quit or sleep timer") elif event.key == pygame.K_i: if not self.book: @@ -921,6 +985,39 @@ class BookReader: # Open sleep timer menu self.sleepTimerMenu.enter_menu() + elif event.key == pygame.K_RIGHTBRACKET: + # Increase playback speed (works for all books!) + if self.book: + currentSpeed = self.config.get_playback_speed() + newSpeed = min(2.0, currentSpeed + 0.1) + newSpeed = round(newSpeed, 1) # Round to 1 decimal place + self._change_playback_speed(newSpeed) + else: + self.speechEngine.speak("No book loaded") + + elif event.key == pygame.K_LEFTBRACKET: + # Decrease playback speed (works for all books!) + if self.book: + currentSpeed = self.config.get_playback_speed() + newSpeed = max(0.5, currentSpeed - 0.1) + newSpeed = round(newSpeed, 1) # Round to 1 decimal place + self._change_playback_speed(newSpeed) + else: + self.speechEngine.speak("No book loaded") + + elif event.key == pygame.K_BACKSPACE: + # Reset playback speed to 1.0 + # Only if not in a menu (menus handle backspace themselves) + if not (self.optionsMenu.is_in_menu() or + self.bookSelector.is_in_browser() or + self.sleepTimerMenu.is_in_menu() or + self.recentBooksMenu.is_in_menu() or + (self.absMenu and self.absMenu.is_in_menu())): + if self.book: + self._change_playback_speed(1.0) + else: + self.speechEngine.speak("No book loaded") + def _handle_recent_books_key(self, event): """Handle key events when in recent books menu""" if event.key == pygame.K_UP: @@ -1327,16 +1424,39 @@ class BookReader: self.config.set_last_book(f"abs://{serverId}") print(f"DEBUG: Saved last_book as: abs://{serverId}") - # Create listening session - self.sessionId = self.absClient.create_session(serverId) - if self.sessionId: - print(f"Created listening session: {self.sessionId}") + # Create listening session (only if we don't already have one from resume) + if not self.sessionId: + self.sessionId = self.absClient.create_session(serverId) + if self.sessionId: + print(f"Created listening session: {self.sessionId}") + else: + print(f"Using existing session ID: {self.sessionId}") + + # Save session and server metadata to server link manager + # This allows resuming the stream with the same session + self.serverLinkManager.create_link( + bookPath=streamUrl, + serverUrl=self.absClient.serverUrl, + serverId=serverId, + libraryId=serverBook.get('libraryId', ''), + title=title, + author=author, + duration=duration, + chapters=len(book.chapters), + sessionId=self.sessionId, + serverBook=serverBook + ) # Try to load progress from server serverProgress = self.absClient.get_progress(serverId) if serverProgress: progressTime = serverProgress.get('currentTime', 0.0) - print(f"Resuming from server progress: {int(progressTime)}s") + minutes = int(progressTime // 60) + seconds = int(progressTime % 60) + print(f"Resuming from server progress: {minutes}m {seconds}s ({progressTime:.1f}s)") + + # Save the exact position for playback resume + self.savedAudioPosition = progressTime # Find chapter that contains this time for i, chap in enumerate(book.chapters): @@ -1348,20 +1468,23 @@ class BookReader: else: # No server progress, start from beginning self.currentChapter = 0 + self.savedAudioPosition = 0.0 # Initialize position self.currentParagraph = 0 - # Load stream URL directly - pygame_player will handle caching via ffmpeg + # Load stream URL directly - mpv will handle streaming natively print(f"Loading stream: {streamUrl[:80]}...") self.speechEngine.speak("Loading stream. This may take a moment.") - # Load the stream URL - pygame_player will cache it using ffmpeg - # Pass auth token so ffmpeg can authenticate - if not self.audioPlayer.load_audio_file(streamUrl, authToken=self.absClient.authToken): + # Load the stream URL - mpv handles streaming with auth headers + # Pass auth token for authentication + # Use saved playback speed from config + playbackSpeed = self.config.get_playback_speed() + if not self.audioPlayer.load_audio_file(streamUrl, authToken=self.absClient.authToken, playbackSpeed=playbackSpeed): self.speechEngine.speak("Failed to load stream. Check terminal for errors.") print("\nERROR: Failed to load stream from server") - print("Make sure ffmpeg is installed: sudo pacman -S ffmpeg") + print("Make sure mpv is installed: sudo pacman -S mpv") return # Success! Start playing @@ -1373,15 +1496,23 @@ class BookReader: if self.absMenu: self.absMenu.exit_menu() - # Update UI if enabled - if self.config.get_show_text(): + # Update UI if enabled (only if screen is initialized) + if self.config.get_show_text() and hasattr(self, 'screen') and self.screen: self._render_screen() - # Start playback - self.audioPlayer.play_audio_file() + # Start playback from saved position (if any) + startPos = self.savedAudioPosition if self.savedAudioPosition > 0 else 0.0 + if startPos > 0: + minutes = int(startPos // 60) + seconds = int(startPos % 60) + print(f"Seeking to {minutes}m {seconds}s...") + self.audioPlayer.play_audio_file(startPosition=startPos) self.isPlaying = True self.isAudioBook = True + # Clear saved position after using it + self.savedAudioPosition = 0.0 + def _download_audiobook(self, serverBook): """ Download audiobook from Audiobookshelf server @@ -1496,6 +1627,39 @@ class BookReader: self.absMenu.exit_menu() self._load_new_book(str(outputPath)) + def _change_playback_speed(self, newSpeed): + """ + Change audio playback speed (instant with mpv!) + + Args: + newSpeed: New playback speed (0.5 to 2.0) + """ + # Clamp speed to valid range + newSpeed = max(0.5, min(2.0, float(newSpeed))) + + # Save to config + self.config.set_playback_speed(newSpeed) + + # Apply speed change based on reader engine and book type + readerEngine = self.config.get_reader_engine() + isAudioBook = self.book and hasattr(self.book, 'isAudioBook') and self.book.isAudioBook + + if isAudioBook: + # Audio books: instant speed change via MpvPlayer + self.audioPlayer.set_speed(newSpeed) + elif readerEngine == 'piper' and self.isPlaying: + # Piper-TTS: restart current paragraph with new speed + # Stop current subprocess + if self.ttsMpvProcess and self.ttsMpvProcess.poll() is None: + self.ttsMpvProcess.terminate() + self.ttsMpvProcess.wait(timeout=0.5) + # Restart playback of current paragraph + self._start_paragraph_playback() + + # Speak feedback + speedPercent = int(newSpeed * 100) + self.speechEngine.speak(f"Speed {speedPercent} percent") + def _load_new_book(self, bookPath): """ Load a new book from file path @@ -1548,6 +1712,10 @@ class BookReader: else: # Stop piper-tts playback and cancel buffering self._cancel_buffer() + # Terminate the TTS mpv subprocess if it's running + if self.ttsMpvProcess and self.ttsMpvProcess.poll() is None: + self.ttsMpvProcess.terminate() + self.ttsMpvProcess.wait(timeout=1) self.audioPlayer.stop() def _start_paragraph_playback(self): @@ -1603,11 +1771,65 @@ class BookReader: wavData = self.ttsEngine.text_to_wav_data(paragraph) if wavData: - self.audioPlayer.play_wav_data(wavData) + # Stop any existing TTS mpv process + if self.ttsMpvProcess and self.ttsMpvProcess.poll() is None: + self.ttsMpvProcess.terminate() + self.ttsMpvProcess.wait() + + # Get audio parameters from TTS engine + audioParams = self.ttsEngine.get_audio_params() + sampleRate = audioParams['sampleRate'] + sampleWidth = audioParams['sampleWidth'] + channels = audioParams['channels'] + + # Determine mpv audio format string + # piper-tts outputs 16-bit signed PCM + mpvAudioFormat = 's16' # 16-bit signed integer + if channels == 2: # Stereo + mpvAudioFormat += 'le' # Little-endian (default for WAV) + + # Launch mpv subprocess to read from stdin + # Get current playback speed from config + playbackSpeed = self.config.get_playback_speed() + mpvCmd = [ + 'mpv', + '--no-terminal', + f'--speed={playbackSpeed}', + '--', '-' + ] + + self.ttsMpvProcess = subprocess.Popen( + mpvCmd, + stdin=subprocess.PIPE + ) + + # Write WAV data to mpv's stdin in a separate thread + def write_mpv_stdin(process, data): + try: + process.stdin.write(data) + process.stdin.flush() + process.stdin.close() + # Explicitly delete data to free memory immediately + del data + except Exception as e: + print(f"Error writing WAV data to mpv stdin: {e}") + finally: + # Wait for mpv to finish and clean up the process + try: + process.wait() + except: + pass + + threading.Thread( + target=write_mpv_stdin, + args=(self.ttsMpvProcess, wavData), + daemon=True + ).start() + # Explicitly delete wavData after playback starts to free memory - # (pygame.mixer.Sound has already copied it) del wavData wavData = None + # Start buffering next paragraph in background self._buffer_next_paragraph() else: @@ -1618,9 +1840,8 @@ class BookReader: self.isPlaying = False raise finally: - # Ensure wavData is freed even on error - if wavData is not None: - del wavData + # The variable is already set to None in all relevant paths + pass def _start_audio_chapter_playback(self, chapter): """Start playing audio book chapter""" @@ -1694,37 +1915,42 @@ class BookReader: def buffer_thread(): """Background thread to generate audio""" - wavData = None + _wavData_to_cleanup = None try: # Generate audio - wavData = self.ttsEngine.text_to_wav_data(paragraph) + wavData_generated = self.ttsEngine.text_to_wav_data(paragraph) + _wavData_to_cleanup = wavData_generated # Check if cancelled if self.cancelBuffer: - # Clean up if cancelled - if wavData: - del wavData + del _wavData_to_cleanup + _wavData_to_cleanup = None return # Store buffered audio with self.bufferLock: if not self.cancelBuffer: - self.bufferedAudio = wavData - wavData = None # Transfer ownership, don't delete + self.bufferedAudio = _wavData_to_cleanup + _wavData_to_cleanup = None # Transfer ownership except Exception as e: print(f"Error buffering paragraph: {e}") - # Clear buffer state on error to prevent stalls with self.bufferLock: self.bufferedAudio = None + if _wavData_to_cleanup is not None: + del _wavData_to_cleanup + _wavData_to_cleanup = None finally: - # Clean up wavData if not transferred to bufferedAudio - if wavData is not None: - del wavData + # The variable is already set to None in all relevant paths + pass # Clear any cancelled buffer state with self.bufferLock: self.cancelBuffer = False + # Clean up previous buffer thread reference before starting new one + if self.bufferThread and not self.bufferThread.is_alive(): + self.bufferThread = None + # Start new buffer thread self.bufferThread = threading.Thread(target=buffer_thread, daemon=True) self.bufferThread.start() @@ -1755,9 +1981,16 @@ class BookReader: if self.sessionId and self.absClient: print(f"Closing listening session: {self.sessionId}") self.absClient.close_session(self.sessionId) + # Clear session from link metadata + if self.bookPath: + self.serverLinkManager.clear_session(str(self.bookPath)) self.sessionId = None self._cancel_buffer() + # Terminate the TTS mpv subprocess if it's running + if self.ttsMpvProcess and self.ttsMpvProcess.poll() is None: + self.ttsMpvProcess.terminate() + self.ttsMpvProcess.wait(timeout=1) self.audioPlayer.cleanup() self.speechEngine.cleanup() if self.readingEngine: @@ -1814,53 +2047,117 @@ def main(): serverId = lastBook[6:] # Remove 'abs://' prefix print(f"Resuming Audiobookshelf book: {serverId}") - # Try to reconnect and stream - print(f"Last book was an Audiobookshelf stream") + # Try to restore from cached server link + from src.server_link_manager import ServerLinkManager + serverLinkManager = ServerLinkManager() - # Start BookStorm even if stream fails - user can browse/select - bookPathFallback = None # Will trigger interactive mode if stream fails - - if config.is_abs_configured(): + # Try to find cached server book metadata by server ID + # The bookPath in the link will be the stream URL from last session + cachedLink = None + for sidecarPath in (Path.home() / ".bookstorm" / "server_links").glob("*.json"): try: - # Initialize Audiobookshelf client - from src.audiobookshelf_client import AudiobookshelfClient - serverUrl = config.get_abs_server_url() - absClient = AudiobookshelfClient(serverUrl, config) + import json + with open(sidecarPath, 'r') as f: + linkData = json.load(f) + if linkData.get('server_id') == serverId: + cachedLink = linkData + break + except: + continue - if absClient.is_authenticated() and absClient.test_connection(): - # Get book details - print(f"Fetching book details from server...") - bookDetails = absClient.get_library_item_details(serverId) + if cachedLink and cachedLink.get('server_book'): + # We have cached metadata - try to resume using it + print(f"Found cached server book metadata, resuming stream...") + + if config.is_abs_configured(): + try: + # Initialize Audiobookshelf client + from src.audiobookshelf_client import AudiobookshelfClient + serverUrl = config.get_abs_server_url() + absClient = AudiobookshelfClient(serverUrl, config) + + if absClient.is_authenticated() and absClient.test_connection(): + # Use cached server book metadata instead of re-fetching + serverBook = cachedLink['server_book'] + print(f"Restoring from cached metadata...") - if bookDetails: - # Successfully got book details - try to stream - print(f"Found book on server, preparing to stream...") try: from src.speech_engine import SpeechEngine speechEngine = SpeechEngine() reader = BookReader(None, config) reader.absClient = absClient - reader._stream_audiobook(bookDetails) + + # Restore session ID if it was saved + savedSessionId = cachedLink.get('session_id') + if savedSessionId: + print(f"Restoring listening session: {savedSessionId}") + reader.sessionId = savedSessionId + + reader._stream_audiobook(serverBook) reader.run_interactive() return 0 except Exception as e: print(f"Error resuming stream: {e}") + import traceback + traceback.print_exc() print("Opening BookStorm anyway - use 'a' to browse server or 'b' for local books") else: - print(f"Book not found on server (may have been deleted)") - print("Opening BookStorm anyway - use 'a' to browse server or 'b' for local books") - else: - print("Cannot connect to Audiobookshelf server or session expired") - print("Opening BookStorm anyway - use 'a' to reconnect or 'b' for local books") + print("Cannot connect to Audiobookshelf server or session expired") + print("Opening BookStorm anyway - use 'a' to reconnect or 'b' for local books") - except Exception as e: - print(f"Error connecting to Audiobookshelf: {e}") - print("Opening BookStorm anyway - use 'a' to reconnect or 'b' for local books") + except Exception as e: + print(f"Error connecting to Audiobookshelf: {e}") + print("Opening BookStorm anyway - use 'a' to reconnect or 'b' for local books") + else: + print("Audiobookshelf not configured") + print("Opening BookStorm anyway - use 'o' to configure server or 'b' for local books") else: - print("Audiobookshelf not configured") - print("Opening BookStorm anyway - use 'o' to configure server or 'b' for local books") + # No cached metadata - try fetching from server + print(f"No cached metadata, fetching from server...") + + if config.is_abs_configured(): + try: + # Initialize Audiobookshelf client + from src.audiobookshelf_client import AudiobookshelfClient + serverUrl = config.get_abs_server_url() + absClient = AudiobookshelfClient(serverUrl, config) + + if absClient.is_authenticated() and absClient.test_connection(): + # Get book details + print(f"Fetching book details from server...") + bookDetails = absClient.get_library_item_details(serverId) + + if bookDetails: + # Successfully got book details - try to stream + print(f"Found book on server, preparing to stream...") + try: + from src.speech_engine import SpeechEngine + speechEngine = SpeechEngine() + + reader = BookReader(None, config) + reader.absClient = absClient + reader._stream_audiobook(bookDetails) + reader.run_interactive() + return 0 + + except Exception as e: + print(f"Error resuming stream: {e}") + print("Opening BookStorm anyway - use 'a' to browse server or 'b' for local books") + else: + print(f"Book not found on server (may have been deleted)") + print("Opening BookStorm anyway - use 'a' to browse server or 'b' for local books") + else: + print("Cannot connect to Audiobookshelf server or session expired") + print("Opening BookStorm anyway - use 'a' to reconnect or 'b' for local books") + + except Exception as e: + print(f"Error connecting to Audiobookshelf: {e}") + print("Opening BookStorm anyway - use 'a' to reconnect or 'b' for local books") + else: + print("Audiobookshelf not configured") + print("Opening BookStorm anyway - use 'o' to configure server or 'b' for local books") # Fall through to open BookStorm in interactive mode # Clear last_book so we don't loop on this error diff --git a/src/config_manager.py b/src/config_manager.py index b13d693..8fd077f 100644 --- a/src/config_manager.py +++ b/src/config_manager.py @@ -56,6 +56,10 @@ class ConfigManager: 'show_text': 'true' } + self.config['Audio'] = { + 'playback_speed': '1.0' + } + self.config['Paths'] = { 'last_book': '', 'books_directory': str(Path.home()), @@ -306,3 +310,19 @@ class ConfigManager: serverUrl = self.get_abs_server_url() username = self.get_abs_username() return bool(serverUrl and username) + + def get_playback_speed(self): + """Get audio playback speed (0.5 to 2.0)""" + try: + speed = float(self.get('Audio', 'playback_speed', '1.0')) + # Clamp to valid range + return max(0.5, min(2.0, speed)) + except ValueError: + return 1.0 + + def set_playback_speed(self, speed): + """Set audio playback speed (0.5 to 2.0)""" + # Clamp to valid range + speed = max(0.5, min(2.0, float(speed))) + self.set('Audio', 'playback_speed', str(speed)) + self.save() diff --git a/src/mpv_player.py b/src/mpv_player.py new file mode 100644 index 0000000..dd89370 --- /dev/null +++ b/src/mpv_player.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +MPV Audio Player + +Audio playback using python-mpv for both TTS and audio books. +Supports real-time speed control without re-encoding. +""" + +import os +from pathlib import Path +import threading + +try: + import mpv + HAS_MPV = True +except ImportError: + HAS_MPV = False + + +class MpvPlayer: + """Audio player using mpv for all playback""" + + def __init__(self): + """Initialize mpv audio player""" + self.isInitialized = False + self.player = None + self.isPaused = False + self.audioFileLoaded = False # Track if audio file is loaded + self.playbackSpeed = 1.0 # Current playback speed + self.endFileCallback = None # Callback for when file finishes + + if not HAS_MPV: + print("Warning: python-mpv not installed. Audio playback will not work.") + print("Install with: pip install python-mpv") + return + + try: + # Initialize mpv player + # pylint: disable=no-member + self.player = mpv.MPV( + input_default_bindings=False, # Disable default key bindings + input_vo_keyboard=False, # Disable keyboard input + video=False, # Audio only + ytdl=False, # Don't use youtube-dl + quiet=True, # Minimal console output + ) + + # Register event handler for cleanup + @self.player.event_callback('end-file') + def on_end_file(event): + """Called when file finishes playing""" + # Call user callback if set + if self.endFileCallback: + self.endFileCallback() + + self.isInitialized = True + + except Exception as e: + print(f"Warning: Could not initialize mpv: {e}") + self.isInitialized = False + + def play_wav_data(self, wavData): + """ + This method is no longer used for TTS playback. + TTS playback is now handled directly by BookReader using subprocess. + """ + print("Warning: MpvPlayer.play_wav_data is deprecated and should not be called.") + return False + + + def pause(self): + """Pause playback""" + if self.isInitialized and self.player: + self.player.pause = True + self.isPaused = True + + def resume(self): + """Resume playback""" + if self.isInitialized and self.player: + self.player.pause = False + self.isPaused = False + + def stop(self): + """Stop playback""" + if self.isInitialized and self.player: + self.player.stop() + self.isPaused = False + + def is_playing(self): + """Check if audio is currently playing""" + if not self.isInitialized or not self.player: + return False + try: + # mpv is playing if not paused and not idle + # pylint: disable=no-member + return not self.player.pause and not self.player.idle_active + except: + return False + + def is_paused(self): + """Check if audio is paused""" + return self.isPaused + + def set_speed(self, speed): + """ + Set playback speed + + Args: + speed: Playback speed (0.5 to 2.0) + """ + if not self.isInitialized or not self.player: + return + + # Clamp speed to valid range + speed = max(0.5, min(2.0, float(speed))) + self.playbackSpeed = speed + self.player.speed = speed + + def get_speed(self): + """Get current playback speed""" + return self.playbackSpeed + + def cleanup(self): + """Cleanup resources""" + if self.isInitialized and self.player: + try: + self.player.stop() + except: + pass + try: + self.player.terminate() + except: + pass + self.isInitialized = False + + def is_available(self): + """Check if mpv is available""" + return self.isInitialized + + # Audio file playback methods (for audiobooks) + + def load_audio_file(self, audioPath, authToken=None, playbackSpeed=1.0): + """ + Load an audio file for streaming playback + + Args: + audioPath: Path to audio file or URL + authToken: Optional Bearer token for authenticated URLs + playbackSpeed: Playback speed (0.5 to 2.0, default 1.0) + + Returns: + True if loaded successfully + """ + if not self.isInitialized: + return False + + try: + audioPath = str(audioPath) + self.playbackSpeed = max(0.5, min(2.0, float(playbackSpeed))) + + # Check if this is a URL (for streaming from Audiobookshelf) + isUrl = audioPath.startswith('http://') or audioPath.startswith('https://') + + if isUrl and authToken: + # Load with authentication header + # pylint: disable=no-member + self.player.http_header_fields = [f'Authorization: Bearer {authToken}'] + else: + # Clear any previous headers + # pylint: disable=no-member + self.player.http_header_fields = [] + + # Load the file (mpv handles all formats natively) + self.player.loadfile(audioPath, 'replace') + self.player.pause = True # Keep paused until play_audio_file() is called + self.player.speed = self.playbackSpeed + self.audioFileLoaded = True + return True + + except Exception as e: + print(f"Error loading audio file: {e}") + self.audioFileLoaded = False + return False + + def play_audio_file(self, startPosition=0.0): + """ + Play loaded audio file from a specific position + + Args: + startPosition: Start time in seconds + + Returns: + True if playback started successfully + """ + if not self.isInitialized or not self.audioFileLoaded: + return False + + try: + # Seek to start position + if startPosition > 0: + self.player.seek(startPosition, reference='absolute') + + # Start playback + self.player.pause = False + self.isPaused = False + return True + + except Exception as e: + print(f"Error playing audio file: {e}") + return False + + def pause_audio_file(self): + """Pause audio file playback""" + if self.isInitialized and self.audioFileLoaded: + self.player.pause = True + self.isPaused = True + + def resume_audio_file(self): + """Resume audio file playback""" + if self.isInitialized and self.audioFileLoaded: + self.player.pause = False + self.isPaused = False + + def stop_audio_file(self): + """Stop audio file playback""" + if self.isInitialized and self.audioFileLoaded: + try: + self.player.stop() + except: + pass + self.isPaused = False + + def is_audio_file_playing(self): + """Check if audio file is currently playing""" + if not self.isInitialized or not self.audioFileLoaded: + return False + return self.is_playing() + + def get_audio_position(self): + """ + Get current playback position in seconds + + Returns: + Position in seconds, or 0.0 if not playing + """ + if not self.isInitialized or not self.audioFileLoaded: + return 0.0 + + try: + # pylint: disable=no-member + pos = self.player.time_pos + return pos if pos is not None else 0.0 + except: + return 0.0 + + def seek_audio(self, position): + """ + Seek to a specific position in the audio file + + Args: + position: Position in seconds + + Returns: + True if seek successful + """ + if not self.isInitialized or not self.audioFileLoaded: + return False + + try: + self.player.seek(position, reference='absolute') + return True + except Exception as e: + print(f"Error seeking audio: {e}") + return False + + def unload_audio_file(self): + """Unload the current audio file""" + if self.audioFileLoaded: + self.stop_audio_file() + self.audioFileLoaded = False + + def is_audio_file_loaded(self): + """Check if audio file is loaded""" + return self.audioFileLoaded + + def set_end_file_callback(self, callback): + """ + Set callback to be called when file finishes playing + + Args: + callback: Function to call (no arguments) + """ + self.endFileCallback = callback \ No newline at end of file diff --git a/src/options_menu.py b/src/options_menu.py index b07390a..b209e8c 100644 --- a/src/options_menu.py +++ b/src/options_menu.py @@ -22,7 +22,7 @@ class OptionsMenu: config: ConfigManager instance speechEngine: SpeechEngine instance voiceSelector: VoiceSelector instance - audioPlayer: PygamePlayer instance + audioPlayer: MpvPlayer instance ttsReloadCallback: Optional callback to reload TTS engine """ self.config = config diff --git a/src/pygame_player.py b/src/pygame_player.py deleted file mode 100644 index 53d48bb..0000000 --- a/src/pygame_player.py +++ /dev/null @@ -1,519 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -Pygame Audio Player - -Audio playback using pygame.mixer with integrated event handling. -Simpler and more reliable than PyAudio approach. -""" - -import io -import pygame - - -class PygamePlayer: - """Audio player using pygame.mixer""" - - def __init__(self): - """Initialize pygame audio player""" - self.isInitialized = False - self.isPaused = False - self.currentSound = None # Track current sound for cleanup - self.audioFileLoaded = False # Track if audio file is loaded - self.audioFilePath = None # Current audio file path - self.tempAudioFile = None # Temporary transcoded audio file - - try: - # Initialize pygame mixer only (not full pygame) - # Use only 1 channel to force immediate cleanup of old sounds - pygame.mixer.init(frequency=22050, size=-16, channels=1, buffer=512) - pygame.mixer.set_num_channels(1) # Limit to 1 channel for sequential playback - self.isInitialized = True - except Exception as e: - print(f"Warning: Could not initialize pygame mixer: {e}") - self.isInitialized = False - - def play_wav_data(self, wavData): - """ - Play WAV audio data - - Args: - wavData: Bytes containing WAV audio data - - Returns: - True if playback started successfully - """ - if not self.isInitialized: - return False - - try: - # Cleanup previous sound to prevent memory leak - if self.currentSound: - # Explicitly stop to release pygame's internal buffers - # This is safe since we call play_wav_data only when ready for next paragraph - self.currentSound.stop() - del self.currentSound - self.currentSound = None - - # Load WAV data from bytes - # CRITICAL: Must close BytesIO after Sound is created to prevent memory leak - wavBuffer = io.BytesIO(wavData) - try: - sound = pygame.mixer.Sound(wavBuffer) - finally: - # Close BytesIO buffer immediately - pygame.mixer.Sound copies the data - wavBuffer.close() - del wavBuffer # Explicitly delete - - # Play the sound and keep reference for cleanup - sound.play() - self.currentSound = sound - self.isPaused = False - return True - - except Exception as e: - print(f"Error playing audio: {e}") - return False - - def pause(self): - """Pause playback""" - if self.isInitialized: - pygame.mixer.pause() - self.isPaused = True - - def resume(self): - """Resume playback""" - if self.isInitialized: - pygame.mixer.unpause() - self.isPaused = False - - def stop(self): - """Stop playback""" - if self.isInitialized: - pygame.mixer.stop() - self.isPaused = False - # Cleanup current sound reference - if self.currentSound: - del self.currentSound - self.currentSound = None - - def is_playing(self): - """Check if audio is currently playing""" - if not self.isInitialized: - return False - return pygame.mixer.get_busy() - - def is_paused(self): - """Check if audio is paused""" - return self.isPaused - - def cleanup(self): - """Cleanup resources""" - # Clean up audio file playback state - if self.audioFileLoaded: - # Note: We don't delete cached files - they're kept for future use - self.tempAudioFile = None - self.audioFileLoaded = False - self.audioFilePath = None - - if self.isInitialized: - # Stop and cleanup current sound - if self.currentSound: - try: - self.currentSound.stop() - except Exception: - pass # Mixer may be shutting down - del self.currentSound - self.currentSound = None - pygame.mixer.quit() - self.isInitialized = False - - def is_available(self): - """Check if pygame mixer is available""" - return self.isInitialized - - # Audio file playback methods (for audiobooks) - - def load_audio_file(self, audioPath, authToken=None): - """ - Load an audio file for streaming playback - - Args: - audioPath: Path to audio file or URL - authToken: Optional Bearer token for authenticated URLs - - Returns: - True if loaded successfully - """ - if not self.isInitialized: - return False - - from pathlib import Path - audioPath = str(audioPath) # Ensure it's a string - - # Check if this is a URL (for streaming from Audiobookshelf) - isUrl = audioPath.startswith('http://') or audioPath.startswith('https://') - - if isUrl: - # Use ffmpeg for streaming from URLs - print(f"DEBUG: Loading URL for streaming") - return self._load_url_with_ffmpeg(audioPath, authToken=authToken) - - # Local file - use existing logic - fileSuffix = Path(audioPath).suffix.lower() - - try: - # Stop any current playback and clean up temp files - self.stop_audio_file() - - # Try to load audio file directly using pygame.mixer.music - pygame.mixer.music.load(audioPath) - self.audioFileLoaded = True - self.audioFilePath = audioPath - return True - - except Exception as e: - print(f"Direct load failed: {e}") - - # Try transcoding with ffmpeg if direct load failed - if "ModPlug_Load failed" in str(e) or "Unrecognized" in str(e): - print(f"Attempting to transcode {fileSuffix} with ffmpeg...") - return self._load_with_ffmpeg_transcode(audioPath) - - # Unknown error - print(f"Error loading audio file: {e}") - self.audioFileLoaded = False - return False - - def _load_url_with_ffmpeg(self, streamUrl, authToken=None): - """ - Stream from URL using ffmpeg to transcode to cache - - Args: - streamUrl: URL to stream from (e.g., Audiobookshelf URL) - authToken: Optional Bearer token for authentication - - Returns: - True if successful - """ - import subprocess - import shutil - import hashlib - from pathlib import Path - - # Check if ffmpeg is available - if not shutil.which('ffmpeg'): - print("\nffmpeg not found. Falling back to direct download...") - print("Install ffmpeg for better streaming: sudo pacman -S ffmpeg") - return False - - # Set up cache directory - cacheDir = Path.home() / '.cache' / 'bookstorm' / 'audiobookshelf' - cacheDir.mkdir(parents=True, exist_ok=True) - - # Generate cache filename from hash of URL (without token for consistency) - # Extract base URL without token parameter - baseUrl = streamUrl.split('?')[0] if '?' in streamUrl else streamUrl - urlHash = hashlib.sha256(baseUrl.encode()).hexdigest()[:16] - cachedPath = cacheDir / f"{urlHash}.ogg" - - # Check if cached version exists - if cachedPath.exists(): - print(f"\nUsing cached stream") - try: - pygame.mixer.music.load(str(cachedPath)) - self.audioFileLoaded = True - self.audioFilePath = streamUrl - self.tempAudioFile = str(cachedPath) - print("Cached file loaded! Starting playback...") - return True - except Exception as e: - print(f"Cached file corrupted, re-downloading: {e}") - cachedPath.unlink(missing_ok=True) - - # No cache available, stream and transcode - try: - print(f"\nStreaming from server...") - print("Transcoding to cache. This will take a moment.") - print(f"(Cached for future use in {cacheDir})\n") - - # Build ffmpeg command with authentication headers if token provided - ffmpegCmd = ['ffmpeg'] - - # Add authentication header for Audiobookshelf - if authToken: - # ffmpeg needs headers in the format "Name: Value\r\n" - authHeader = f"Authorization: Bearer {authToken}" - ffmpegCmd.extend(['-headers', authHeader]) - print(f"DEBUG: Using Bearer token authentication") - - ffmpegCmd.extend([ - '-i', streamUrl, - '-vn', # No video - '-c:a', 'libvorbis', - '-q:a', '4', # Medium quality - '-threads', '0', # Use all CPU cores - '-y', - str(cachedPath) - ]) - - print(f"DEBUG: ffmpeg command: {' '.join(ffmpegCmd[:6])}...") - - # Run ffmpeg with progress output - result = subprocess.run( - ffmpegCmd, - capture_output=False, # Show progress to user - text=True, - timeout=1800 # 30 minute timeout for large audiobooks - ) - - if result.returncode != 0: - print(f"\nStreaming/transcoding failed (exit code {result.returncode})") - cachedPath.unlink(missing_ok=True) - return False - - # Try to load the transcoded file - try: - pygame.mixer.music.load(str(cachedPath)) - self.audioFileLoaded = True - self.audioFilePath = streamUrl # Keep URL for reference - self.tempAudioFile = str(cachedPath) - print("\nStream cached successfully!") - print("Starting playback...") - return True - - except Exception as e: - print(f"Error loading transcoded stream: {e}") - cachedPath.unlink(missing_ok=True) - return False - - except subprocess.TimeoutExpired: - print("\nStreaming timed out (file too large or connection too slow)") - cachedPath.unlink(missing_ok=True) - return False - except KeyboardInterrupt: - print("\nStreaming cancelled by user") - cachedPath.unlink(missing_ok=True) - return False - except Exception as e: - print(f"Error during streaming: {e}") - cachedPath.unlink(missing_ok=True) - return False - - def _load_with_ffmpeg_transcode(self, audioPath, fastMode=False): - """ - Transcode audio file using ffmpeg and load the result - - Args: - audioPath: Path to original audio file - fastMode: If True, use faster/lower quality settings - - Returns: - True if successful - """ - import subprocess - import shutil - import hashlib - from pathlib import Path - - # Check if ffmpeg is available - if not shutil.which('ffmpeg'): - print("\nffmpeg not found. Please install ffmpeg or convert the file manually:") - print(f" ffmpeg -i '{audioPath}' -c:a libmp3lame -q:a 2 output.mp3") - return False - - # Set up persistent cache directory - cacheDir = Path.home() / '.cache' / 'bookstorm' / 'audio' - cacheDir.mkdir(parents=True, exist_ok=True) - - # Generate cache filename from hash of original file path - pathHash = hashlib.sha256(str(Path(audioPath).resolve()).encode()).hexdigest()[:16] - cachedPath = cacheDir / f"{pathHash}.ogg" - - # Check if cached version exists - if cachedPath.exists(): - print(f"\nUsing cached transcoded file for {Path(audioPath).name}") - try: - pygame.mixer.music.load(str(cachedPath)) - self.audioFileLoaded = True - self.audioFilePath = audioPath - self.tempAudioFile = str(cachedPath) - print("Cached file loaded! Starting playback...") - return True - except Exception as e: - print(f"Cached file corrupted, re-transcoding: {e}") - cachedPath.unlink(missing_ok=True) - - # No cache available, transcode the file - try: - print(f"\nTranscoding {Path(audioPath).name}...") - print("This will take a moment. Press Ctrl+C to cancel.") - print(f"(Cached for future use in {cacheDir})\n") - - # Build ffmpeg command - if fastMode: - # Fast mode: lower quality, faster encoding - ffmpegCmd = [ - 'ffmpeg', - '-i', audioPath, - '-vn', # No video - '-c:a', 'libvorbis', - '-q:a', '1', # Lower quality (0-10, lower is better) - '-threads', '0', # Use all CPU cores - '-y', - str(cachedPath) - ] - else: - # Normal mode: balanced quality - ffmpegCmd = [ - 'ffmpeg', - '-i', audioPath, - '-vn', - '-c:a', 'libvorbis', - '-q:a', '4', # Medium quality - '-threads', '0', - '-y', - str(cachedPath) - ] - - # Run ffmpeg with progress output - result = subprocess.run( - ffmpegCmd, - capture_output=False, # Show progress to user - text=True, - timeout=600 # 10 minute timeout - ) - - if result.returncode != 0: - print(f"\nTranscoding failed (exit code {result.returncode})") - cachedPath.unlink(missing_ok=True) - return False - - # Try to load the transcoded file - try: - pygame.mixer.music.load(str(cachedPath)) - self.audioFileLoaded = True - self.audioFilePath = audioPath # Keep original path for reference - self.tempAudioFile = str(cachedPath) - print("\nTranscoding complete! Cached for future use.") - print("Starting playback...") - return True - - except Exception as e: - print(f"Error loading transcoded file: {e}") - cachedPath.unlink(missing_ok=True) - return False - - except subprocess.TimeoutExpired: - print("\nTranscoding timed out (file too large or system too slow)") - cachedPath.unlink(missing_ok=True) - return False - except KeyboardInterrupt: - print("\nTranscoding cancelled by user") - cachedPath.unlink(missing_ok=True) - return False - except Exception as e: - print(f"Error during transcoding: {e}") - cachedPath.unlink(missing_ok=True) - return False - - def play_audio_file(self, startPosition=0.0): - """ - Play loaded audio file from a specific position - - Args: - startPosition: Start time in seconds - - Returns: - True if playback started successfully - """ - if not self.isInitialized or not self.audioFileLoaded: - return False - - try: - # Start playback - pygame.mixer.music.play(start=startPosition) - self.isPaused = False - return True - - except Exception as e: - print(f"Error playing audio file: {e}") - return False - - def pause_audio_file(self): - """Pause audio file playback""" - if self.isInitialized and self.audioFileLoaded: - pygame.mixer.music.pause() - self.isPaused = True - - def resume_audio_file(self): - """Resume audio file playback""" - if self.isInitialized and self.audioFileLoaded: - pygame.mixer.music.unpause() - self.isPaused = False - - def stop_audio_file(self): - """Stop audio file playback""" - # Only stop if mixer is initialized - if self.isInitialized and self.audioFileLoaded: - try: - pygame.mixer.music.stop() - except Exception: - pass # Mixer may already be shut down - self.isPaused = False - - # Note: We don't delete tempAudioFile anymore since it's a persistent cache - # The cache files are kept in ~/.cache/bookstorm/audio/ for future use - - def is_audio_file_playing(self): - """Check if audio file is currently playing""" - if not self.isInitialized or not self.audioFileLoaded: - return False - return pygame.mixer.music.get_busy() - - def get_audio_position(self): - """ - Get current playback position in milliseconds - - Returns: - Position in milliseconds, or 0.0 if not playing - """ - if not self.isInitialized or not self.audioFileLoaded: - return 0.0 - - # pygame.mixer.music.get_pos() returns time in milliseconds - return pygame.mixer.music.get_pos() / 1000.0 - - def seek_audio(self, position): - """ - Seek to a specific position in the audio file - - Args: - position: Position in seconds - - Note: - pygame.mixer.music doesn't support direct seeking. - We need to stop and restart from the position. - """ - if not self.isInitialized or not self.audioFileLoaded: - return False - - try: - # Stop current playback - pygame.mixer.music.stop() - - # Restart from new position - pygame.mixer.music.play(start=position) - self.isPaused = False - return True - - except Exception as e: - print(f"Error seeking audio: {e}") - return False - - def unload_audio_file(self): - """Unload the current audio file""" - if self.audioFileLoaded: - self.stop_audio_file() # This also cleans up temp files - self.audioFileLoaded = False - self.audioFilePath = None diff --git a/src/server_link_manager.py b/src/server_link_manager.py index 793c782..713e640 100644 --- a/src/server_link_manager.py +++ b/src/server_link_manager.py @@ -31,7 +31,8 @@ class ServerLinkManager: def create_link(self, bookPath: str, serverUrl: str, serverId: str, libraryId: str, title: str = "", author: str = "", duration: float = 0.0, - chapters: int = 0, manualOverride: bool = False): + chapters: int = 0, manualOverride: bool = False, sessionId: str = None, + serverBook: Dict = None): """ Create server link for a local book @@ -45,6 +46,8 @@ class ServerLinkManager: duration: Audio duration in seconds chapters: Number of chapters manualOverride: True if user manually linked despite mismatch + sessionId: Active listening session ID (for streaming) + serverBook: Full server book metadata (for streaming) """ bookHash = self._get_book_hash(bookPath) sidecarPath = self.sidecarDir / f"{bookHash}.json" @@ -61,7 +64,9 @@ class ServerLinkManager: 'title': title, 'author': author }, - 'manual_override': manualOverride + 'manual_override': manualOverride, + 'session_id': sessionId, + 'server_book': serverBook } with open(sidecarPath, 'w') as f: @@ -129,3 +134,41 @@ class ServerLinkManager: if sidecarPath.exists(): sidecarPath.unlink() print(f"Deleted server link: {sidecarPath}") + + def update_session(self, bookPath: str, sessionId: str): + """ + Update session ID for an existing link + + Args: + bookPath: Path to book file (or stream URL) + sessionId: New session ID + """ + linkData = self.get_link(bookPath) + if not linkData: + return + + linkData['session_id'] = sessionId + bookHash = self._get_book_hash(bookPath) + sidecarPath = self.sidecarDir / f"{bookHash}.json" + + with open(sidecarPath, 'w') as f: + json.dump(linkData, f, indent=2) + + def clear_session(self, bookPath: str): + """ + Clear session ID from link (when session closed) + + Args: + bookPath: Path to book file (or stream URL) + """ + linkData = self.get_link(bookPath) + if not linkData: + return + + linkData['session_id'] = None + bookHash = self._get_book_hash(bookPath) + sidecarPath = self.sidecarDir / f"{bookHash}.json" + + with open(sidecarPath, 'w') as f: + json.dump(linkData, f, indent=2) + diff --git a/src/speech_engine.py b/src/speech_engine.py index 4f89866..62b3e70 100644 --- a/src/speech_engine.py +++ b/src/speech_engine.py @@ -147,6 +147,7 @@ class SpeechEngine: self.readingCallback('INTERRUPTED') # Speak with callback (event_types is speechd API parameter) + # pylint: disable=no-member self.client.speak( textStr, callback=speech_callback, diff --git a/src/voice_selector.py b/src/voice_selector.py index 8f4a8a0..814644f 100644 --- a/src/voice_selector.py +++ b/src/voice_selector.py @@ -9,7 +9,7 @@ Allows browsing, testing, and selecting voice models. from pathlib import Path from src.tts_engine import TtsEngine -from src.pygame_player import PygamePlayer +from src.mpv_player import MpvPlayer class VoiceSelector: @@ -161,7 +161,7 @@ class VoiceSelector: try: tts = TtsEngine(voice['path']) - player = PygamePlayer() + player = MpvPlayer() print("Generating speech...") wavData = tts.text_to_wav_data(testText)