Merge pull request #13 from denizsafak/beta

- Added new output format: `m4b`, enabling chapter metadata in audiobooks. Special thanks to @jborza for implementing this feature in PR #10.
- Better approach for determining the correct configuration folder for Linux and MacOS, using platformdirs. (Fixes Docker issue #12)
- Improvements in documentation and code.
This commit is contained in:
Deniz Şafak
2025-05-04 11:00:44 +03:00
committed by GitHub
7 changed files with 112 additions and 24 deletions
+3 -1
View File
@@ -1 +1,3 @@
- Better approach for detemining the correct configuration folder for Linux and MacOS, using platformdirs. - Added new output format: `m4b`, enabling chapter metadata in audiobooks. Special thanks to @jborza for implementing this feature in PR #10.
- Better approach for determining the correct configuration folder for Linux and MacOS, using platformdirs. (Fixes Docker issue #12)
- Improvements in documentation and code.
+7 -3
View File
@@ -85,7 +85,7 @@ Heres Abogen in action: in this demo, it processes 3,000 characters of tex
- **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. - **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`, `.MP3`, and `M4B (with chapters)` (Special thanks to @jborza for chapter support in PR #10)
- **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`
- **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**:
@@ -185,6 +185,10 @@ Abogen launches automatically inside the container.
- You can use `/shared` directory to share files between your host and the container. - 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`. - For later use, start it with `docker start abogen` and stop it with `docker stop abogen`.
Known issues:
- Audio preview is not working inside container (ALSA error).
- `Open temp directory` and `Open configuration directory` options in settings not working. (Tried pcmanfm, did not work with 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/).) (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`
@@ -196,9 +200,9 @@ Abogen is a standalone project, but it is inspired by and shares some similariti
## `Roadmap` ## `Roadmap`
- [ ] Improve PDF support for better text extraction. - [ ] Improve PDF support for better text extraction.
- [ ] Add chapter metadata for .m4a files using ffmpeg-bin. - [x] Add chapter metadata for .m4a files. (Issue #9, PR #10)
- [ ] Add support for different languages in GUI. - [ ] Add support for different languages in GUI.
- [x] Add voice formula feature that enables mixing different voice models. #1 - [x] Add voice formula feature that enables mixing different voice models. (Issue #1, PR #5)
- [ ] Add support for kokoro-onnx. - [ ] Add support for kokoro-onnx.
- [ ] Add dark mode. - [ ] Add dark mode.
+1 -1
View File
@@ -221,7 +221,7 @@ if errorlevel 1 (
:: Install setup requirements :: Install setup requirements
echo Installing setup requirements... echo Installing setup requirements...
%PYTHON_CONSOLE_PATH% -m pip install --upgrade setuptools wheel sphinx hatchling --no-warn-script-location %PYTHON_CONSOLE_PATH% -m pip install --upgrade setuptools wheel sphinx hatchling progress --no-warn-script-location
if errorlevel 1 ( if errorlevel 1 (
echo Failed to install setup requirements. echo Failed to install setup requirements.
pause pause
+1 -1
View File
@@ -1 +1 @@
1.0.4 1.0.5
+87 -11
View File
@@ -11,7 +11,8 @@ 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 from voice_formulas import get_new_voice
import static_ffmpeg
import threading # for efficient waiting
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"])
@@ -128,6 +129,7 @@ class ConversionThread(QThread):
use_gpu=True, use_gpu=True,
): # Add use_gpu parameter ): # Add use_gpu parameter
super().__init__() super().__init__()
self._chapter_options_event = threading.Event()
self.np = np_module self.np = np_module
self.KPipeline = kpipeline_class self.KPipeline = kpipeline_class
self.file_name = file_name self.file_name = file_name
@@ -252,18 +254,12 @@ class ConversionThread(QThread):
and not self.chapter_options_set and not self.chapter_options_set
): ):
self.waiting_for_user_input = True # Emit signal to main thread and wait
# Emit signal to main thread to show dialog
self.chapters_detected.emit(total_chapters) self.chapters_detected.emit(total_chapters)
self._chapter_options_event.wait()
# Wait for chapter options to be set
while self.waiting_for_user_input and not self.cancel_requested:
time.sleep(0.1)
if self.cancel_requested: if self.cancel_requested:
self.conversion_finished.emit("Cancelled", None) self.conversion_finished.emit("Cancelled", None)
return return
self.chapter_options_set = True self.chapter_options_set = True
# Log all detected chapters at the beginning # Log all detected chapters at the beginning
@@ -339,6 +335,12 @@ class ConversionThread(QThread):
# Initialize current segment counter # Initialize current segment counter
current_segment = 0 current_segment = 0
# Initialize chapter times
chapters_time = [
{"chapter": chapter[0], "start": 0.0, "end": 0.0}
for chapter in chapters
]
# Instead of processing the whole text, process by chapter # Instead of processing the whole text, process by chapter
for chapter_idx, (chapter_name, chapter_text) in enumerate(chapters, 1): for chapter_idx, (chapter_name, chapter_text) in enumerate(chapters, 1):
if total_chapters > 1: if total_chapters > 1:
@@ -354,6 +356,10 @@ class ConversionThread(QThread):
chapter_subtitle_entries = [] chapter_subtitle_entries = []
chapter_current_time = 0.0 chapter_current_time = 0.0
# chapter start time
chapter_time = chapters_time[chapter_idx - 1]
chapter_time["start"] = current_time
# 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+"
@@ -467,6 +473,9 @@ class ConversionThread(QThread):
# Update progress more frequently (after each result) # Update progress more frequently (after each result)
self.progress_updated.emit(percent, etr_str) self.progress_updated.emit(percent, etr_str)
# Update chapter end time
chapter_time["end"] = current_time
# Save the individual chapter output if save_chapters_separately is enabled # Save the individual chapter output if save_chapters_separately is enabled
if ( if (
save_chapters_separately save_chapters_separately
@@ -483,14 +492,20 @@ class ConversionThread(QThread):
# Concatenate chapter audio and save # Concatenate chapter audio and save
chapter_audio = self.np.concatenate(chapter_audio_segments) chapter_audio = self.np.concatenate(chapter_audio_segments)
# If output format is m4b, save chapter as wav
chapter_ext = self.output_format
chapter_format = self.output_format
if self.output_format == "m4b":
chapter_ext = "wav"
chapter_format = "wav"
chapter_out_path = os.path.join( chapter_out_path = os.path.join(
chapters_out_dir, f"{chapter_filename}.{self.output_format}" chapters_out_dir, f"{chapter_filename}.{chapter_ext}"
) )
sf.write( sf.write(
chapter_out_path, chapter_out_path,
chapter_audio, chapter_audio,
24000, 24000,
format=self.output_format, format=chapter_format,
) )
# Generate .srt subtitle file for chapter if not Disabled # Generate .srt subtitle file for chapter if not Disabled
@@ -531,6 +546,7 @@ class ConversionThread(QThread):
or not self.save_chapters_separately or not self.save_chapters_separately
or getattr(self, "merge_chapters_at_end", True) or getattr(self, "merge_chapters_at_end", True)
) )
m4b_picked = False # Ensure m4b_picked is always defined
if audio_segments and merge_chapters: if audio_segments and merge_chapters:
self.log_updated.emit("\nFinalizing audio file...\n") self.log_updated.emit("\nFinalizing audio file...\n")
audio = self.np.concatenate(audio_segments) audio = self.np.concatenate(audio_segments)
@@ -539,7 +555,16 @@ class ConversionThread(QThread):
out_filename = f"{base_name}{suffix}.{self.output_format}" out_filename = f"{base_name}{suffix}.{self.output_format}"
out_path = os.path.join(out_dir, out_filename) out_path = os.path.join(out_dir, out_filename)
srt_path = os.path.splitext(out_path)[0] + ".srt" srt_path = os.path.splitext(out_path)[0] + ".srt"
# in case the user picked m4b format, we need to change the output format to wav
if self.output_format == "m4b":
out_path = os.path.splitext(out_path)[0] + ".wav"
self.output_format = "wav"
m4b_picked = True
sf.write(out_path, audio, 24000, format=self.output_format) sf.write(out_path, audio, 24000, format=self.output_format)
if m4b_picked:
out_path = self._generate_m4b_with_chapters(out_path, chapters_time)
if self.subtitle_mode != "Disabled": if self.subtitle_mode != "Disabled":
with open( with open(
srt_path, "w", encoding="utf-8", errors="replace" srt_path, "w", encoding="utf-8", errors="replace"
@@ -579,6 +604,57 @@ class ConversionThread(QThread):
self.save_chapters_separately = options["save_chapters_separately"] self.save_chapters_separately = options["save_chapters_separately"]
self.merge_chapters_at_end = options["merge_chapters_at_end"] self.merge_chapters_at_end = options["merge_chapters_at_end"]
self.waiting_for_user_input = False self.waiting_for_user_input = False
self._chapter_options_event.set()
def _generate_m4b_with_chapters(self, out_path, chapters_time):
# If there is only one chapter, skip adding chapters and just return the wav file path
if not chapters_time or len(chapters_time) <= 1:
self.log_updated.emit(
("File contains only one chapter or no chapters were detected. The audio will be saved as a standard .wav file instead.\n", "red")
)
return out_path
# generate chapters.txt in the same folder as the output file
chapters_info_path = os.path.splitext(out_path)[0] + "_chapters.txt"
with open(chapters_info_path, "w", encoding="utf-8") as f:
f.write(';FFMETADATA1\n') # required header for ffmpeg
for chapter in chapters_time:
f.write(f"[CHAPTER]\n")
f.write(f"TIMEBASE=1/10\n")
# use 10th of second for start/end time
f.write(f"START={int(chapter["start"]*10)}\n")
f.write(f"END={int(chapter["end"]*10)}\n")
f.write(f"title={chapter["chapter"]}\n\n")
# call ffmpeg to merge the chapters into the output file
# ffmpeg installed on first call to add_paths()
static_ffmpeg.add_paths()
import subprocess
out_path_m4b = os.path.splitext(out_path)[0] + ".m4b"
# ffmpeg -i input.m4b -i ch.txt -map 0:a -map_chapters 1 output.m4b
ffmpeg_cmd = [
"ffmpeg",
"-i", out_path,
"-i", chapters_info_path,
"-map", "0:a",
"-map_chapters", "1",
out_path_m4b
]
self.log_updated.emit("Adding chapters to the audio file...\n")
result = subprocess.run(ffmpeg_cmd, capture_output=True, text=True)
# Check for errors in the ffmpeg command
if result.returncode != 0:
self.log_updated.emit((f"FFmpeg error: {result.stderr}", "red"))
# Log error but continue with original file
self.log_updated.emit(("Falling back to the original audio file without chapters\n", "orange"))
return out_path
else:
self.log_updated.emit(("Successfully added chapters to the audio file.\n", "green"))
# delete the chapters path and original (wav) file
os.remove(chapters_info_path)
os.remove(out_path)
# the new file is in m4b format - use that for messages
return out_path_m4b
def _srt_time(self, t): def _srt_time(self, t):
"""Helper function to format time for SRT files""" """Helper function to format time for SRT files"""
+11 -6
View File
@@ -670,13 +670,18 @@ class abogen(QWidget):
format_layout = QVBoxLayout() format_layout = QVBoxLayout()
format_layout.addWidget(QLabel("Output Format:", self)) format_layout.addWidget(QLabel("Output Format:", self))
self.format_combo = QComboBox(self) self.format_combo = QComboBox(self)
self.format_combo.setStyleSheet( self.format_combo.setStyleSheet("QComboBox { min-height: 20px; padding: 6px 12px; }")
"QComboBox { min-height: 20px; padding: 6px 12px; }" # Add items with display labels and underlying keys
for key, label in [("wav","wav"),("flac","flac"),("mp3","mp3"),("m4b","m4b (with chapters)")]:
self.format_combo.addItem(label, key)
# Initialize selection by matching saved key
idx = self.format_combo.findData(self.selected_format)
if idx >= 0:
self.format_combo.setCurrentIndex(idx)
# Map selection back to key on change
self.format_combo.currentIndexChanged.connect(
lambda i: self.on_format_changed(self.format_combo.itemData(i))
) )
format_options = ["wav", "flac", "mp3"]
self.format_combo.addItems(format_options)
self.format_combo.setCurrentText(self.selected_format)
self.format_combo.currentTextChanged.connect(self.on_format_changed)
format_layout.addWidget(self.format_combo) format_layout.addWidget(self.format_combo)
controls_layout.addLayout(format_layout) controls_layout.addLayout(format_layout)
# Save location # Save location
+2 -1
View File
@@ -22,7 +22,8 @@ dependencies = [
"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",
"chardet>=5.2.0" "chardet>=5.2.0",
"static_ffmpeg>=2.13"
] ]
classifiers = [ classifiers = [