This commit is contained in:
Juraj Borza
2025-05-03 19:26:46 +02:00
27 changed files with 2430 additions and 624 deletions
+1 -1
View File
@@ -1 +1 @@
1.0.2
1.0.4
Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 571 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 786 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 521 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 465 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 441 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 617 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 B

+819 -428
View File
File diff suppressed because it is too large Load Diff
-13
View File
@@ -102,16 +102,3 @@ SAMPLE_VOICE_TEXTS = {
"p": "Este é um exemplo da voz selecionada.",
"z": "这是所选语音的示例。",
}
# flags mapping for voice display
FLAGS = {
"a": "🇺🇸",
"b": "🇬🇧",
"e": "🇪🇸",
"f": "🇫🇷",
"h": "🇮🇳",
"i": "🇮🇹",
"j": "🇯🇵",
"p": "🇧🇷",
"z": "🇨🇳",
}
+38 -13
View File
@@ -4,6 +4,7 @@ import tempfile
import time
import chardet
import charset_normalizer
from platformdirs import user_desktop_dir
from PyQt5.QtCore import QThread, pyqtSignal, Qt
from PyQt5.QtWidgets import QCheckBox, QVBoxLayout, QDialog, QLabel, QDialogButtonBox
import soundfile as sf
@@ -15,6 +16,7 @@ import static_ffmpeg
def get_sample_voice_text(lang_code):
return SAMPLE_VOICE_TEXTS.get(lang_code, SAMPLE_VOICE_TEXTS["a"])
def detect_encoding(file_path):
with open(file_path, "rb") as f:
raw_data = f.read()
@@ -150,6 +152,9 @@ class ConversionThread(QThread):
self.max_subtitle_words = 50 # Default value, will be overridden from GUI
def run(self):
print(
f"\nVoice: {self.voice}\nLanguage: {self.lang_code}\nSpeed: {self.speed}\nGPU: {self.use_gpu}\nFile: {self.file_name}\nSubtitle mode: {self.subtitle_mode}\nOutput format: {self.output_format}\nSave option: {self.save_option}\n"
)
try:
# Show configuration
self.log_updated.emit("Configuration:")
@@ -172,9 +177,7 @@ class ConversionThread(QThread):
self.log_updated.emit(f"- Output format: {self.output_format}")
self.log_updated.emit(f"- Save option: {self.save_option}")
if self.replace_single_newlines:
self.log_updated.emit(
f"- Replace single newlines: Yes"
)
self.log_updated.emit(f"- Replace single newlines: Yes")
# Display save_chapters_separately flag if it's set
if hasattr(self, "save_chapters_separately"):
@@ -206,7 +209,9 @@ class ConversionThread(QThread):
text = self.file_name # Treat file_name as direct text input
else:
encoding = detect_encoding(self.file_name)
with open(self.file_name, "r", encoding=encoding, errors="replace") as file:
with open(
self.file_name, "r", encoding=encoding, errors="replace"
) as file:
text = file.read()
# Clean up text using utility function
@@ -279,7 +284,7 @@ class ConversionThread(QThread):
base_path = self.display_path if self.display_path else self.file_name
base_name = os.path.splitext(os.path.basename(base_path))[0]
if self.save_option == "Save to Desktop":
parent_dir = os.path.join(os.path.expanduser("~"), "Desktop")
parent_dir = user_desktop_dir()
elif self.save_option == "Save next to input file":
parent_dir = os.path.dirname(base_path)
else:
@@ -361,9 +366,9 @@ class ConversionThread(QThread):
# Set split_pattern to \n+ which will split on one or more newlines
split_pattern = r"\n+"
# Check if the voice is a formula and load it if necessary
if '*' in self.voice:
if "*" in self.voice:
loaded_voice = get_new_voice(tts, self.voice, self.use_gpu)
else:
loaded_voice = self.voice
@@ -506,7 +511,9 @@ class ConversionThread(QThread):
chapter_srt_path = os.path.join(
chapters_out_dir, f"{chapter_filename}.srt"
)
with open(chapter_srt_path, "w", encoding="utf-8", errors="replace") as srt_file:
with open(
chapter_srt_path, "w", encoding="utf-8", errors="replace"
) as srt_file:
for i, (start, end, text) in enumerate(
chapter_subtitle_entries, 1
):
@@ -557,7 +564,9 @@ class ConversionThread(QThread):
out_path = self._generate_m4b_with_chapters(out_path, chapters_time)
if self.subtitle_mode != "Disabled":
with open(srt_path, "w", encoding="utf-8", errors="replace") as srt_file:
with open(
srt_path, "w", encoding="utf-8", errors="replace"
) as srt_file:
for i, (start, end, text) in enumerate(subtitle_entries, 1):
srt_file.write(
f"{i}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n"
@@ -740,7 +749,14 @@ class VoicePreviewThread(QThread):
error = pyqtSignal(str)
def __init__(
self, np_module, kpipeline_class, lang_code, voice, speed, parent=None
self,
np_module,
kpipeline_class,
lang_code,
voice,
speed,
use_gpu=False,
parent=None,
):
super().__init__(parent)
self.np_module = np_module
@@ -748,17 +764,26 @@ class VoicePreviewThread(QThread):
self.lang_code = lang_code
self.voice = voice
self.speed = speed
self.temp_wav = None
self.use_gpu = use_gpu
def run(self):
print(
f"\nVoice: {self.voice}\nLanguage: {self.lang_code}\nSpeed: {self.speed}\nGPU: {self.use_gpu}\n"
)
try:
device = "cuda" if self.use_gpu else "cpu"
tts = self.kpipeline_class(
lang_code=self.lang_code, repo_id="hexgrad/Kokoro-82M"
lang_code=self.lang_code, repo_id="hexgrad/Kokoro-82M", device=device
)
# Enable voice formula support for preview
if "*" in self.voice:
loaded_voice = get_new_voice(tts, self.voice, self.use_gpu)
else:
loaded_voice = self.voice
sample_text = get_sample_voice_text(self.lang_code)
audio_segments = []
for result in tts(
sample_text, voice=self.voice, speed=self.speed, split_pattern=None
sample_text, voice=loaded_voice, speed=self.speed, split_pattern=None
):
audio_segments.append(result.audio)
if audio_segments:
+228 -47
View File
@@ -66,12 +66,13 @@ from constants import (
GITHUB_URL,
PROGRAM_DESCRIPTION,
LANGUAGE_DESCRIPTIONS,
FLAGS,
VOICES_INTERNAL,
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
)
from threading import Thread
from voice_formula_gui import VoiceFormulaDialog
from platformdirs import user_desktop_dir
from voice_profiles import load_profiles
# Import ctypes for Windows-specific taskbar icon
if platform.system() == "Windows":
@@ -458,9 +459,17 @@ class abogen(QWidget):
self.selected_chapters = set()
self.last_opened_book_path = None # Track the last opened book path
self.last_output_path = None
self.selected_voice = self.config.get("selected_voice", "af_heart")
self.selected_lang = self.selected_voice[0]
self.mixed_voice_state = None # Store the mixed voice state
# Only one of selected_profile_name or selected_voice should be set
self.selected_profile_name = self.config.get("selected_profile_name")
self.selected_voice = None
self.selected_lang = None
self.mixed_voice_state = None
if self.selected_profile_name:
self.selected_voice = None
self.selected_lang = None
else:
self.selected_voice = self.config.get("selected_voice", "af_heart")
self.selected_lang = self.selected_voice[0] if self.selected_voice else None
self.is_converting = False
self.subtitle_mode = self.config.get("subtitle_mode", "Sentence")
self.max_subtitle_words = self.config.get(
@@ -468,8 +477,8 @@ class abogen(QWidget):
) # Default max words per subtitle
self.selected_format = self.config.get("selected_format", "wav")
self.use_gpu = self.config.get(
"use_gpu", True
) # Load GPU setting with default True
"use_gpu", True # Load GPU setting with default True
)
self.replace_single_newlines = self.config.get("replace_single_newlines", False)
self._pending_close_event = None
self.gpu_ok = False # Initialize GPU availability status
@@ -489,17 +498,32 @@ class abogen(QWidget):
self.initUI()
self.speed_slider.setValue(int(self.config.get("speed", 1.00) * 100))
self.update_speed_label()
idx = self.voice_combo.findData(self.selected_voice)
# Set initial selection: prefer profile, else voice
idx = -1
if self.selected_profile_name:
idx = self.voice_combo.findData(f"profile:{self.selected_profile_name}")
elif self.selected_voice:
idx = self.voice_combo.findData(self.selected_voice)
if idx >= 0:
self.voice_combo.setCurrentIndex(idx)
# If a profile is selected at startup, load voices and language
if self.selected_profile_name:
from voice_profiles import load_profiles
entry = load_profiles().get(self.selected_profile_name, {})
if isinstance(entry, dict):
self.mixed_voice_state = entry.get("voices", [])
self.selected_lang = entry.get("language")
else:
self.mixed_voice_state = entry
self.selected_lang = entry[0][0] if entry and entry[0] else None
if self.save_option == "Choose output folder" and self.selected_output_folder:
self.save_path_label.setText(self.selected_output_folder)
self.save_path_label.show()
self.subtitle_combo.setCurrentText(self.subtitle_mode)
# Enable/disable subtitle options based on selected language
self.subtitle_combo.setEnabled(
self.selected_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION
)
# Enable/disable subtitle options based on selected language (profile or voice)
enable = self.selected_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION
self.subtitle_combo.setEnabled(enable)
# loading gif for preview button
loading_gif_path = get_resource_path("abogen.assets", "loading.gif")
if loading_gif_path:
@@ -559,22 +583,20 @@ class abogen(QWidget):
voice_layout.addWidget(QLabel("Select Voice:", self))
voice_row = QHBoxLayout()
self.voice_combo = QComboBox(self)
for v in VOICES_INTERNAL:
flag = FLAGS.get(v[0], "")
self.voice_combo.addItem(f"{flag} {v}", v)
self.voice_combo.currentIndexChanged.connect(self.on_voice_combo_changed)
self.voice_combo.setStyleSheet(
"QComboBox { min-height: 20px; padding: 6px 12px; }"
)
self.voice_combo.currentIndexChanged.connect(self.on_voice_changed)
self.voice_combo.setToolTip(
"The first character represents the language:\n"
'"a" => American English\n"b" => British English\n"e" => Spanish\n"f" => French\n"h" => Hindi\n"i" => Italian\n"j" => Japanese\n"p" => Brazilian Portuguese\n"z" => Mandarin Chinese\nThe second character represents the gender:\n"m" => Male\n"f" => Female'
)
voice_row.addWidget(self.voice_combo)
# Voice formula button
self.btn_voice_formula_mixer = QPushButton(self)
self.btn_voice_formula_mixer.setText("🛠") # TODO add voice formula icon
mixer_icon_path = get_resource_path("abogen.assets", "voice_mixer.png")
self.btn_voice_formula_mixer.setIcon(QIcon(mixer_icon_path))
self.btn_voice_formula_mixer.setToolTip("Mix and match voices")
self.btn_voice_formula_mixer.setFixedSize(40, 36)
self.btn_voice_formula_mixer.setStyleSheet("QPushButton { padding: 6px 12px; }")
@@ -639,10 +661,9 @@ class abogen(QWidget):
)
self.subtitle_combo.setCurrentText(self.subtitle_mode)
self.subtitle_combo.currentTextChanged.connect(self.on_subtitle_mode_changed)
# Enable/disable subtitle options based on selected language
self.subtitle_combo.setEnabled(
self.selected_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION
)
# Enable/disable subtitle options based on selected language (profile or voice)
enable = self.selected_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION
self.subtitle_combo.setEnabled(enable)
subtitle_layout.addWidget(self.subtitle_combo)
controls_layout.addLayout(subtitle_layout)
# Output format
@@ -764,6 +785,7 @@ class abogen(QWidget):
container_layout.addWidget(self.finish_widget)
outer_layout.addWidget(container)
self.setLayout(outer_layout)
self.populate_profiles_in_voice_combo()
def open_file_dialog(self):
if self.is_converting:
@@ -946,6 +968,87 @@ class abogen(QWidget):
else:
self.subtitle_combo.setEnabled(False)
def on_voice_combo_changed(self, index):
data = self.voice_combo.itemData(index)
if isinstance(data, str) and data.startswith("profile:"):
pname = data.split(":", 1)[1]
self.selected_profile_name = pname
from voice_profiles import load_profiles
entry = load_profiles().get(pname, {})
# set mixed voices and language
if isinstance(entry, dict):
self.mixed_voice_state = entry.get("voices", [])
self.selected_lang = entry.get("language")
else:
self.mixed_voice_state = entry
self.selected_lang = entry[0][0] if entry and entry[0] else None
self.selected_voice = None
self.config["selected_profile_name"] = pname
self.config.pop("selected_voice", None)
save_config(self.config)
# enable subtitles based on profile language
self.subtitle_combo.setEnabled(
self.selected_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION
)
else:
self.mixed_voice_state = None
self.selected_profile_name = None
self.selected_voice, self.selected_lang = data, data[0]
self.config["selected_voice"] = data
if "selected_profile_name" in self.config:
del self.config["selected_profile_name"]
save_config(self.config)
if self.selected_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION:
self.subtitle_combo.setEnabled(True)
self.subtitle_mode = self.subtitle_combo.currentText()
else:
self.subtitle_combo.setEnabled(False)
def update_subtitle_combo_for_profile(self, profile_name):
from voice_profiles import load_profiles
entry = load_profiles().get(profile_name, {})
lang = entry.get("language") if isinstance(entry, dict) else None
enable = lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION
self.subtitle_combo.setEnabled(enable)
def populate_profiles_in_voice_combo(self):
# preserve current voice or profile
current = self.voice_combo.currentData()
self.voice_combo.blockSignals(True)
self.voice_combo.clear()
# re-add profiles
profile_icon = QIcon(get_resource_path("abogen.assets", "profile.png"))
for pname in load_profiles().keys():
self.voice_combo.addItem(profile_icon, pname, f"profile:{pname}")
# re-add voices
for v in VOICES_INTERNAL:
icon = QIcon()
flag_path = get_resource_path("abogen.assets.flags", f"{v[0]}.png")
if flag_path and os.path.exists(flag_path):
icon = QIcon(flag_path)
self.voice_combo.addItem(icon, f"{v}", v)
# restore selection
idx = -1
if self.selected_profile_name:
idx = self.voice_combo.findData(f"profile:{self.selected_profile_name}")
elif current:
idx = self.voice_combo.findData(current)
if idx >= 0:
self.voice_combo.setCurrentIndex(idx)
# Also update subtitle combo for selected profile
data = self.voice_combo.itemData(idx)
if isinstance(data, str) and data.startswith("profile:"):
pname = data.split(":", 1)[1]
self.update_subtitle_combo_for_profile(pname)
self.voice_combo.blockSignals(False)
# If no profiles exist, clear selected_profile_name from config
if not load_profiles():
if "selected_profile_name" in self.config:
del self.config["selected_profile_name"]
save_config(self.config)
def convert_input_box_to_log(self):
self.input_box.hide()
self.log_text.show()
@@ -1050,16 +1153,27 @@ class abogen(QWidget):
if not self.subtitle_combo.isEnabled()
else self.subtitle_mode
)
# if voice formula is not None, use the selected voice
if self.mixed_voice_state:
formula_components = [f"{weight} * {name}" for name, weight in self.mixed_voice_state]
formula_components = [
f"{name}*{weight}" for name, weight in self.mixed_voice_state
]
voice_formula = " + ".join(filter(None, formula_components))
else:
voice_formula = self.selected_voice
# selected language - use the first voice of the mix
match = re.search(r'\b([a-z])', voice_formula)
selected_lang = match.group(1)
# determine selected language: use profile setting if profile selected, else voice code
if self.selected_profile_name:
from voice_profiles import load_profiles
entry = load_profiles().get(self.selected_profile_name, {})
selected_lang = entry.get("language")
else:
selected_lang = self.selected_voice[0] if self.selected_voice else None
# fallback: extract from formula if missing
if not selected_lang:
m = re.search(r"\b([a-z])", voice_formula)
selected_lang = m.group(1) if m else None
self.conversion_thread = ConversionThread(
self.selected_file,
@@ -1083,7 +1197,9 @@ class abogen(QWidget):
# Pass max_subtitle_words from config
self.conversion_thread.max_subtitle_words = self.max_subtitle_words
# Pass replace_single_newlines setting
self.conversion_thread.replace_single_newlines = self.replace_single_newlines
self.conversion_thread.replace_single_newlines = (
self.replace_single_newlines
)
# Pass chapter count for EPUB or PDF files
if self.selected_file_type in ["epub", "pdf"] and hasattr(
self, "selected_chapters"
@@ -1314,6 +1430,7 @@ class abogen(QWidget):
self.btn_preview.setEnabled(False)
self.btn_preview.setToolTip("Loading...")
self.voice_combo.setEnabled(False)
self.btn_voice_formula_mixer.setEnabled(False) # Disable mixer button
self.btn_start.setEnabled(False) # Disable start button during preview
# start loading animation
self.loading_movie.start()
@@ -1328,23 +1445,46 @@ class abogen(QWidget):
# stop loading animation and restore icon on error
if error:
self.loading_movie.stop()
self.btn_preview.setIcon(self.play_icon)
self._show_error_message_box(
"Loading Error", f"Error loading numpy or KPipeline: {error}"
)
self.btn_preview.setIcon(self.play_icon)
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) # Re-enable start button on error
return
lang, voice, speed = (
self.selected_voice[0],
self.selected_voice,
self.speed_slider.value() / 100.0,
)
# Support preview for voice profiles
speed = self.speed_slider.value() / 100.0
if self.mixed_voice_state:
# Build voice formula string
components = [f"{name}*{weight}" for name, weight in self.mixed_voice_state]
voice = " + ".join(filter(None, components))
# determine language: use profile setting if available, else first voice code
if self.selected_profile_name:
from voice_profiles import load_profiles
entry = load_profiles().get(self.selected_profile_name, {})
lang = entry.get("language")
else:
lang = None
if not lang and self.mixed_voice_state:
lang = (
self.mixed_voice_state[0][0][0]
if self.mixed_voice_state and self.mixed_voice_state[0][0]
else None
)
else:
lang = self.selected_voice[0]
voice = self.selected_voice
# use same gpu/cpu logic as in conversion
gpu_msg, gpu_ok = get_gpu_acceleration(self.use_gpu)
self.preview_thread = VoicePreviewThread(
np_module, kpipeline_class, lang, voice, speed
np_module, kpipeline_class, lang, voice, speed, gpu_ok
)
self.preview_thread.finished.connect(self._play_preview_audio)
self.preview_thread.error.connect(self._preview_error)
@@ -1354,13 +1494,15 @@ class abogen(QWidget):
temp_wav = self.preview_thread.temp_wav
if not temp_wav:
self.loading_movie.stop()
self.btn_preview.setIcon(self.play_icon)
self._show_error_message_box(
"Preview Error", "Preview error: No audio generated."
)
self.btn_preview.setEnabled(True)
self.btn_preview.setIcon(self.play_icon)
self.btn_preview.setToolTip("Preview selected voice")
self.btn_preview.setEnabled(True)
self.voice_combo.setEnabled(True)
self.btn_voice_formula_mixer.setEnabled(True) # Re-enable mixer button
self.btn_start.setEnabled(True)
return
# stop loading animation, switch to stop icon
@@ -1406,10 +1548,16 @@ class abogen(QWidget):
def _preview_cleanup(self):
self.preview_playing = False
self.btn_preview.setIcon(self.play_icon)
self.loading_movie.stop()
try:
self.loading_movie.frameChanged.disconnect()
except Exception:
pass # Ignore error if not connected
self.btn_preview.setIcon(self.play_icon)
self.btn_preview.setToolTip("Preview selected voice")
self.btn_preview.setEnabled(True)
self.voice_combo.setEnabled(True)
self.btn_voice_formula_mixer.setEnabled(True) # Re-enable mixer button
self.btn_start.setEnabled(True)
def _preview_error(self, msg):
@@ -1546,7 +1694,7 @@ class abogen(QWidget):
menu.addAction(add_shortcut_action)
# Add reveal config option
reveal_config_action = QAction("Open config.json directory", self)
reveal_config_action = QAction("Open configuration directory", self)
reveal_config_action.triggered.connect(self.reveal_config_in_explorer)
menu.addAction(reveal_config_action)
@@ -1619,7 +1767,7 @@ class abogen(QWidget):
try:
# where to put the .lnk
desktop = os.path.join(os.environ.get("USERPROFILE", ""), "Desktop")
desktop = user_desktop_dir()
shortcut_path = os.path.join(desktop, "abogen.lnk")
# target exe
@@ -1678,17 +1826,50 @@ class abogen(QWidget):
def toggle_check_updates(self, checked):
self.config["check_updates"] = checked
save_config(self.config)
def show_voice_formula_dialog(self):
# get the current voice mix
if self.mixed_voice_state is None:
# if no voice mix is set, use the selected voice
self.mixed_voice_state = [(self.selected_voice, 1.0)]
dialog = VoiceFormulaDialog(self, initial_state=self.mixed_voice_state)
from voice_profiles import load_profiles
profiles = load_profiles()
initial_state = None
selected_profile = self.selected_profile_name
if selected_profile:
entry = profiles.get(selected_profile, {})
if isinstance(entry, dict):
initial_state = entry.get("voices", [])
else:
initial_state = entry
elif self.mixed_voice_state is not None:
initial_state = self.mixed_voice_state
elif self.selected_voice:
# If a single voice is selected, default to first profile if available
if profiles:
first_profile = next(iter(profiles))
entry = profiles[first_profile]
selected_profile = first_profile
if isinstance(entry, dict):
initial_state = entry.get("voices", [])
else:
initial_state = entry
else:
initial_state = []
else:
initial_state = []
dialog = VoiceFormulaDialog(
self, initial_state=initial_state, selected_profile=selected_profile
)
if dialog.exec_() == QDialog.Accepted:
if dialog.current_profile:
self.selected_profile_name = dialog.current_profile
self.config["selected_profile_name"] = dialog.current_profile
if "selected_voice" in self.config:
del self.config["selected_voice"]
save_config(self.config)
self.populate_profiles_in_voice_combo()
idx = self.voice_combo.findData(f"profile:{dialog.current_profile}")
if idx >= 0:
self.voice_combo.setCurrentIndex(idx)
self.mixed_voice_state = dialog.get_selected_voices()
def show_about_dialog(self):
"""Show an About dialog with program information including GitHub link."""
# Get application icon for dialog
+9 -1
View File
@@ -32,6 +32,14 @@ def get_resource_path(package, resource):
except (ImportError, FileNotFoundError):
pass
# Always try to resolve as a relative path from this file
parts = package.split(".")
rel_path = os.path.join(
os.path.dirname(os.path.abspath(__file__)), *parts[1:], resource
)
if os.path.exists(rel_path):
return rel_path
# Fallback to local file system
try:
# Extract the subdirectory from package name (e.g., 'assets' from 'abogen.assets')
@@ -50,7 +58,7 @@ def get_resource_path(package, resource):
def get_version():
"""Return the current version of the application."""
try:
with open(get_resource_path("abogen", "VERSION"), "r") as f:
with open(get_resource_path("/", "VERSION"), "r") as f:
return f.read().strip()
except Exception:
return "Unknown"
File diff suppressed because it is too large Load Diff
+22 -16
View File
@@ -1,51 +1,57 @@
import re
from constants import VOICES_INTERNAL
# Calls parsing and loads the voice to gpu or cpu
def get_new_voice(pipeline, formula, use_gpu):
try:
weighted_voice = parse_voice_formula(pipeline, formula)
device = "cuda" if use_gpu else "cpu"
weighted_voice = parse_voice_formula(pipeline, formula)
# device = "cuda" if use_gpu else "cpu"
# Setting the device "cuda" gives "Error occurred: split_with_sizes(): argument 'split_sizes' (position 2)"
# error when the device is gpu. So disabling this for now.
device = "cpu"
return weighted_voice.to(device)
except Exception as e:
raise ValueError(f"Failed to create voice: {str(e)}")
# Parse the formula and get the combined voice tensor
# Parse the formula and get the combined voice tensor
def parse_voice_formula(pipeline, formula):
if not formula.strip():
raise ValueError("Empty voice formula")
# Initialize the weighted sum
weighted_sum = None
total_weight = calculate_sum_from_formula(formula)
# Split the formula into terms
voices = formula.split('+')
voices = formula.split("+")
for term in voices:
# Parse each term (format: "0.333 * voice_name")
weight, voice_name = term.strip().split('*')
# Parse each term (format: "voice_name*0.333")
voice_name, weight = term.strip().split("*")
weight = float(weight.strip())
# normalize the weight
weight /= total_weight if total_weight > 0 else 1.0
voice_name = voice_name.strip()
# Get the voice tensor
if voice_name not in VOICES_INTERNAL:
raise ValueError(f"Unknown voice: {voice_name}")
voice_tensor = pipeline.load_single_voice(voice_name)
# Add to weighted sum
if weighted_sum is None:
weighted_sum = weight * voice_tensor
else:
weighted_sum += weight * voice_tensor
return weighted_sum
def calculate_sum_from_formula(formula):
weights = re.findall(r'([\d.]+) \*', formula)
weights = re.findall(r"\* *([\d.]+)", formula)
total_sum = sum(float(weight) for weight in weights)
return total_sum
return total_sum
+59
View File
@@ -0,0 +1,59 @@
import os
import json
from utils import get_user_config_path, get_resource_path
def _get_profiles_path():
config_path = get_user_config_path()
config_dir = os.path.dirname(config_path)
return os.path.join(config_dir, "voice_profiles.json")
def load_profiles():
"""Load all voice profiles from JSON file."""
path = _get_profiles_path()
if os.path.exists(path):
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
# always expect abogen_voice_profiles wrapper
if isinstance(data, dict) and "abogen_voice_profiles" in data:
return data["abogen_voice_profiles"]
# fallback: treat as profiles dict
if isinstance(data, dict):
return data
except Exception:
return {}
return {}
def save_profiles(profiles):
"""Save all voice profiles to JSON file."""
path = _get_profiles_path()
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
# always save with abogen_voice_profiles wrapper
json.dump({"abogen_voice_profiles": profiles}, f, indent=2)
def delete_profile(name):
"""Remove a profile by name."""
profiles = load_profiles()
if name in profiles:
del profiles[name]
save_profiles(profiles)
def duplicate_profile(src, dest):
"""Duplicate an existing profile."""
profiles = load_profiles()
if src in profiles and dest:
profiles[dest] = profiles[src]
save_profiles(profiles)
def export_profiles(export_path):
"""Export all profiles to specified JSON file."""
profiles = load_profiles()
with open(export_path, "w", encoding="utf-8") as f:
json.dump({"abogen_voice_profiles": profiles}, f, indent=2)