Add chapter metadata - added m4b format, generating chapters info and converting via ffmpeg

This commit is contained in:
Juraj Borza
2025-05-03 19:26:19 +02:00
parent d500a42d8e
commit e09535fa83
3 changed files with 68 additions and 4 deletions
+64 -1
View File
@@ -10,7 +10,7 @@ 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
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"])
@@ -334,6 +334,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:
@@ -349,6 +355,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+"
@@ -462,6 +472,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
@@ -532,7 +545,17 @@ 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":
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) 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(srt_path, "w", encoding="utf-8", errors="replace") as srt_file: with open(srt_path, "w", encoding="utf-8", errors="replace") as srt_file:
for i, (start, end, text) in enumerate(subtitle_entries, 1): 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.merge_chapters_at_end = options["merge_chapters_at_end"]
self.waiting_for_user_input = False 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): def _srt_time(self, t):
"""Helper function to format time for SRT files""" """Helper function to format time for SRT files"""
h = int(t // 3600) h = int(t // 3600)
+1 -1
View File
@@ -652,7 +652,7 @@ class abogen(QWidget):
self.format_combo.setStyleSheet( self.format_combo.setStyleSheet(
"QComboBox { min-height: 20px; padding: 6px 12px; }" "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.addItems(format_options)
self.format_combo.setCurrentText(self.selected_format) self.format_combo.setCurrentText(self.selected_format)
self.format_combo.currentTextChanged.connect(self.on_format_changed) self.format_combo.currentTextChanged.connect(self.on_format_changed)
+2 -1
View File
@@ -21,7 +21,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 = [