diff --git a/abogen/conversion.py b/abogen/conversion.py index 248306b..e97fc4a 100644 --- a/abogen/conversion.py +++ b/abogen/conversion.py @@ -11,7 +11,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"]) @@ -339,6 +339,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 +360,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 +477,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 @@ -539,7 +552,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" @@ -580,6 +602,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 ad1cb6f..95ba0e2 100644 --- a/abogen/gui.py +++ b/abogen/gui.py @@ -673,7 +673,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 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 = [