Conversion to mpv for playback mostly complete.
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user