Added voice preview caching system

This commit is contained in:
Deniz Şafak
2025-05-22 00:26:27 +03:00
parent 082bb08e48
commit 065eaa4745
3 changed files with 158 additions and 35 deletions
+1
View File
@@ -1,5 +1,6 @@
# v1.0.8 (pre-release) # v1.0.8 (pre-release)
- Added support for AMD GPUs in Linux (Special thanks to @hg000125 for his contribution in #23) - Added support for AMD GPUs in Linux (Special thanks to @hg000125 for his contribution in #23)
- Added voice preview caching system that stores generated previews in the cache folder, mentioned by @jborza in #22
- Added extra metadata support for chaptered M4B files, ensuring better compatibility with audiobook players. - Added extra metadata support for chaptered M4B files, ensuring better compatibility with audiobook players.
- Added new option: `Separate chapters audio format`, allowing to choose between `wav`, `mp4`, `flac` and `opus` formats for chaptered audio files. - Added new option: `Separate chapters audio format`, allowing to choose between `wav`, `mp4`, `flac` and `opus` formats for chaptered audio files.
- Skipping PyTorch CUDA installation if GPU is not NVIDIA in WINDOWS_INSTALL.bat script, preventing unnecessary installation of PyTorch. - Skipping PyTorch CUDA installation if GPU is not NVIDIA in WINDOWS_INSTALL.bat script, preventing unnecessary installation of PyTorch.
+25 -9
View File
@@ -4,6 +4,7 @@ import tempfile
import time import time
import chardet import chardet
import charset_normalizer import charset_normalizer
import hashlib # For generating unique cache filenames
from platformdirs import user_desktop_dir from platformdirs import user_desktop_dir
from PyQt5.QtCore import QThread, pyqtSignal, Qt from PyQt5.QtCore import QThread, pyqtSignal, Qt
from PyQt5.QtWidgets import QCheckBox, QVBoxLayout, QDialog, QLabel, QDialogButtonBox from PyQt5.QtWidgets import QCheckBox, QVBoxLayout, QDialog, QLabel, QDialogButtonBox
@@ -943,11 +944,32 @@ class VoicePreviewThread(QThread):
self.voice = voice self.voice = voice
self.speed = speed self.speed = speed
self.use_gpu = use_gpu self.use_gpu = use_gpu
# Cache location for preview audio
self.cache_dir = os.path.join(tempfile.gettempdir(), PROGRAM_NAME, "preview_cache")
os.makedirs(self.cache_dir, exist_ok=True)
# Calculate cache path
self.cache_path = self._get_cache_path()
def _get_cache_path(self):
"""Generate a unique filename for the voice with its parameters"""
# For a voice formula, use a hash of the formula
if "*" in self.voice:
voice_id = f"voice_formula_{hashlib.md5(self.voice.encode()).hexdigest()[:8]}"
else:
voice_id = self.voice
# Create a unique filename based on voice_id, language, and speed
filename = f"{voice_id}_{self.lang_code}_{self.speed:.2f}.wav"
return os.path.join(self.cache_dir, filename)
def run(self): def run(self):
print( print(
f"\nVoice: {self.voice}\nLanguage: {self.lang_code}\nSpeed: {self.speed}\nGPU: {self.use_gpu}\n" f"\nVoice: {self.voice}\nLanguage: {self.lang_code}\nSpeed: {self.speed}\nGPU: {self.use_gpu}\n"
) )
# Generate the preview and save to cache
try: try:
device = "cuda" if self.use_gpu else "cpu" device = "cuda" if self.use_gpu else "cpu"
tts = self.kpipeline_class( tts = self.kpipeline_class(
@@ -966,15 +988,9 @@ class VoicePreviewThread(QThread):
audio_segments.append(result.audio) audio_segments.append(result.audio)
if audio_segments: if audio_segments:
audio = self.np_module.concatenate(audio_segments) audio = self.np_module.concatenate(audio_segments)
# Create temp wav file in a folder in the system temp directory # Save directly to the cache path
temp_dir = os.path.join(tempfile.gettempdir(), PROGRAM_NAME) sf.write(self.cache_path, audio, 24000)
os.makedirs(temp_dir, exist_ok=True) self.temp_wav = self.cache_path
fd, temp_path = tempfile.mkstemp(
prefix="abogen_", suffix=".wav", dir=temp_dir
)
os.close(fd)
sf.write(temp_path, audio, 24000)
self.temp_wav = temp_path
self.finished.emit() self.finished.emit()
except Exception as e: except Exception as e:
self.error.emit(f"Voice preview error: {str(e)}") self.error.emit(f"Voice preview error: {str(e)}")
+132 -26
View File
@@ -4,6 +4,7 @@ import tempfile
import platform import platform
import base64 import base64
import re import re
import hashlib # Added for cache path generation
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QApplication, QApplication,
QWidget, QWidget,
@@ -679,6 +680,7 @@ class abogen(QWidget):
self.btn_preview.clicked.connect(self.preview_voice) self.btn_preview.clicked.connect(self.preview_voice)
voice_row.addWidget(self.btn_preview) voice_row.addWidget(self.btn_preview)
self.preview_playing = False self.preview_playing = False
self.play_audio_thread = None # Keep track of audio playing thread
voice_layout.addLayout(voice_row) voice_layout.addLayout(voice_row)
controls_layout.addLayout(voice_layout) controls_layout.addLayout(voice_layout)
# Subtitle mode # Subtitle mode
@@ -1524,20 +1526,106 @@ class abogen(QWidget):
"Open File Error", f"Could not open file:\n{e}" "Open File Error", f"Could not open file:\n{e}"
) )
def _get_preview_cache_path(self):
"""Generate the expected cache path for the current voice settings."""
speed = self.speed_slider.value() / 100.0
voice_to_cache = ""
lang_to_cache = ""
if self.mixed_voice_state:
components = [f"{name}*{weight}" for name, weight in self.mixed_voice_state]
voice_formula = " + ".join(filter(None, components))
voice_to_cache = voice_formula
if self.selected_profile_name:
from voice_profiles import load_profiles
entry = load_profiles().get(self.selected_profile_name, {})
lang_to_cache = entry.get("language")
else:
lang_to_cache = self.selected_lang
if not lang_to_cache and self.mixed_voice_state:
lang_to_cache = (
self.mixed_voice_state[0][0][0]
if self.mixed_voice_state and self.mixed_voice_state[0][0]
else None
)
elif self.selected_voice:
lang_to_cache = self.selected_voice[0]
voice_to_cache = self.selected_voice
else: # No voice or profile selected
return None
if not lang_to_cache or not voice_to_cache: # Not enough info
return None
cache_dir = os.path.join(tempfile.gettempdir(), PROGRAM_NAME, "preview_cache")
if "*" in voice_to_cache: # Voice formula
voice_id = f"voice_formula_{hashlib.md5(voice_to_cache.encode()).hexdigest()[:8]}"
else: # Single voice
voice_id = voice_to_cache
filename = f"{voice_id}_{lang_to_cache}_{speed:.2f}.wav"
return os.path.join(cache_dir, filename)
def preview_voice(self): def preview_voice(self):
if self.preview_playing: if self.preview_playing:
try: try:
import pygame if self.play_audio_thread and self.play_audio_thread.isRunning():
# Attempt to stop pygame mixer if it's in use by PlayAudioThread
pygame.mixer.music.stop() # This is a bit indirect, ideally PlayAudioThread would have a stop method
except Exception: import pygame
pass if pygame.mixer.get_init() and pygame.mixer.music.get_busy():
pygame.mixer.music.stop()
pygame.mixer.music.unload() # Ensure it's unloaded
pygame.mixer.quit() # Quit the mixer
self.play_audio_thread.quit() # Ask thread to finish
self.play_audio_thread.wait(500) # Wait a bit
except Exception as e:
print(f"Error stopping preview audio: {e}")
self._preview_cleanup() self._preview_cleanup()
return return
if hasattr(self, "preview_thread") and self.preview_thread.isRunning(): if hasattr(self, "preview_thread") and self.preview_thread.isRunning():
return return
# Check for cache first
cached_path = self._get_preview_cache_path()
if cached_path and os.path.exists(cached_path):
print(f"Cache hit for {cached_path}")
self.btn_preview.setEnabled(False) # Disable button briefly
self.voice_combo.setEnabled(False)
self.btn_voice_formula_mixer.setEnabled(False)
self.btn_start.setEnabled(False)
# Directly play from cache
self.preview_playing = True
self.btn_preview.setIcon(self.stop_icon)
self.btn_preview.setToolTip("Stop preview")
self.btn_preview.setEnabled(True)
def cleanup_cached_play():
self._preview_cleanup()
try:
# Ensure pygame mixer is initialized for the audio thread
import pygame
if not pygame.mixer.get_init():
pygame.mixer.init()
self.play_audio_thread = PlayAudioThread(cached_path)
self.play_audio_thread.finished.connect(cleanup_cached_play)
self.play_audio_thread.error.connect(
lambda msg: (self._show_preview_error_box(msg), cleanup_cached_play())
)
self.play_audio_thread.start()
except Exception as e:
self._show_error_message_box(
"Preview Error", f"Could not play cached preview audio:\n{e}"
)
cleanup_cached_play()
return
# If no cache hit, proceed to load pipeline and generate
self.btn_preview.setEnabled(False) self.btn_preview.setEnabled(False)
self.btn_preview.setToolTip("Loading...") self.btn_preview.setToolTip("Loading...")
self.voice_combo.setEnabled(False) self.voice_combo.setEnabled(False)
@@ -1615,42 +1703,60 @@ class abogen(QWidget):
self.preview_thread.error.connect(self._preview_error) self.preview_thread.error.connect(self._preview_error)
self.preview_thread.start() self.preview_thread.start()
def _play_preview_audio(self): def _play_preview_audio(self, from_cache=True): # from_cache default is now False
temp_wav = self.preview_thread.temp_wav # If preview_thread is the source, get temp_wav from it
if not temp_wav: if hasattr(self, 'preview_thread') and not from_cache:
self.loading_movie.stop() temp_wav = self.preview_thread.temp_wav
elif from_cache: # This case is now handled before calling _play_preview_audio
cached_path = self._get_preview_cache_path()
if cached_path and os.path.exists(cached_path):
temp_wav = cached_path
else: # Should not happen if cache check was done
self._show_error_message_box("Preview Error", "Cache file expected but not found.")
self._preview_cleanup()
return
else: # Should have temp_wav from preview_thread or handled by cache check
self._show_error_message_box("Preview Error", "Preview audio path not found.")
self._preview_cleanup()
return
if not temp_wav:
if hasattr(self, 'loading_movie'): self.loading_movie.stop()
self._show_error_message_box( self._show_error_message_box(
"Preview Error", "Preview error: No audio generated." "Preview Error", "Preview error: No audio generated."
) )
self.btn_preview.setIcon(self.play_icon) self._preview_cleanup()
self.btn_preview.setEnabled(True)
self.btn_preview.setToolTip("Preview selected voice")
self.voice_combo.setEnabled(True)
self.btn_voice_formula_mixer.setEnabled(True) # Re-enable mixer button
self.btn_start.setEnabled(True)
return return
# stop loading animation, switch to stop icon # stop loading animation, switch to stop icon
self.loading_movie.stop() if hasattr(self, 'loading_movie'): self.loading_movie.stop()
self.preview_playing = True self.preview_playing = True
self.btn_preview.setIcon(self.stop_icon) self.btn_preview.setIcon(self.stop_icon)
self.btn_preview.setToolTip("Stop preview") self.btn_preview.setToolTip("Stop preview")
self.btn_preview.setEnabled(True) self.btn_preview.setEnabled(True)
def cleanup(): def cleanup():
try: # Only remove if not from cache AND it's a temp file from VoicePreviewThread
os.remove(temp_wav) if not from_cache and hasattr(self, 'preview_thread') and hasattr(self.preview_thread, 'temp_wav') and self.preview_thread.temp_wav == temp_wav:
except Exception: try:
pass if os.path.exists(temp_wav): # Ensure it exists before trying to remove
os.remove(temp_wav)
except Exception:
pass
self._preview_cleanup() self._preview_cleanup()
try: try:
self.audio_thread = PlayAudioThread(temp_wav) # Ensure pygame mixer is initialized for the audio thread
self.audio_thread.finished.connect(cleanup) import pygame
self.audio_thread.error.connect( if not pygame.mixer.get_init():
pygame.mixer.init()
self.play_audio_thread = PlayAudioThread(temp_wav)
self.play_audio_thread.finished.connect(cleanup)
self.play_audio_thread.error.connect(
lambda msg: (self._show_preview_error_box(msg), cleanup()) lambda msg: (self._show_preview_error_box(msg), cleanup())
) )
self.audio_thread.start() self.play_audio_thread.start()
except Exception as e: except Exception as e:
self._show_error_message_box( self._show_error_message_box(
"Preview Error", f"Could not play preview audio:\n{e}" "Preview Error", f"Could not play preview audio:\n{e}"
@@ -1673,9 +1779,9 @@ class abogen(QWidget):
def _preview_cleanup(self): def _preview_cleanup(self):
self.preview_playing = False self.preview_playing = False
self.loading_movie.stop() if hasattr(self, 'loading_movie'): self.loading_movie.stop()
try: try:
self.loading_movie.frameChanged.disconnect() if hasattr(self, 'loading_movie'): self.loading_movie.frameChanged.disconnect()
except Exception: except Exception:
pass # Ignore error if not connected pass # Ignore error if not connected
self.btn_preview.setIcon(self.play_icon) self.btn_preview.setIcon(self.play_icon)