mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
Add chapter metadata - added m4b format, generating chapters info and converting via ffmpeg
This commit is contained in:
+64
-1
@@ -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
@@ -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
@@ -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 = [
|
||||||
|
|||||||
Reference in New Issue
Block a user