@@ -1,6 +1,8 @@
|
|||||||
- Enhanced EPUB handling by treating all items in chapter list (including anchors) as chapters, improving navigation and organization for poorly structured books, mentioned by @Darthagnon in #4
|
- Added voice mixing, allowing multiple voices to be combined into a single “Mixed Voice”, a feature mentioned by @PulsarFTW in #1. Special thanks to @jborza for making this possible through his contributions in #5.
|
||||||
- Fixed the issue with some chapters in EPUB files had missing content.
|
- Added profile system to voice mixer, allowing users to create and manage multiple voice profiles.
|
||||||
- Fixed the issue with some EPUB files only having one chapter caused the program to ignore the entire book.
|
- Improvements in the voice mixer, mostly for organizing controls and enhancing user experience.
|
||||||
- Fixed "utf-8' codec can't decode byte" error, mentioned by @nigelp in #3
|
- Added icons for flags and genders in the GUI, making it easier to identify different options.
|
||||||
- Added "Replace single newlines with spaces" option in the menu. This can be useful for texts that have imaginary line breaks.
|
- Improved the content and chapter extraction process for EPUB files, ensuring better handling of various structures.
|
||||||
|
- Switched to platformdirs for determining the correct desktop path, instead of using old methods.
|
||||||
|
- Fixed preview voices was not using GPU acceleration, which was causing performance issues.
|
||||||
- Improvements in code and documentation.
|
- Improvements in code and documentation.
|
||||||
@@ -68,7 +68,7 @@ abogen
|
|||||||
1) Drag and drop any ePub, PDF, or text file (or use the built-in text editor)
|
1) Drag and drop any ePub, PDF, or text file (or use the built-in text editor)
|
||||||
2) Configure the settings:
|
2) Configure the settings:
|
||||||
- Set speech speed
|
- Set speech speed
|
||||||
- Select a voice
|
- Select a voice (or create a custom voice using voice mixer)
|
||||||
- Select subtitle generation style (by sentence, word, etc.)
|
- Select subtitle generation style (by sentence, word, etc.)
|
||||||
- Select output format
|
- Select output format
|
||||||
- Select where to save the output
|
- Select where to save the output
|
||||||
@@ -83,6 +83,7 @@ Here’s Abogen in action: in this demo, it processes ∼3,000 characters of tex
|
|||||||
- **Supported formats**: `ePub`, `PDF`, or `.TXT` files (or use built-in text editor)
|
- **Supported formats**: `ePub`, `PDF`, or `.TXT` files (or use built-in text editor)
|
||||||
- **Speed**: Adjust speech rate from `0.1x` to `2.0x`
|
- **Speed**: Adjust speech rate from `0.1x` to `2.0x`
|
||||||
- **Voices**: First letter of the language code (e.g., `a` for American English, `b` for British English, etc.), second letter is for `m` for male and `f` for female.
|
- **Voices**: First letter of the language code (e.g., `a` for American English, `b` for British English, etc.), second letter is for `m` for male and `f` for female.
|
||||||
|
- **Voice mixer**: Create custom voices by mixing different voice models with a profile system.
|
||||||
- **Generate subtitles**: `Disabled`, `Sentence`, `Sentence + Comma`, `1 word`, `2 words`, `3 words`, etc. (Represents the number of words in each subtitle entry)
|
- **Generate subtitles**: `Disabled`, `Sentence`, `Sentence + Comma`, `1 word`, `2 words`, `3 words`, etc. (Represents the number of words in each subtitle entry)
|
||||||
- **Output formats**: `.WAV`, `.FLAC`, or `.MP3`
|
- **Output formats**: `.WAV`, `.FLAC`, or `.MP3`
|
||||||
- **Save location**: `Save next to input file`, `Save to desktop`, or `Choose output folder`
|
- **Save location**: `Save next to input file`, `Save to desktop`, or `Choose output folder`
|
||||||
@@ -93,10 +94,15 @@ Here’s Abogen in action: in this demo, it processes ∼3,000 characters of tex
|
|||||||
- **Create desktop shortcut**: Creates a shortcut on your desktop for easy access.
|
- **Create desktop shortcut**: Creates a shortcut on your desktop for easy access.
|
||||||
- **Open config.json directory**: Opens the directory where the configuration file is stored.
|
- **Open config.json directory**: Opens the directory where the configuration file is stored.
|
||||||
- **Open temp directory**: Opens the temporary directory where converted text files are stored.
|
- **Open temp directory**: Opens the temporary directory where converted text files are stored.
|
||||||
- **Clear all teporary files**: Deletes all temporary files created during the conversion process.
|
- **Clear all temporary files**: Deletes all temporary files created during the conversion process.
|
||||||
- **Check for updates at startup**: Automatically checks for updates when the program starts.
|
- **Check for updates at startup**: Automatically checks for updates when the program starts.
|
||||||
- **After conversion**: `Open file`, `Go to folder`, `New conversion`, or `Go back`.
|
- **After conversion**: `Open file`, `Go to folder`, `New conversion`, or `Go back`.
|
||||||
|
|
||||||
|
## `Voice Mixer`
|
||||||
|
<img title="Abogen Voice Mixer" src='https://raw.githubusercontent.com/denizsafak/abogen/refs/heads/main/demo/voice_mixer.png'>
|
||||||
|
|
||||||
|
With voice mixer, you can create custom voices by mixing different voice models. You can adjust the weight of each voice and save your custom voice as a profile for future use. The voice mixer allows you to create unique and personalized voices. (Huge thanks to [@jborza](https://github.com/jborza) for making this possible through his contributions in [#5](https://github.com/denizsafak/abogen/pull/5))
|
||||||
|
|
||||||
## `Supported Languages`
|
## `Supported Languages`
|
||||||
```
|
```
|
||||||
# 🇺🇸 'a' => American English, 🇬🇧 'b' => British English
|
# 🇺🇸 'a' => American English, 🇬🇧 'b' => British English
|
||||||
@@ -136,7 +142,7 @@ Abogen is a standalone project, but it is inspired by and shares some similariti
|
|||||||
- [ ] Improve PDF support for better text extraction.
|
- [ ] Improve PDF support for better text extraction.
|
||||||
- [ ] Add chapter metadata for .m4a files using ffmpeg-bin.
|
- [ ] Add chapter metadata for .m4a files using ffmpeg-bin.
|
||||||
- [ ] Add support for different languages in GUI.
|
- [ ] Add support for different languages in GUI.
|
||||||
- [ ] Add voice formula feature that enables mixing different voice models. https://github.com/denizsafak/abogen/issues/1
|
- [x] Add voice formula feature that enables mixing different voice models. #1
|
||||||
- [ ] Add support for kokoro-onnx.
|
- [ ] Add support for kokoro-onnx.
|
||||||
- [ ] Add dark mode.
|
- [ ] Add dark mode.
|
||||||
|
|
||||||
@@ -154,7 +160,7 @@ If you'd like to modify the code and contribute to development, you can [downloa
|
|||||||
```bash
|
```bash
|
||||||
# Go to the directory where you extracted the repository and run:
|
# Go to the directory where you extracted the repository and run:
|
||||||
pip install -e . # Installs the package in editable mode
|
pip install -e . # Installs the package in editable mode
|
||||||
python -m build # Builds the package in dist folder
|
python -m build # Builds the package in dist folder (optional)
|
||||||
abogen # Opens the GUI
|
abogen # Opens the GUI
|
||||||
```
|
```
|
||||||
Feel free to explore the code and make any changes you like.
|
Feel free to explore the code and make any changes you like.
|
||||||
@@ -162,7 +168,9 @@ Feel free to explore the code and make any changes you like.
|
|||||||
## `Credits`
|
## `Credits`
|
||||||
- Abogen uses [Kokoro](https://github.com/hexgrad/kokoro) for its high-quality, natural-sounding text-to-speech synthesis. Huge thanks to the Kokoro team for making this possible.
|
- Abogen uses [Kokoro](https://github.com/hexgrad/kokoro) for its high-quality, natural-sounding text-to-speech synthesis. Huge thanks to the Kokoro team for making this possible.
|
||||||
- Thanks to [@wojiushixiaobai](https://github.com/wojiushixiaobai) for [Embedded Python](https://github.com/wojiushixiaobai/Python-Embed-Win64) packages. These modified packages include pip pre-installed, enabling Abogen to function as a standalone application without requiring users to separately install Python in Windows.
|
- Thanks to [@wojiushixiaobai](https://github.com/wojiushixiaobai) for [Embedded Python](https://github.com/wojiushixiaobai/Python-Embed-Win64) packages. These modified packages include pip pre-installed, enabling Abogen to function as a standalone application without requiring users to separately install Python in Windows.
|
||||||
|
- Thanks to creators of [EbookLib](https://github.com/aerkalov/ebooklib), a Python library for reading and writing ePub files, which is used for extracting text from ePub files.
|
||||||
- Special thanks to the [PyQt](https://www.riverbankcomputing.com/software/pyqt/) team for providing the cross-platform GUI toolkit that powers Abogen's interface.
|
- Special thanks to the [PyQt](https://www.riverbankcomputing.com/software/pyqt/) team for providing the cross-platform GUI toolkit that powers Abogen's interface.
|
||||||
|
- Icons: [US](https://icons8.com/icon/aRiu1GGi6Aoe/usa), [Great Britain](https://icons8.com/icon/t3NE3BsOAQwq/great-britain), [Spain](https://icons8.com/icon/ly7tzANRt33n/spain), [France](https://icons8.com/icon/3muzEmi4dpD5/france), [India](https://icons8.com/icon/esGVrxg9VCJ1/india), [Italy](https://icons8.com/icon/PW8KZnP7qXzO/italy), [Japan](https://icons8.com/icon/McQbrq9qaQye/japan), [Brazil](https://icons8.com/icon/zHmH8HpOmM90/brazil), [China](https://icons8.com/icon/Ej50Oe3crXwF/china), [Female](https://icons8.com/icon/uI49hxbpxTkp/female), [Male](https://icons8.com/icon/12351/male) and [Voice Id](https://icons8.com/icon/GskSeVoroQ7u/voice-id) icons by [Icons8](https://icons8.com/).
|
||||||
|
|
||||||
## `License`
|
## `License`
|
||||||
This project is available under the MIT License - see the [LICENSE](https://github.com/denizsafak/abogen/blob/main/LICENSE) file for details.
|
This project is available under the MIT License - see the [LICENSE](https://github.com/denizsafak/abogen/blob/main/LICENSE) file for details.
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
1.0.2
|
1.0.3
|
||||||
|
After Width: | Height: | Size: 442 B |
|
After Width: | Height: | Size: 571 B |
|
After Width: | Height: | Size: 786 B |
|
After Width: | Height: | Size: 521 B |
|
After Width: | Height: | Size: 372 B |
|
After Width: | Height: | Size: 465 B |
|
After Width: | Height: | Size: 381 B |
|
After Width: | Height: | Size: 441 B |
|
After Width: | Height: | Size: 617 B |
|
After Width: | Height: | Size: 431 B |
|
After Width: | Height: | Size: 426 B |
|
After Width: | Height: | Size: 241 B |
|
After Width: | Height: | Size: 331 B |
@@ -102,16 +102,3 @@ SAMPLE_VOICE_TEXTS = {
|
|||||||
"p": "Este é um exemplo da voz selecionada.",
|
"p": "Este é um exemplo da voz selecionada.",
|
||||||
"z": "这是所选语音的示例。",
|
"z": "这是所选语音的示例。",
|
||||||
}
|
}
|
||||||
|
|
||||||
# flags mapping for voice display
|
|
||||||
FLAGS = {
|
|
||||||
"a": "🇺🇸",
|
|
||||||
"b": "🇬🇧",
|
|
||||||
"e": "🇪🇸",
|
|
||||||
"f": "🇫🇷",
|
|
||||||
"h": "🇮🇳",
|
|
||||||
"i": "🇮🇹",
|
|
||||||
"j": "🇯🇵",
|
|
||||||
"p": "🇧🇷",
|
|
||||||
"z": "🇨🇳",
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,16 +4,19 @@ import tempfile
|
|||||||
import time
|
import time
|
||||||
import chardet
|
import chardet
|
||||||
import charset_normalizer
|
import charset_normalizer
|
||||||
|
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
|
||||||
import soundfile as sf
|
import soundfile as sf
|
||||||
from utils import clean_text
|
from utils import clean_text
|
||||||
from constants import PROGRAM_NAME, LANGUAGE_DESCRIPTIONS, SAMPLE_VOICE_TEXTS
|
from constants import PROGRAM_NAME, LANGUAGE_DESCRIPTIONS, SAMPLE_VOICE_TEXTS
|
||||||
|
from voice_formulas import get_new_voice
|
||||||
|
|
||||||
|
|
||||||
def get_sample_voice_text(lang_code):
|
def get_sample_voice_text(lang_code):
|
||||||
return SAMPLE_VOICE_TEXTS.get(lang_code, SAMPLE_VOICE_TEXTS["a"])
|
return SAMPLE_VOICE_TEXTS.get(lang_code, SAMPLE_VOICE_TEXTS["a"])
|
||||||
|
|
||||||
|
|
||||||
def detect_encoding(file_path):
|
def detect_encoding(file_path):
|
||||||
with open(file_path, "rb") as f:
|
with open(file_path, "rb") as f:
|
||||||
raw_data = f.read()
|
raw_data = f.read()
|
||||||
@@ -149,6 +152,9 @@ class ConversionThread(QThread):
|
|||||||
self.max_subtitle_words = 50 # Default value, will be overridden from GUI
|
self.max_subtitle_words = 50 # Default value, will be overridden from GUI
|
||||||
|
|
||||||
def run(self):
|
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:
|
try:
|
||||||
# Show configuration
|
# Show configuration
|
||||||
self.log_updated.emit("Configuration:")
|
self.log_updated.emit("Configuration:")
|
||||||
@@ -171,9 +177,7 @@ class ConversionThread(QThread):
|
|||||||
self.log_updated.emit(f"- Output format: {self.output_format}")
|
self.log_updated.emit(f"- Output format: {self.output_format}")
|
||||||
self.log_updated.emit(f"- Save option: {self.save_option}")
|
self.log_updated.emit(f"- Save option: {self.save_option}")
|
||||||
if self.replace_single_newlines:
|
if self.replace_single_newlines:
|
||||||
self.log_updated.emit(
|
self.log_updated.emit(f"- Replace single newlines: Yes")
|
||||||
f"- Replace single newlines: Yes"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Display save_chapters_separately flag if it's set
|
# Display save_chapters_separately flag if it's set
|
||||||
if hasattr(self, "save_chapters_separately"):
|
if hasattr(self, "save_chapters_separately"):
|
||||||
@@ -205,7 +209,9 @@ class ConversionThread(QThread):
|
|||||||
text = self.file_name # Treat file_name as direct text input
|
text = self.file_name # Treat file_name as direct text input
|
||||||
else:
|
else:
|
||||||
encoding = detect_encoding(self.file_name)
|
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()
|
text = file.read()
|
||||||
|
|
||||||
# Clean up text using utility function
|
# Clean up text using utility function
|
||||||
@@ -278,11 +284,19 @@ class ConversionThread(QThread):
|
|||||||
base_path = self.display_path if self.display_path else self.file_name
|
base_path = self.display_path if self.display_path else self.file_name
|
||||||
base_name = os.path.splitext(os.path.basename(base_path))[0]
|
base_name = os.path.splitext(os.path.basename(base_path))[0]
|
||||||
if self.save_option == "Save to Desktop":
|
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":
|
elif self.save_option == "Save next to input file":
|
||||||
parent_dir = os.path.dirname(base_path)
|
parent_dir = os.path.dirname(base_path)
|
||||||
else:
|
else:
|
||||||
parent_dir = self.output_folder or os.getcwd()
|
parent_dir = self.output_folder or os.getcwd()
|
||||||
|
# Ensure the output folder exists, error if it doesn't
|
||||||
|
if not os.path.exists(parent_dir):
|
||||||
|
self.log_updated.emit(
|
||||||
|
(
|
||||||
|
f"Output folder does not exist: {parent_dir}",
|
||||||
|
"red",
|
||||||
|
)
|
||||||
|
)
|
||||||
# Find a unique suffix for both folder and merged file, always
|
# Find a unique suffix for both folder and merged file, always
|
||||||
counter = 1
|
counter = 1
|
||||||
while True:
|
while True:
|
||||||
@@ -342,9 +356,16 @@ class ConversionThread(QThread):
|
|||||||
|
|
||||||
# Set split_pattern to \n+ which will split on one or more newlines
|
# Set split_pattern to \n+ which will split on one or more newlines
|
||||||
split_pattern = r"\n+"
|
split_pattern = r"\n+"
|
||||||
|
|
||||||
|
# Check if the voice is a formula and load it if necessary
|
||||||
|
if "*" in self.voice:
|
||||||
|
loaded_voice = get_new_voice(tts, self.voice, self.use_gpu)
|
||||||
|
else:
|
||||||
|
loaded_voice = self.voice
|
||||||
|
|
||||||
for result in tts(
|
for result in tts(
|
||||||
chapter_text,
|
chapter_text,
|
||||||
voice=self.voice,
|
voice=loaded_voice,
|
||||||
speed=self.speed,
|
speed=self.speed,
|
||||||
split_pattern=split_pattern,
|
split_pattern=split_pattern,
|
||||||
):
|
):
|
||||||
@@ -477,7 +498,9 @@ class ConversionThread(QThread):
|
|||||||
chapter_srt_path = os.path.join(
|
chapter_srt_path = os.path.join(
|
||||||
chapters_out_dir, f"{chapter_filename}.srt"
|
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(
|
for i, (start, end, text) in enumerate(
|
||||||
chapter_subtitle_entries, 1
|
chapter_subtitle_entries, 1
|
||||||
):
|
):
|
||||||
@@ -518,7 +541,9 @@ class ConversionThread(QThread):
|
|||||||
srt_path = os.path.splitext(out_path)[0] + ".srt"
|
srt_path = os.path.splitext(out_path)[0] + ".srt"
|
||||||
sf.write(out_path, audio, 24000, format=self.output_format)
|
sf.write(out_path, audio, 24000, format=self.output_format)
|
||||||
if self.subtitle_mode != "Disabled":
|
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):
|
for i, (start, end, text) in enumerate(subtitle_entries, 1):
|
||||||
srt_file.write(
|
srt_file.write(
|
||||||
f"{i}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n"
|
f"{i}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n"
|
||||||
@@ -661,7 +686,14 @@ class VoicePreviewThread(QThread):
|
|||||||
error = pyqtSignal(str)
|
error = pyqtSignal(str)
|
||||||
|
|
||||||
def __init__(
|
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)
|
super().__init__(parent)
|
||||||
self.np_module = np_module
|
self.np_module = np_module
|
||||||
@@ -669,17 +701,26 @@ class VoicePreviewThread(QThread):
|
|||||||
self.lang_code = lang_code
|
self.lang_code = lang_code
|
||||||
self.voice = voice
|
self.voice = voice
|
||||||
self.speed = speed
|
self.speed = speed
|
||||||
self.temp_wav = None
|
self.use_gpu = use_gpu
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
try:
|
print(
|
||||||
tts = self.kpipeline_class(
|
f"\nVoice: {self.voice}\nLanguage: {self.lang_code}\nSpeed: {self.speed}\nGPU: {self.use_gpu}\n"
|
||||||
lang_code=self.lang_code, repo_id="hexgrad/Kokoro-82M"
|
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
device = "cuda" if self.use_gpu else "cpu"
|
||||||
|
tts = self.kpipeline_class(
|
||||||
|
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)
|
sample_text = get_sample_voice_text(self.lang_code)
|
||||||
audio_segments = []
|
audio_segments = []
|
||||||
for result in tts(
|
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)
|
audio_segments.append(result.audio)
|
||||||
if audio_segments:
|
if audio_segments:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import time
|
|||||||
import tempfile
|
import tempfile
|
||||||
import platform
|
import platform
|
||||||
import base64
|
import base64
|
||||||
|
import re
|
||||||
from PyQt5.QtWidgets import (
|
from PyQt5.QtWidgets import (
|
||||||
QApplication,
|
QApplication,
|
||||||
QWidget,
|
QWidget,
|
||||||
@@ -65,11 +66,13 @@ from constants import (
|
|||||||
GITHUB_URL,
|
GITHUB_URL,
|
||||||
PROGRAM_DESCRIPTION,
|
PROGRAM_DESCRIPTION,
|
||||||
LANGUAGE_DESCRIPTIONS,
|
LANGUAGE_DESCRIPTIONS,
|
||||||
FLAGS,
|
|
||||||
VOICES_INTERNAL,
|
VOICES_INTERNAL,
|
||||||
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
|
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION,
|
||||||
)
|
)
|
||||||
from threading import Thread
|
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
|
# Import ctypes for Windows-specific taskbar icon
|
||||||
if platform.system() == "Windows":
|
if platform.system() == "Windows":
|
||||||
@@ -456,8 +459,17 @@ class abogen(QWidget):
|
|||||||
self.selected_chapters = set()
|
self.selected_chapters = set()
|
||||||
self.last_opened_book_path = None # Track the last opened book path
|
self.last_opened_book_path = None # Track the last opened book path
|
||||||
self.last_output_path = None
|
self.last_output_path = None
|
||||||
|
# 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_voice = self.config.get("selected_voice", "af_heart")
|
||||||
self.selected_lang = self.selected_voice[0]
|
self.selected_lang = self.selected_voice[0] if self.selected_voice else None
|
||||||
self.is_converting = False
|
self.is_converting = False
|
||||||
self.subtitle_mode = self.config.get("subtitle_mode", "Sentence")
|
self.subtitle_mode = self.config.get("subtitle_mode", "Sentence")
|
||||||
self.max_subtitle_words = self.config.get(
|
self.max_subtitle_words = self.config.get(
|
||||||
@@ -465,8 +477,8 @@ class abogen(QWidget):
|
|||||||
) # Default max words per subtitle
|
) # Default max words per subtitle
|
||||||
self.selected_format = self.config.get("selected_format", "wav")
|
self.selected_format = self.config.get("selected_format", "wav")
|
||||||
self.use_gpu = self.config.get(
|
self.use_gpu = self.config.get(
|
||||||
"use_gpu", True
|
"use_gpu", True # Load GPU setting with default True
|
||||||
) # Load GPU setting with default True
|
)
|
||||||
self.replace_single_newlines = self.config.get("replace_single_newlines", False)
|
self.replace_single_newlines = self.config.get("replace_single_newlines", False)
|
||||||
self._pending_close_event = None
|
self._pending_close_event = None
|
||||||
self.gpu_ok = False # Initialize GPU availability status
|
self.gpu_ok = False # Initialize GPU availability status
|
||||||
@@ -486,17 +498,32 @@ class abogen(QWidget):
|
|||||||
self.initUI()
|
self.initUI()
|
||||||
self.speed_slider.setValue(int(self.config.get("speed", 1.00) * 100))
|
self.speed_slider.setValue(int(self.config.get("speed", 1.00) * 100))
|
||||||
self.update_speed_label()
|
self.update_speed_label()
|
||||||
|
# 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)
|
idx = self.voice_combo.findData(self.selected_voice)
|
||||||
if idx >= 0:
|
if idx >= 0:
|
||||||
self.voice_combo.setCurrentIndex(idx)
|
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:
|
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.setText(self.selected_output_folder)
|
||||||
self.save_path_label.show()
|
self.save_path_label.show()
|
||||||
self.subtitle_combo.setCurrentText(self.subtitle_mode)
|
self.subtitle_combo.setCurrentText(self.subtitle_mode)
|
||||||
# Enable/disable subtitle options based on selected language
|
# Enable/disable subtitle options based on selected language (profile or voice)
|
||||||
self.subtitle_combo.setEnabled(
|
enable = self.selected_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION
|
||||||
self.selected_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION
|
self.subtitle_combo.setEnabled(enable)
|
||||||
)
|
|
||||||
# loading gif for preview button
|
# loading gif for preview button
|
||||||
loading_gif_path = get_resource_path("abogen.assets", "loading.gif")
|
loading_gif_path = get_resource_path("abogen.assets", "loading.gif")
|
||||||
if loading_gif_path:
|
if loading_gif_path:
|
||||||
@@ -556,19 +583,26 @@ class abogen(QWidget):
|
|||||||
voice_layout.addWidget(QLabel("Select Voice:", self))
|
voice_layout.addWidget(QLabel("Select Voice:", self))
|
||||||
voice_row = QHBoxLayout()
|
voice_row = QHBoxLayout()
|
||||||
self.voice_combo = QComboBox(self)
|
self.voice_combo = QComboBox(self)
|
||||||
for v in VOICES_INTERNAL:
|
self.voice_combo.currentIndexChanged.connect(self.on_voice_combo_changed)
|
||||||
flag = FLAGS.get(v[0], "")
|
|
||||||
self.voice_combo.addItem(f"{flag} {v}", v)
|
|
||||||
self.voice_combo.setStyleSheet(
|
self.voice_combo.setStyleSheet(
|
||||||
"QComboBox { min-height: 20px; padding: 6px 12px; }"
|
"QComboBox { min-height: 20px; padding: 6px 12px; }"
|
||||||
)
|
)
|
||||||
self.voice_combo.currentIndexChanged.connect(self.on_voice_changed)
|
|
||||||
self.voice_combo.setToolTip(
|
self.voice_combo.setToolTip(
|
||||||
"The first character represents the language:\n"
|
"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'
|
'"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_row.addWidget(self.voice_combo)
|
||||||
|
|
||||||
|
# Voice formula button
|
||||||
|
self.btn_voice_formula_mixer = QPushButton(self)
|
||||||
|
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; }")
|
||||||
|
self.btn_voice_formula_mixer.clicked.connect(self.show_voice_formula_dialog)
|
||||||
|
voice_row.addWidget(self.btn_voice_formula_mixer)
|
||||||
|
|
||||||
# Play/Stop icons
|
# Play/Stop icons
|
||||||
def make_icon(color, shape):
|
def make_icon(color, shape):
|
||||||
pix = QPixmap(20, 20)
|
pix = QPixmap(20, 20)
|
||||||
@@ -627,10 +661,9 @@ class abogen(QWidget):
|
|||||||
)
|
)
|
||||||
self.subtitle_combo.setCurrentText(self.subtitle_mode)
|
self.subtitle_combo.setCurrentText(self.subtitle_mode)
|
||||||
self.subtitle_combo.currentTextChanged.connect(self.on_subtitle_mode_changed)
|
self.subtitle_combo.currentTextChanged.connect(self.on_subtitle_mode_changed)
|
||||||
# Enable/disable subtitle options based on selected language
|
# Enable/disable subtitle options based on selected language (profile or voice)
|
||||||
self.subtitle_combo.setEnabled(
|
enable = self.selected_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION
|
||||||
self.selected_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION
|
self.subtitle_combo.setEnabled(enable)
|
||||||
)
|
|
||||||
subtitle_layout.addWidget(self.subtitle_combo)
|
subtitle_layout.addWidget(self.subtitle_combo)
|
||||||
controls_layout.addLayout(subtitle_layout)
|
controls_layout.addLayout(subtitle_layout)
|
||||||
# Output format
|
# Output format
|
||||||
@@ -752,6 +785,7 @@ class abogen(QWidget):
|
|||||||
container_layout.addWidget(self.finish_widget)
|
container_layout.addWidget(self.finish_widget)
|
||||||
outer_layout.addWidget(container)
|
outer_layout.addWidget(container)
|
||||||
self.setLayout(outer_layout)
|
self.setLayout(outer_layout)
|
||||||
|
self.populate_profiles_in_voice_combo()
|
||||||
|
|
||||||
def open_file_dialog(self):
|
def open_file_dialog(self):
|
||||||
if self.is_converting:
|
if self.is_converting:
|
||||||
@@ -934,6 +968,87 @@ class abogen(QWidget):
|
|||||||
else:
|
else:
|
||||||
self.subtitle_combo.setEnabled(False)
|
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):
|
def convert_input_box_to_log(self):
|
||||||
self.input_box.hide()
|
self.input_box.hide()
|
||||||
self.log_text.show()
|
self.log_text.show()
|
||||||
@@ -1039,11 +1154,32 @@ class abogen(QWidget):
|
|||||||
else self.subtitle_mode
|
else self.subtitle_mode
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# if voice formula is not None, use the selected voice
|
||||||
|
if 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
|
||||||
|
# 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.conversion_thread = ConversionThread(
|
||||||
self.selected_file,
|
self.selected_file,
|
||||||
self.selected_lang,
|
selected_lang,
|
||||||
speed,
|
speed,
|
||||||
self.selected_voice,
|
voice_formula,
|
||||||
self.save_option,
|
self.save_option,
|
||||||
self.selected_output_folder,
|
self.selected_output_folder,
|
||||||
subtitle_mode=actual_subtitle_mode,
|
subtitle_mode=actual_subtitle_mode,
|
||||||
@@ -1061,7 +1197,9 @@ class abogen(QWidget):
|
|||||||
# Pass max_subtitle_words from config
|
# Pass max_subtitle_words from config
|
||||||
self.conversion_thread.max_subtitle_words = self.max_subtitle_words
|
self.conversion_thread.max_subtitle_words = self.max_subtitle_words
|
||||||
# Pass replace_single_newlines setting
|
# 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
|
# Pass chapter count for EPUB or PDF files
|
||||||
if self.selected_file_type in ["epub", "pdf"] and hasattr(
|
if self.selected_file_type in ["epub", "pdf"] and hasattr(
|
||||||
self, "selected_chapters"
|
self, "selected_chapters"
|
||||||
@@ -1292,6 +1430,7 @@ class abogen(QWidget):
|
|||||||
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)
|
||||||
|
self.btn_voice_formula_mixer.setEnabled(False) # Disable mixer button
|
||||||
self.btn_start.setEnabled(False) # Disable start button during preview
|
self.btn_start.setEnabled(False) # Disable start button during preview
|
||||||
# start loading animation
|
# start loading animation
|
||||||
self.loading_movie.start()
|
self.loading_movie.start()
|
||||||
@@ -1306,23 +1445,43 @@ class abogen(QWidget):
|
|||||||
# stop loading animation and restore icon on error
|
# stop loading animation and restore icon on error
|
||||||
if error:
|
if error:
|
||||||
self.loading_movie.stop()
|
self.loading_movie.stop()
|
||||||
self.btn_preview.setIcon(self.play_icon)
|
|
||||||
self._show_error_message_box(
|
self._show_error_message_box(
|
||||||
"Loading Error", f"Error loading numpy or KPipeline: {error}"
|
"Loading Error", f"Error loading numpy or KPipeline: {error}"
|
||||||
)
|
)
|
||||||
|
self.btn_preview.setIcon(self.play_icon)
|
||||||
self.btn_preview.setEnabled(True)
|
self.btn_preview.setEnabled(True)
|
||||||
self.btn_preview.setToolTip("Preview selected voice")
|
self.btn_preview.setToolTip("Preview selected voice")
|
||||||
self.voice_combo.setEnabled(True)
|
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
|
self.btn_start.setEnabled(True) # Re-enable start button on error
|
||||||
return
|
return
|
||||||
|
|
||||||
lang, voice, speed = (
|
# Support preview for voice profiles
|
||||||
self.selected_voice[0],
|
speed = self.speed_slider.value() / 100.0
|
||||||
self.selected_voice,
|
if self.mixed_voice_state:
|
||||||
self.speed_slider.value() / 100.0,
|
# 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
|
||||||
|
|
||||||
self.preview_thread = VoicePreviewThread(
|
self.preview_thread = VoicePreviewThread(
|
||||||
np_module, kpipeline_class, lang, voice, speed
|
np_module, kpipeline_class, lang, voice, speed, self.use_gpu
|
||||||
)
|
)
|
||||||
self.preview_thread.finished.connect(self._play_preview_audio)
|
self.preview_thread.finished.connect(self._play_preview_audio)
|
||||||
self.preview_thread.error.connect(self._preview_error)
|
self.preview_thread.error.connect(self._preview_error)
|
||||||
@@ -1332,13 +1491,15 @@ class abogen(QWidget):
|
|||||||
temp_wav = self.preview_thread.temp_wav
|
temp_wav = self.preview_thread.temp_wav
|
||||||
if not temp_wav:
|
if not temp_wav:
|
||||||
self.loading_movie.stop()
|
self.loading_movie.stop()
|
||||||
self.btn_preview.setIcon(self.play_icon)
|
|
||||||
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.setEnabled(True)
|
self.btn_preview.setIcon(self.play_icon)
|
||||||
self.btn_preview.setToolTip("Preview selected voice")
|
self.btn_preview.setToolTip("Preview selected voice")
|
||||||
|
self.btn_preview.setEnabled(True)
|
||||||
self.voice_combo.setEnabled(True)
|
self.voice_combo.setEnabled(True)
|
||||||
|
self.btn_voice_formula_mixer.setEnabled(True) # Re-enable mixer button
|
||||||
self.btn_start.setEnabled(True)
|
self.btn_start.setEnabled(True)
|
||||||
return
|
return
|
||||||
# stop loading animation, switch to stop icon
|
# stop loading animation, switch to stop icon
|
||||||
@@ -1388,6 +1549,7 @@ class abogen(QWidget):
|
|||||||
self.btn_preview.setToolTip("Preview selected voice")
|
self.btn_preview.setToolTip("Preview selected voice")
|
||||||
self.btn_preview.setEnabled(True)
|
self.btn_preview.setEnabled(True)
|
||||||
self.voice_combo.setEnabled(True)
|
self.voice_combo.setEnabled(True)
|
||||||
|
self.btn_voice_formula_mixer.setEnabled(True) # Re-enable mixer button
|
||||||
self.btn_start.setEnabled(True)
|
self.btn_start.setEnabled(True)
|
||||||
|
|
||||||
def _preview_error(self, msg):
|
def _preview_error(self, msg):
|
||||||
@@ -1524,7 +1686,7 @@ class abogen(QWidget):
|
|||||||
menu.addAction(add_shortcut_action)
|
menu.addAction(add_shortcut_action)
|
||||||
|
|
||||||
# Add reveal config option
|
# 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)
|
reveal_config_action.triggered.connect(self.reveal_config_in_explorer)
|
||||||
menu.addAction(reveal_config_action)
|
menu.addAction(reveal_config_action)
|
||||||
|
|
||||||
@@ -1597,7 +1759,7 @@ class abogen(QWidget):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# where to put the .lnk
|
# 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")
|
shortcut_path = os.path.join(desktop, "abogen.lnk")
|
||||||
|
|
||||||
# target exe
|
# target exe
|
||||||
@@ -1657,6 +1819,38 @@ class abogen(QWidget):
|
|||||||
self.config["check_updates"] = checked
|
self.config["check_updates"] = checked
|
||||||
save_config(self.config)
|
save_config(self.config)
|
||||||
|
|
||||||
|
def show_voice_formula_dialog(self):
|
||||||
|
# If a profile is selected in combo, open mixer with that profile
|
||||||
|
initial_state = None
|
||||||
|
selected_profile = self.selected_profile_name
|
||||||
|
if selected_profile:
|
||||||
|
entry = load_profiles().get(selected_profile, {})
|
||||||
|
# support new dict format
|
||||||
|
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
|
||||||
|
else:
|
||||||
|
initial_state = [(self.selected_voice, 1.0)] if self.selected_voice else []
|
||||||
|
dialog = VoiceFormulaDialog(
|
||||||
|
self, initial_state=initial_state, selected_profile=selected_profile
|
||||||
|
)
|
||||||
|
if dialog.exec_() == QDialog.Accepted:
|
||||||
|
# After OK, select the profile in combo and update config
|
||||||
|
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):
|
def show_about_dialog(self):
|
||||||
"""Show an About dialog with program information including GitHub link."""
|
"""Show an About dialog with program information including GitHub link."""
|
||||||
# Get application icon for dialog
|
# Get application icon for dialog
|
||||||
|
|||||||
@@ -32,6 +32,14 @@ def get_resource_path(package, resource):
|
|||||||
except (ImportError, FileNotFoundError):
|
except (ImportError, FileNotFoundError):
|
||||||
pass
|
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
|
# Fallback to local file system
|
||||||
try:
|
try:
|
||||||
# Extract the subdirectory from package name (e.g., 'assets' from 'abogen.assets')
|
# 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():
|
def get_version():
|
||||||
"""Return the current version of the application."""
|
"""Return the current version of the application."""
|
||||||
try:
|
try:
|
||||||
with open(get_resource_path("abogen", "VERSION"), "r") as f:
|
with open(get_resource_path("/", "VERSION"), "r") as f:
|
||||||
return f.read().strip()
|
return f.read().strip()
|
||||||
except Exception:
|
except Exception:
|
||||||
return "Unknown"
|
return "Unknown"
|
||||||
|
|||||||
@@ -0,0 +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"
|
||||||
|
# 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
|
||||||
|
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("+")
|
||||||
|
|
||||||
|
for term in voices:
|
||||||
|
# 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)
|
||||||
|
total_sum = sum(float(weight) for weight in weights)
|
||||||
|
return total_sum
|
||||||
@@ -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)
|
||||||
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 28 KiB |
@@ -15,9 +15,10 @@ keywords = ["audiobook", "epub", "pdf", "text-to-speech", "subtitle", "tts", "ko
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"PyQt5>=5.15.11",
|
"PyQt5>=5.15.11",
|
||||||
"kokoro>=0.9.4",
|
"kokoro>=0.9.4",
|
||||||
"ebooklib>=0.18",
|
"ebooklib>=0.19",
|
||||||
"beautifulsoup4>=4.13.4",
|
"beautifulsoup4>=4.13.4",
|
||||||
"PyMuPDF>=1.25.5",
|
"PyMuPDF>=1.25.5",
|
||||||
|
"platformdirs>=4.3.7",
|
||||||
"soundfile>=0.13.1",
|
"soundfile>=0.13.1",
|
||||||
"pygame>=2.6.1",
|
"pygame>=2.6.1",
|
||||||
"charset_normalizer>=3.4.1",
|
"charset_normalizer>=3.4.1",
|
||||||
|
|||||||