mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
Added voice preview caching system
This commit is contained in:
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user