diff --git a/CHANGELOG.md b/CHANGELOG.md index d3d73b8..b567176 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1 +1,3 @@ -- Better approach for detemining 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) +- Improvements in documentation and code. \ 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/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 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 diff --git a/abogen/conversion.py b/abogen/conversion.py index 248306b..197c7ed 100644 --- a/abogen/conversion.py +++ b/abogen/conversion.py @@ -11,7 +11,8 @@ 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 +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 @@ -339,6 +335,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: @@ -354,6 +356,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+" @@ -467,6 +473,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 @@ -483,14 +492,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 @@ -531,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) @@ -539,7 +555,16 @@ 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": + 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" @@ -579,6 +604,57 @@ 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): + # 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): """Helper function to format time for SRT files""" diff --git a/abogen/gui.py b/abogen/gui.py index ad1cb6f..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"] - 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 diff --git a/pyproject.toml b/pyproject.toml index 3f5ff16..83d973d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,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 = [