From e09535fa83e5fa023a68bacfe6bf1db73690baa4 Mon Sep 17 00:00:00 2001 From: Juraj Borza Date: Sat, 3 May 2025 19:26:19 +0200 Subject: [PATCH] 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 = [