From e09535fa83e5fa023a68bacfe6bf1db73690baa4 Mon Sep 17 00:00:00 2001 From: Juraj Borza Date: Sat, 3 May 2025 19:26:19 +0200 Subject: [PATCH 01/11] Add chapter metadata - added m4b format, generating chapters info and converting via ffmpeg --- abogen/conversion.py | 67 ++++++++++++++++++++++++++++++++++++++++++-- abogen/gui.py | 2 +- pyproject.toml | 3 +- 3 files changed, 68 insertions(+), 4 deletions(-) diff --git a/abogen/conversion.py b/abogen/conversion.py index e4d8ba4..1b601a1 100644 --- a/abogen/conversion.py +++ b/abogen/conversion.py @@ -10,7 +10,7 @@ import soundfile as sf from utils import clean_text from constants import PROGRAM_NAME, LANGUAGE_DESCRIPTIONS, SAMPLE_VOICE_TEXTS from voice_formulas import get_new_voice - +import static_ffmpeg def get_sample_voice_text(lang_code): return SAMPLE_VOICE_TEXTS.get(lang_code, SAMPLE_VOICE_TEXTS["a"]) @@ -334,6 +334,12 @@ class ConversionThread(QThread): # Initialize current segment counter 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 for chapter_idx, (chapter_name, chapter_text) in enumerate(chapters, 1): if total_chapters > 1: @@ -349,6 +355,10 @@ class ConversionThread(QThread): chapter_subtitle_entries = [] 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 split_pattern = r"\n+" @@ -357,7 +367,7 @@ class ConversionThread(QThread): loaded_voice = get_new_voice(tts, self.voice, self.use_gpu) else: loaded_voice = self.voice - + for result in tts( chapter_text, voice=loaded_voice, @@ -462,6 +472,9 @@ class ConversionThread(QThread): # Update progress more frequently (after each result) 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 if ( save_chapters_separately @@ -532,7 +545,17 @@ class ConversionThread(QThread): out_filename = f"{base_name}{suffix}.{self.output_format}" out_path = os.path.join(out_dir, out_filename) 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": + picked_out_path = out_path + 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) + if m4b_picked: + 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: for i, (start, end, text) in enumerate(subtitle_entries, 1): @@ -571,6 +594,46 @@ class ConversionThread(QThread): self.merge_chapters_at_end = options["merge_chapters_at_end"] self.waiting_for_user_input = False + def _generate_m4b_with_chapters(self, out_path, chapters_time): + # 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 + ] + try: + result = subprocess.run(ffmpeg_cmd, capture_output=True, text=True) + # TODO Check for errors in the ffmpeg command + except subprocess.CalledProcessError as e: + self.log_updated.emit( + (f"FFmpeg error: {e.stderr}", "red") + ) + return out_path + # 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): """Helper function to format time for SRT files""" h = int(t // 3600) diff --git a/abogen/gui.py b/abogen/gui.py index 345b307..72f9bcd 100644 --- a/abogen/gui.py +++ b/abogen/gui.py @@ -652,7 +652,7 @@ class abogen(QWidget): self.format_combo.setStyleSheet( "QComboBox { min-height: 20px; padding: 6px 12px; }" ) - format_options = ["wav", "flac", "mp3"] + format_options = ["wav", "flac", "mp3", "m4b"] self.format_combo.addItems(format_options) self.format_combo.setCurrentText(self.selected_format) self.format_combo.currentTextChanged.connect(self.on_format_changed) diff --git a/pyproject.toml b/pyproject.toml index 03491fe..ddf2099 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,8 @@ dependencies = [ "soundfile>=0.13.1", "pygame>=2.6.1", "charset_normalizer>=3.4.1", - "chardet>=5.2.0" + "chardet>=5.2.0", + "static_ffmpeg>=2.13" ] classifiers = [ From 62aeaecf89b4e6cba8a5089791214e79d3e2cf30 Mon Sep 17 00:00:00 2001 From: Juraj Borza Date: Sat, 3 May 2025 19:34:35 +0200 Subject: [PATCH 02/11] Removed a variable that was unused in m4b generation --- abogen/conversion.py | 1 - 1 file changed, 1 deletion(-) diff --git a/abogen/conversion.py b/abogen/conversion.py index 404598f..e97fc4a 100644 --- a/abogen/conversion.py +++ b/abogen/conversion.py @@ -555,7 +555,6 @@ class ConversionThread(QThread): # in case the user picked m4b format, we need to change the output format to wav if self.output_format == "m4b": - picked_out_path = out_path out_path = os.path.splitext(out_path)[0] + ".wav" self.output_format = "wav" m4b_picked = True From 9aebdecc60099fb389440c9f9f112fed4c5a60e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C5=9Eafak?= Date: Sat, 3 May 2025 23:13:43 +0300 Subject: [PATCH 03/11] Fix progress error --- WINDOWS_INSTALL.bat | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WINDOWS_INSTALL.bat b/WINDOWS_INSTALL.bat index dbe8d6b..c29e37e 100644 --- a/WINDOWS_INSTALL.bat +++ b/WINDOWS_INSTALL.bat @@ -221,7 +221,7 @@ if errorlevel 1 ( :: Install 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 ( echo Failed to install setup requirements. pause From 9170d9ae3e574570bf4a3c10dc451fbae9d06830 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C5=9Eafak?= Date: Sat, 3 May 2025 23:15:38 +0300 Subject: [PATCH 04/11] Update readme --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 93493cb..ab4f1f9 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ With voice mixer, you can create custom voices by mixing different voice models. 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: ``` - +<> ``` 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 @@ -116,10 +116,10 @@ These are chapter markers. They are automatically added when you process ePUB or You can manually add these markers to plain text files for the same benefits. Simply include them in your text like this: ``` - +<> This is the beginning of my text... - +<> 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. From 4311ed51d497241ee1dc8e832296cddbb78d61bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C5=9Eafak?= Date: Sat, 3 May 2025 23:40:01 +0300 Subject: [PATCH 05/11] Fix m4b error in seperate chapters --- abogen/conversion.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/abogen/conversion.py b/abogen/conversion.py index e97fc4a..21597f2 100644 --- a/abogen/conversion.py +++ b/abogen/conversion.py @@ -496,14 +496,20 @@ class ConversionThread(QThread): # Concatenate chapter audio and save 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( - chapters_out_dir, f"{chapter_filename}.{self.output_format}" + chapters_out_dir, f"{chapter_filename}.{chapter_ext}" ) sf.write( chapter_out_path, chapter_audio, 24000, - format=self.output_format, + format=chapter_format, ) # Generate .srt subtitle file for chapter if not Disabled From 13cf6f20a44078ca3dd1c4267ae8e48e527f7058 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C5=9Eafak?= Date: Sun, 4 May 2025 00:03:53 +0300 Subject: [PATCH 06/11] Added informative messages and improved error handling for ffmpeg --- abogen/conversion.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/abogen/conversion.py b/abogen/conversion.py index 21597f2..2e2610b 100644 --- a/abogen/conversion.py +++ b/abogen/conversion.py @@ -634,14 +634,18 @@ class ConversionThread(QThread): "-map_chapters", "1", out_path_m4b ] - try: - result = subprocess.run(ffmpeg_cmd, capture_output=True, text=True) - # TODO Check for errors in the ffmpeg command - except subprocess.CalledProcessError as e: - self.log_updated.emit( - (f"FFmpeg error: {e.stderr}", "red") - ) + + 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) From a1ccd51ea09fdeb998b62e8837b9355c471b591e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C5=9Eafak?= Date: Sun, 4 May 2025 00:13:33 +0300 Subject: [PATCH 07/11] Fix dockerfile --- abogen/Dockerfile | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/abogen/Dockerfile b/abogen/Dockerfile index a4fd02a..9ac8ff1 100644 --- a/abogen/Dockerfile +++ b/abogen/Dockerfile @@ -4,7 +4,6 @@ # 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 @@ -36,15 +35,8 @@ RUN echo '#!/bin/bash\nsource /app/venv/bin/activate\nexec abogen' > /startapp.s && 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 base‐image startup scripts +RUN /bin/bash -c "source /app/venv/bin/activate && pip install abogen" +# Change back to user ROOT as the startup scripts inside base image needs it USER root \ No newline at end of file From 68b44a3516eaf15cc7c77be89c406b5f7d4341c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C5=9Eafak?= Date: Sun, 4 May 2025 00:51:56 +0300 Subject: [PATCH 08/11] Add label for m4b output format, improvements --- CHANGELOG.md | 3 ++- abogen/conversion.py | 13 +++++-------- abogen/gui.py | 17 +++++++++++------ 3 files changed, 18 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3d73b8..93f5ede 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1 +1,2 @@ -- Better approach for detemining the correct configuration folder for Linux and MacOS, using platformdirs. \ No newline at end of file +- Added support for chapter metadata in m4b audio files, enabling proper chapter navigation 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. \ No newline at end of file diff --git a/abogen/conversion.py b/abogen/conversion.py index 2e2610b..0612e0c 100644 --- a/abogen/conversion.py +++ b/abogen/conversion.py @@ -12,6 +12,7 @@ from utils import clean_text from constants import PROGRAM_NAME, LANGUAGE_DESCRIPTIONS, SAMPLE_VOICE_TEXTS from voice_formulas import get_new_voice import static_ffmpeg +import threading # for efficient waiting def get_sample_voice_text(lang_code): return SAMPLE_VOICE_TEXTS.get(lang_code, SAMPLE_VOICE_TEXTS["a"]) @@ -128,6 +129,7 @@ class ConversionThread(QThread): use_gpu=True, ): # Add use_gpu parameter super().__init__() + self._chapter_options_event = threading.Event() self.np = np_module self.KPipeline = kpipeline_class self.file_name = file_name @@ -252,18 +254,12 @@ class ConversionThread(QThread): and not self.chapter_options_set ): - self.waiting_for_user_input = True - # Emit signal to main thread to show dialog + # Emit signal to main thread and wait self.chapters_detected.emit(total_chapters) - - # Wait for chapter options to be set - while self.waiting_for_user_input and not self.cancel_requested: - time.sleep(0.1) - + self._chapter_options_event.wait() if self.cancel_requested: self.conversion_finished.emit("Cancelled", None) return - self.chapter_options_set = True # Log all detected chapters at the beginning @@ -607,6 +603,7 @@ class ConversionThread(QThread): self.save_chapters_separately = options["save_chapters_separately"] self.merge_chapters_at_end = options["merge_chapters_at_end"] self.waiting_for_user_input = False + self._chapter_options_event.set() def _generate_m4b_with_chapters(self, out_path, chapters_time): # generate chapters.txt in the same folder as the output file diff --git a/abogen/gui.py b/abogen/gui.py index 95ba0e2..b5780db 100644 --- a/abogen/gui.py +++ b/abogen/gui.py @@ -670,13 +670,18 @@ class abogen(QWidget): format_layout = QVBoxLayout() format_layout.addWidget(QLabel("Output Format:", self)) self.format_combo = QComboBox(self) - self.format_combo.setStyleSheet( - "QComboBox { min-height: 20px; padding: 6px 12px; }" + self.format_combo.setStyleSheet("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", "m4b"] - 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) controls_layout.addLayout(format_layout) # Save location From a03120d66f8ada6bee9215bc851d01c6056e4722 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C5=9Eafak?= Date: Sun, 4 May 2025 02:07:34 +0300 Subject: [PATCH 09/11] Fix "cannot access local variable 'm4b_picked'", improve m4b handling --- abogen/conversion.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/abogen/conversion.py b/abogen/conversion.py index 0612e0c..197c7ed 100644 --- a/abogen/conversion.py +++ b/abogen/conversion.py @@ -546,6 +546,7 @@ class ConversionThread(QThread): or not self.save_chapters_separately or getattr(self, "merge_chapters_at_end", True) ) + m4b_picked = False # Ensure m4b_picked is always defined if audio_segments and merge_chapters: self.log_updated.emit("\nFinalizing audio file...\n") audio = self.np.concatenate(audio_segments) @@ -606,6 +607,12 @@ class ConversionThread(QThread): 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: From 1d27f6fd2e518e09e5e27fb4d2bee8e696287b8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C5=9Eafak?= Date: Sun, 4 May 2025 10:50:36 +0300 Subject: [PATCH 10/11] Improvements in documentation --- CHANGELOG.md | 4 ++-- README.md | 10 +++++++--- abogen/VERSION | 2 +- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93f5ede..76b272c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,2 +1,2 @@ -- Added support for chapter metadata in m4b audio files, enabling proper chapter navigation 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. \ No newline at end of file +- 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) \ No newline at end of file diff --git a/README.md b/README.md index ab4f1f9..886c5df 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ Here’s 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. - **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) -- **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` - **Chapter Control**: Select specific `chapters` from ePUBs or `chapters + pages` from PDFs. - **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. - 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/).) ## `Similar Projects` @@ -196,9 +200,9 @@ Abogen is a standalone project, but it is inspired by and shares some similariti ## `Roadmap` - [ ] 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. -- [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 dark mode. diff --git a/abogen/VERSION b/abogen/VERSION index a6a3a43..1464c52 100644 --- a/abogen/VERSION +++ b/abogen/VERSION @@ -1 +1 @@ -1.0.4 \ No newline at end of file +1.0.5 \ No newline at end of file From fa144561fb05e339a80a8d0aa73f174442427807 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deniz=20=C5=9Eafak?= Date: Sun, 4 May 2025 10:58:37 +0300 Subject: [PATCH 11/11] Update changelog --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76b272c..b567176 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,2 +1,3 @@ - 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) \ No newline at end of file +- Better approach for determining the correct configuration folder for Linux and MacOS, using platformdirs. (Fixes Docker issue #12) +- Improvements in documentation and code. \ No newline at end of file