Conversion to mpv for playback mostly complete.

This commit is contained in:
Storm Dragon
2025-10-08 19:33:29 -04:00
parent 4387a5cb56
commit d19c90e69a
8 changed files with 744 additions and 608 deletions
+20
View File
@@ -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()
+294
View File
@@ -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
+1 -1
View File
@@ -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
-519
View File
@@ -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
+45 -2
View File
@@ -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)
+1
View File
@@ -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,
+2 -2
View File
@@ -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)