Merge pull request #11 from denizsafak/main

Update beta branch
This commit is contained in:
Deniz Şafak
2025-05-03 22:57:27 +03:00
committed by GitHub
9 changed files with 178 additions and 40 deletions
+1 -4
View File
@@ -1,4 +1 @@
- Fixed the issue when a voice is selected, the voice mixer tries to pre-select that voice and ignores existing profiles. - Better approach for detemining the correct configuration folder for Linux and MacOS, using platformdirs.
- Fixed the error while renaming the default "New profile" in the voice mixer.
- Prevented using special characters in the profile name to avoid conflicts.
- Improved invalid profile handling in the voice mixer.
+58 -1
View File
@@ -90,7 +90,7 @@ Heres Abogen in action: in this demo, it processes 3,000 characters of tex
- **Chapter Control**: Select specific `chapters` from ePUBs or `chapters + pages` from PDFs. - **Chapter Control**: Select specific `chapters` from ePUBs or `chapters + pages` from PDFs.
- **Options**: - **Options**:
- **Replace single newlines with spaces**: Replaces single newlines with spaces in the text. This is useful for texts that have imaginary line breaks. - **Replace single newlines with spaces**: Replaces single newlines with spaces in the text. This is useful for texts that have imaginary line breaks.
- **Configure max words per subtitle**: Automatically configures the maximum number of words per subtitle entry. - **Configure max words per subtitle**: Configures the maximum number of words per subtitle entry.
- **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.
@@ -103,6 +103,30 @@ Heres Abogen in action: in this demo, it processes 3,000 characters of tex
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)) 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))
## `About Chapter Markers`
When you process ePUB or PDF files, abogen converts them into text files stored in your temporary directory. When you click "Edit," you're actually modifying these converted text files. In these text files, you'll notice tags that look like this:
```
<CHAPTER_MARKER:Chapter Title>
```
These are chapter markers. They are automatically added when you process ePUB or PDF files, based on the chapters you select. They serve an important purpose:
- Allow you to split the text into separate audio files for each chapter
- Save time by letting you reprocess only specific chapters if errors occur, rather than the entire file
You can manually add these markers to plain text files for the same benefits. Simply include them in your text like this:
```
<CHAPTER_MARKER:Introduction>
This is the beginning of my text...
<CHAPTER_MARKER:Main Content>
Here's another part...
```
When you process the text file, abogen will detect these markers automatically and ask if you want to save each chapter separately and create a merged version.
![Abogen Chapter Marker](https://raw.githubusercontent.com/denizsafak/abogen/refs/heads/main/demo/chapter_marker.png)
## `Supported Languages` ## `Supported Languages`
``` ```
# 🇺🇸 'a' => American English, 🇬🇧 'b' => British English # 🇺🇸 'a' => American English, 🇬🇧 'b' => British English
@@ -131,6 +155,38 @@ audio-samplerate=48000
volume-max=200 volume-max=200
``` ```
## `Docker Guide`
If you want to run Abogen in a Docker container:
1) [Download the repository](https://github.com/denizsafak/abogen/archive/refs/heads/main.zip) and extract, or clone it using git.
2) Go to `abogen` folder. You should see `Dockerfile` there.
3) Open your termminal in that directory and run the following commands:
```bash
# Build the Docker image:
docker build --progress plain -t abogen .
# Note that building the image may take a while.
# After building is complete, run the Docker container:
# Windows
docker run --name abogen -v %cd%:/shared -p 5800:5800 -p 5900:5900 --gpus all abogen
# Linux
docker run --name abogen -v $(pwd):/shared -p 5800:5800 -p 5900:5900 --gpus all abogen
# MacOS
docker run --name abogen -v $(pwd):/shared -p 5800:5800 -p 5900:5900 abogen
# We expose port 5800 for use by a web browser, 5900 if you want to connect with a VNC client.
```
Abogen launches automatically inside the container.
- You can access it via a web browser at `http://localhost:5800` or connect to it using a VNC client at `localhost:5900`.
- You can use `/shared` directory to share files between your host and the container.
- For later use, start it with `docker start abogen` and stop it with `docker stop abogen`.
(Special thanks to [@geo38](https://www.reddit.com/user/geo38/) from Reddit, who provided the Dockerfile and instructions in [this comment](https://www.reddit.com/r/selfhosted/comments/1k8x1yo/comment/mpe0bz8/).)
## `Similar Projects` ## `Similar Projects`
Abogen is a standalone project, but it is inspired by and shares some similarities with other projects. Here are a few: Abogen is a standalone project, but it is inspired by and shares some similarities with other projects. Here are a few:
- [audiblez](https://github.com/santinic/audiblez): Generate audiobooks from e-books. **(Has CLI and GUI support)** - [audiblez](https://github.com/santinic/audiblez): Generate audiobooks from e-books. **(Has CLI and GUI support)**
@@ -160,6 +216,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
pip install build # Install the build package
python -m build # Builds the package in dist folder (optional) python -m build # Builds the package in dist folder (optional)
abogen # Opens the GUI abogen # Opens the GUI
``` ```
+50
View File
@@ -0,0 +1,50 @@
# Special thanks to @geo38 from Reddit, who provided this Dockerfile:
# https://www.reddit.com/r/selfhosted/comments/1k8x1yo/comment/mpe0bz8/
# Use a docker base image that runs a window manager that can be viewed
# outside the image with a web browser or VNC client.
# https://github.com/jlesage/docker-baseimage-gui
FROM jlesage/baseimage-gui:debian-12-v4
# Load stuff needed by abogen
RUN apt-get update \
&& apt-get install -y \
python3 \
python3-venv \
python3-pip \
python3-pyqt5 \
espeak-ng \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# The base image will run /startapp.sh on launch.
#
# The base image runs that script as user 'app' uid=1000. That user
# does not exist in the base image but is created at run time.
#
# We need to install abogen in python venv (requirement of newer python3).
#
# The python venv has to be writable by the 'app' user as abogen dynamically
# installs python packages, so create the venv as that user
#
# We intend to share the /shared directory with the host using a bind volume
# in order to access any source files and the created files.
RUN echo '#!/bin/bash\nsource /app/venv/bin/activate\nexec abogen' > /startapp.sh \
&& chmod 555 /startapp.sh \
&& mkdir /app /shared \
&& chown 1000:1000 /app /shared \
&& chmod 755 /app /shared
# Switch to user 1000 to create the virtual environment
USER 1000:1000
RUN python3 -m venv /app/venv
# Make /app the working dir, copy your project in, and install from “.”
COPY . /app/
WORKDIR /app
RUN /bin/bash -c "source venv/bin/activate && pip install ."
# Change back to root for the baseimage startup scripts
USER root
+1 -1
View File
@@ -1 +1 @@
1.0.3 1.0.4
+24 -5
View File
@@ -1417,7 +1417,6 @@ class abogen(QWidget):
if self.preview_playing: if self.preview_playing:
try: try:
import pygame import pygame
pygame.mixer.music.stop() pygame.mixer.music.stop()
except Exception: except Exception:
pass pass
@@ -1432,8 +1431,20 @@ class abogen(QWidget):
self.voice_combo.setEnabled(False) self.voice_combo.setEnabled(False)
self.btn_voice_formula_mixer.setEnabled(False) # Disable mixer button 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
self.loading_movie.start() # Start loading animation - ensure signal connection is always active
if hasattr(self, 'loading_movie'):
# Disconnect previous connections to avoid multiple connections
try:
self.loading_movie.frameChanged.disconnect()
except TypeError:
pass # Ignore error if not connected
# Reconnect the signal
self.loading_movie.frameChanged.connect(
lambda: self.btn_preview.setIcon(QIcon(self.loading_movie.currentPixmap()))
)
self.loading_movie.start()
def pipeline_loaded_callback(np_module, kpipeline_class, error): def pipeline_loaded_callback(np_module, kpipeline_class, error):
self._on_pipeline_loaded_for_preview(np_module, kpipeline_class, error) self._on_pipeline_loaded_for_preview(np_module, kpipeline_class, error)
@@ -1480,8 +1491,11 @@ class abogen(QWidget):
lang = self.selected_voice[0] lang = self.selected_voice[0]
voice = self.selected_voice 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( self.preview_thread = VoicePreviewThread(
np_module, kpipeline_class, lang, voice, speed, self.use_gpu np_module, kpipeline_class, lang, voice, speed, gpu_ok
) )
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)
@@ -1545,7 +1559,12 @@ class abogen(QWidget):
def _preview_cleanup(self): def _preview_cleanup(self):
self.preview_playing = False 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.setToolTip("Preview selected voice")
self.btn_preview.setEnabled(True) self.btn_preview.setEnabled(True)
self.voice_combo.setEnabled(True) self.voice_combo.setEnabled(True)
+12 -3
View File
@@ -66,10 +66,19 @@ def get_version():
# Define config path # Define config path
def get_user_config_path(): def get_user_config_path():
if os.name == "nt": from platformdirs import user_config_dir
config_dir = os.path.join(os.environ["APPDATA"], "abogen") # TODO Config directory is changed for Linux and MacOS. But if old config exists, it will be used.
# On nonWindows, prefer ~/.config/abogen if it already exists
if platform.system() != "Windows":
custom_dir = os.path.join(os.path.expanduser("~"), ".config", "abogen")
if os.path.exists(custom_dir):
config_dir = custom_dir
else:
config_dir = user_config_dir("abogen", appauthor=False, roaming=True)
else: else:
config_dir = os.path.join(os.path.expanduser("~"), ".config", "abogen") # Windows and fallback case
config_dir = user_config_dir("abogen", appauthor=False, roaming=True)
os.makedirs(config_dir, exist_ok=True) os.makedirs(config_dir, exist_ok=True)
return os.path.join(config_dir, "config.json") return os.path.join(config_dir, "config.json")
+31 -25
View File
@@ -475,7 +475,6 @@ class VoiceFormulaDialog(QDialog):
self.add_voices(initial_state or []) self.add_voices(initial_state or [])
self.update_weighted_sums() self.update_weighted_sums()
self.update_subtitle_combo_enabled()
# assemble splitter # assemble splitter
splitter.addWidget(profile_widget) splitter.addWidget(profile_widget)
@@ -496,8 +495,6 @@ class VoiceFormulaDialog(QDialog):
for vm in self.voice_mixers: for vm in self.voice_mixers:
vm.spin_box.valueChanged.connect(self.mark_profile_modified) vm.spin_box.valueChanged.connect(self.mark_profile_modified)
vm.checkbox.stateChanged.connect(lambda *_: self.mark_profile_modified()) vm.checkbox.stateChanged.connect(lambda *_: self.mark_profile_modified())
vm.spin_box.valueChanged.connect(self.update_subtitle_combo_enabled)
vm.checkbox.stateChanged.connect(self.update_subtitle_combo_enabled)
def keyPressEvent(self, event): def keyPressEvent(self, event):
# Bind Delete key to delete_profile when a profile is selected # Bind Delete key to delete_profile when a profile is selected
@@ -682,8 +679,6 @@ class VoiceFormulaDialog(QDialog):
voice_mixer.checkbox.stateChanged.connect( voice_mixer.checkbox.stateChanged.connect(
lambda *_: self.mark_profile_modified() lambda *_: self.mark_profile_modified()
) )
voice_mixer.spin_box.valueChanged.connect(self.update_subtitle_combo_enabled)
voice_mixer.checkbox.stateChanged.connect(self.update_subtitle_combo_enabled)
return voice_mixer return voice_mixer
def handle_voice_checkbox(self, voice_mixer, state): def handle_voice_checkbox(self, voice_mixer, state):
@@ -743,20 +738,6 @@ class VoiceFormulaDialog(QDialog):
self.error_label.show() self.error_label.show()
self.weighted_sums_container.hide() self.weighted_sums_container.hide()
def update_subtitle_combo_enabled(self):
# Only enable subtitle_combo if at least one selected voice is from supported languages
selected_langs = set()
for vm in self.voice_mixers:
if vm.checkbox.isChecked() and vm.spin_box.value() > 0:
lang_code = vm.voice_name[0]
selected_langs.add(lang_code)
enable = any(
lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION
for lang in selected_langs
)
if self.subtitle_combo:
self.subtitle_combo.setEnabled(enable)
def disable_voice_by_name(self, voice_name): def disable_voice_by_name(self, voice_name):
for mixer in self.voice_mixers: for mixer in self.voice_mixers:
if mixer.voice_name == voice_name: if mixer.voice_name == voice_name:
@@ -819,7 +800,6 @@ class VoiceFormulaDialog(QDialog):
# sync enabled state # sync enabled state
vm.toggle_inputs() vm.toggle_inputs()
self.update_weighted_sums() self.update_weighted_sums()
self.update_subtitle_combo_enabled()
def save_profile_by_name(self, name): def save_profile_by_name(self, name):
profiles = load_profiles() profiles = load_profiles()
@@ -1192,8 +1172,8 @@ class VoiceFormulaDialog(QDialog):
def rename_profile(self, item): def rename_profile(self, item):
name = item.text().lstrip("*") name = item.text().lstrip("*")
# block if profile has unsaved changes # block if profile has unsaved changes and it's not a virtual New profile
if self._profile_dirty.get(name, False): if self._profile_dirty.get(name, False) and not (self._virtual_new_profile and name == "New profile"):
QMessageBox.warning( QMessageBox.warning(
self, "Unsaved Changes", "Please save the profile before renaming." self, "Unsaved Changes", "Please save the profile before renaming."
) )
@@ -1212,13 +1192,39 @@ class VoiceFormulaDialog(QDialog):
if not re.match(r'^[\w\- ]+$', new): if not re.match(r'^[\w\- ]+$', new):
QMessageBox.warning(self, "Invalid Name", "Profile name can only contain letters, numbers, spaces, underscores, and hyphens.") QMessageBox.warning(self, "Invalid Name", "Profile name can only contain letters, numbers, spaces, underscores, and hyphens.")
continue continue
profiles = load_profiles() profiles = load_profiles()
if new in profiles: if new in profiles:
QMessageBox.warning(self, "Duplicate Name", "Profile already exists.") QMessageBox.warning(self, "Duplicate Name", "Profile already exists.")
continue continue
profiles[new] = profiles.pop(old)
save_profiles(profiles) # Special case for renaming the virtual "New profile"
item.setText(new) if self._virtual_new_profile and name == "New profile":
# Create the profile with the new name
profiles[new] = {
"voices": self.get_selected_voices(),
"language": self.language_combo.currentData(),
}
save_profiles(profiles)
# Update tracking properties
self._virtual_new_profile = False
self._profile_dirty.pop("New profile", None)
self._profile_dirty[new] = False
# Update the current profile name
self.current_profile = new
item.setText(new)
else:
# Standard renaming for regular profiles
profiles[new] = profiles.pop(old)
save_profiles(profiles)
item.setText(new)
# Update the current profile name if it was renamed
if self.current_profile == old:
self.current_profile = new
parent = self.parent() parent = self.parent()
if hasattr(parent, "populate_profiles_in_voice_combo"): if hasattr(parent, "populate_profiles_in_voice_combo"):
parent.populate_profiles_in_voice_combo() parent.populate_profiles_in_voice_combo()
+1 -1
View File
@@ -1,6 +1,6 @@
import os import os
import json import json
from utils import get_user_config_path, get_resource_path from utils import get_user_config_path
def _get_profiles_path(): def _get_profiles_path():
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB