Generate subtitles dynamically like sound files, improvements in code

This commit is contained in:
Deniz Şafak
2025-07-15 11:50:22 +03:00
parent 6b85a08ccb
commit 89a7116c87
4 changed files with 119 additions and 73 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# v1.1.2 (pre-release) # v1.1.2 (pre-release)
- Now you can play the audio files while they are processing. - Now you can play the audio files while they are processing.
- While generating audio files, now it directly writes to audio file instead of RAM, which significantly reduces memory usage and prevents memory outage issues. - Audio and subtitle files are now written directly to disk during generation, which significantly reduces memory usage.
- Added a better logic for detecting chapters from the epub, mentioned by @jefro108 in #33 - Added a better logic for detecting chapters from the epub, mentioned by @jefro108 in #33
- Added a new option: `Reset to default settings`, allowing users to reset all settings to their default values. - Added a new option: `Reset to default settings`, allowing users to reset all settings to their default values.
- Added a new option: `Disable Kokoro's internet access`. This lets you prevent Kokoro from downloading models or voices from HuggingFace Hub, which can help avoid long waiting times if your computer is offline. - Added a new option: `Disable Kokoro's internet access`. This lets you prevent Kokoro from downloading models or voices from HuggingFace Hub, which can help avoid long waiting times if your computer is offline.
+9 -1
View File
@@ -1908,5 +1908,13 @@ class HandlerDialog(QDialog):
def closeEvent(self, event): def closeEvent(self, event):
if self.pdf_doc is not None: if self.pdf_doc is not None:
self.pdf_doc.close() try:
if hasattr(self.pdf_doc, "is_closed"):
if not self.pdf_doc.is_closed:
self.pdf_doc.close()
else:
# Fallback: try/except close
self.pdf_doc.close()
except Exception:
pass
event.accept() event.accept()
+95 -69
View File
@@ -305,8 +305,9 @@ class ConversionThread(QThread):
if self.save_option == "Choose output folder": if self.save_option == "Choose output folder":
self.log_updated.emit( self.log_updated.emit(
f" - Output folder: {self.output_folder or os.getcwd()}" f"- Output folder: {self.output_folder or os.getcwd()}"
) )
self.log_updated.emit("\nInitializing TTS pipeline...") self.log_updated.emit("\nInitializing TTS pipeline...")
# Set device based on use_gpu setting # Set device based on use_gpu setting
@@ -490,6 +491,30 @@ class ConversionThread(QThread):
self.log_updated.emit((f"Unsupported output format: {self.output_format}", "red")) self.log_updated.emit((f"Unsupported output format: {self.output_format}", "red"))
self.conversion_finished.emit(("Audio generation failed.", "red"), None) self.conversion_finished.emit(("Audio generation failed.", "red"), None)
return return
# Open merged subtitle file for incremental writing if needed
merged_subtitle_file = None
if self.subtitle_mode != "Disabled":
subtitle_format = getattr(self, "subtitle_format", "srt")
file_extension = "ass" if "ass" in subtitle_format else "srt"
merged_subtitle_path = os.path.splitext(merged_out_path)[0] + f".{file_extension}"
if "ass" in subtitle_format:
merged_subtitle_file = open(merged_subtitle_path, "w", encoding="utf-8", errors="replace")
# Minimal ASS header
merged_subtitle_file.write("[Script Info]\n")
merged_subtitle_file.write("Title: Generated by Abogen\n")
merged_subtitle_file.write("ScriptType: v4.00+\n\n")
merged_subtitle_file.write("[Events]\n")
merged_subtitle_file.write("Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n")
# Set margin/alignment for ASS
is_centered = subtitle_format in ("ass_centered_wide", "ass_centered_narrow")
is_narrow = subtitle_format in ("ass_narrow", "ass_centered_narrow")
merged_subtitle_margin = "90" if is_narrow else ""
merged_subtitle_alignment_tag = f"{{\\an5}}" if is_centered else ""
else:
merged_subtitle_file = open(merged_subtitle_path, "w", encoding="utf-8", errors="replace")
else:
merged_subtitle_path = None
merged_subtitle_file = None
else: else:
# If not merging, set merged_out_file and related variables to None # If not merging, set merged_out_file and related variables to None
merged_out_file = None merged_out_file = None
@@ -557,10 +582,37 @@ class ConversionThread(QThread):
else: else:
self.log_updated.emit((f"Unsupported chapter format: {separate_chapters_format}", "red")) self.log_updated.emit((f"Unsupported chapter format: {separate_chapters_format}", "red"))
continue continue
# Open chapter subtitle file for incremental writing if needed
chapter_subtitle_file = None
if self.subtitle_mode != "Disabled":
subtitle_format = getattr(self, "subtitle_format", "srt")
file_extension = "ass" if "ass" in subtitle_format else "srt"
chapter_subtitle_path = os.path.join(
chapters_out_dir, f"{chapter_filename}.{file_extension}"
)
if "ass" in subtitle_format:
chapter_subtitle_file = open(chapter_subtitle_path, "w", encoding="utf-8", errors="replace")
# Minimal ASS header
chapter_subtitle_file.write("[Script Info]\n")
chapter_subtitle_file.write("Title: Generated by Abogen\n")
chapter_subtitle_file.write("ScriptType: v4.00+\n\n")
chapter_subtitle_file.write("[Events]\n")
chapter_subtitle_file.write("Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n")
is_centered = subtitle_format in ("ass_centered_wide", "ass_centered_narrow")
is_narrow = subtitle_format in ("ass_narrow", "ass_centered_narrow")
chapter_subtitle_margin = "90" if is_narrow else ""
chapter_subtitle_alignment_tag = f"{{\\an5}}" if is_centered else ""
else:
chapter_subtitle_file = open(chapter_subtitle_path, "w", encoding="utf-8", errors="replace")
else:
chapter_subtitle_path = None
chapter_subtitle_file = None
else: else:
chapter_out_file = None chapter_out_file = None
chapter_out_path = None chapter_out_path = None
chapter_ffmpeg_proc = None chapter_ffmpeg_proc = None
chapter_subtitle_file = None
chapter_subtitle_path = None
for result in tts( for result in tts(
chapter_text, chapter_text,
voice=loaded_voice, voice=loaded_voice,
@@ -631,18 +683,49 @@ class ConversionThread(QThread):
# Process tokens according to subtitle mode # Process tokens according to subtitle mode
# Global subtitle processing ONLY if merging # Global subtitle processing ONLY if merging
if merge_chapters_at_end: if merge_chapters_at_end:
# Incremental subtitle writing for merged output
new_entries = []
self._process_subtitle_tokens( self._process_subtitle_tokens(
tokens_with_timestamps, tokens_with_timestamps,
subtitle_entries, new_entries,
self.max_subtitle_words, self.max_subtitle_words,
) )
if merged_subtitle_file:
subtitle_format = getattr(self, "subtitle_format", "srt")
if "ass" in subtitle_format:
for (start, end, text) in new_entries:
start_time = self._ass_time(start)
end_time = self._ass_time(end)
merged_subtitle_file.write(
f"Dialogue: 0,{start_time},{end_time},Default,,{merged_subtitle_margin},{merged_subtitle_margin},0,,{merged_subtitle_alignment_tag}{text}\n"
)
else:
for i, (start, end, text) in enumerate(new_entries, 1):
merged_subtitle_file.write(
f"{i}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n"
)
# Per-chapter subtitle processing for both file and ffmpeg_proc # Per-chapter subtitle processing for both file and ffmpeg_proc
if chapter_out_file or chapter_ffmpeg_proc: if chapter_out_file or chapter_ffmpeg_proc:
new_chapter_entries = []
self._process_subtitle_tokens( self._process_subtitle_tokens(
chapter_tokens_with_timestamps, chapter_tokens_with_timestamps,
chapter_subtitle_entries, new_chapter_entries,
self.max_subtitle_words, self.max_subtitle_words,
) )
if chapter_subtitle_file:
subtitle_format = getattr(self, "subtitle_format", "srt")
if "ass" in subtitle_format:
for (start, end, text) in new_chapter_entries:
start_time = self._ass_time(start)
end_time = self._ass_time(end)
chapter_subtitle_file.write(
f"Dialogue: 0,{start_time},{end_time},Default,,{chapter_subtitle_margin},{chapter_subtitle_margin},0,,{chapter_subtitle_alignment_tag}{text}\n"
)
else:
for i, (start, end, text) in enumerate(new_chapter_entries, 1):
chapter_subtitle_file.write(
f"{i}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n"
)
if merge_chapters_at_end: if merge_chapters_at_end:
current_time += chunk_dur current_time += chunk_dur
if chapter_out_file or chapter_ffmpeg_proc: if chapter_out_file or chapter_ffmpeg_proc:
@@ -687,41 +770,10 @@ class ConversionThread(QThread):
chapter_ffmpeg_proc.wait() chapter_ffmpeg_proc.wait()
if chapter_out_file: if chapter_out_file:
chapter_out_file.close() chapter_out_file.close()
# Subtitle file for chapter # Close chapter subtitle file if open
if save_chapters_separately and total_chapters > 1 and self.subtitle_mode != "Disabled" and chapter_subtitle_entries: if chapter_subtitle_file:
subtitle_format = getattr(self, "subtitle_format", "srt") chapter_subtitle_file.close()
file_extension = "ass" if "ass" in subtitle_format else "srt" if save_chapters_separately and total_chapters > 1 and self.subtitle_mode != "Disabled" and chapter_subtitle_path:
chapter_subtitle_path = os.path.join(
chapters_out_dir, f"{chapter_filename}.{file_extension}"
)
if "ass" in subtitle_format:
is_centered = subtitle_format in (
"ass_centered_wide",
"ass_centered_narrow",
)
is_narrow = subtitle_format in (
"ass_narrow",
"ass_centered_narrow",
)
self._write_ass_subtitle(
chapter_subtitle_path,
chapter_subtitle_entries,
is_centered,
is_narrow,
)
else:
with open(
chapter_subtitle_path,
"w",
encoding="utf-8",
errors="replace",
) as srt_file:
for i, (start, end, text) in enumerate(
chapter_subtitle_entries, 1
):
srt_file.write(
f"{i}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n"
)
self.log_updated.emit( self.log_updated.emit(
( (
f"\nChapter {chapter_idx} saved to: {chapter_out_path}\n\nChapter subtitle saved to: {chapter_subtitle_path}", f"\nChapter {chapter_idx} saved to: {chapter_out_path}\n\nChapter subtitle saved to: {chapter_subtitle_path}",
@@ -735,7 +787,6 @@ class ConversionThread(QThread):
"green", "green",
) )
) )
# Finalize merged output file ONLY if merging # Finalize merged output file ONLY if merging
if merge_chapters_at_end: if merge_chapters_at_end:
self.log_updated.emit(("\nFinalizing audio...", "grey")) self.log_updated.emit(("\nFinalizing audio...", "grey"))
@@ -787,40 +838,15 @@ class ConversionThread(QThread):
ffmpeg_proc.stdin.close() ffmpeg_proc.stdin.close()
ffmpeg_proc.wait() ffmpeg_proc.wait()
self.progress_updated.emit(100, "00:00:00") self.progress_updated.emit(100, "00:00:00")
# Close merged subtitle file if open
if merged_subtitle_file:
merged_subtitle_file.close()
# Subtitle and final message logic # Subtitle and final message logic
if merge_chapters_at_end: if merge_chapters_at_end:
if self.subtitle_mode != "Disabled": if self.subtitle_mode != "Disabled":
subtitle_format = getattr(self, "subtitle_format", "srt")
file_extension = "ass" if "ass" in subtitle_format else "srt"
subtitle_path = (
os.path.splitext(merged_out_path)[0] + f".{file_extension}"
)
if "ass" in subtitle_format:
is_centered = subtitle_format in (
"ass_centered_wide",
"ass_centered_narrow",
)
is_narrow = subtitle_format in (
"ass_narrow",
"ass_centered_narrow",
)
self._write_ass_subtitle(
subtitle_path, subtitle_entries, is_centered, is_narrow
)
else:
with open(
subtitle_path, "w", encoding="utf-8", errors="replace"
) as srt_file:
for i, (start, end, text) in enumerate(
subtitle_entries, 1
):
srt_file.write(
f"{i}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n"
)
self.conversion_finished.emit( self.conversion_finished.emit(
( (
f"\nAudiobook saved to: {merged_out_path}\n\nSubtitle saved to: {subtitle_path}", f"\nAudiobook saved to: {merged_out_path}\n\nSubtitle saved to: {merged_subtitle_path}",
"green", "green",
), ),
merged_out_path, merged_out_path,
@@ -991,7 +1017,7 @@ class ConversionThread(QThread):
word_count += 1 word_count += 1
# Split sentences based on separator or word count # Split sentences based on separator or word count
if (re.search(separator, token["text"])) or word_count >= max_subtitle_words: if (re.search(separator, token["text"]) and token["whitespace"] == " ") or word_count >= max_subtitle_words:
if current_sentence: if current_sentence:
# Create subtitle entry for this sentence # Create subtitle entry for this sentence
start_time = current_sentence[0]["start"] start_time = current_sentence[0]["start"]
+14 -2
View File
@@ -2808,10 +2808,22 @@ class abogen(QWidget):
save_config(self.config) save_config(self.config)
def toggle_kokoro_internet_access(self, disabled): def toggle_kokoro_internet_access(self, disabled):
if disabled:
message = (
"Disabling Kokoro's internet access will block downloads of models and voices from Hugging Face Hub. "
"This can make processing faster when there is no internet connection, since no requests will be made. "
"The app needs to restart to apply this change.\n\nDo you want to continue?"
)
else:
message = (
"Enabling Kokoro's internet access will allow it to download models and voices from Hugging Face Hub. "
"The app needs to restart to apply this change.\n\nDo you want to continue?"
)
reply = QMessageBox.question( reply = QMessageBox.question(
self, self,
"Restart Required", "Restart Required",
"Changing Kokoro's internet access requires restarting the application.\nDo you want to continue?",
message,
QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes | QMessageBox.No,
QMessageBox.No, QMessageBox.No,
) )
@@ -2834,7 +2846,7 @@ class abogen(QWidget):
reply = QMessageBox.question( reply = QMessageBox.question(
self, self,
"Reset Settings", "Reset Settings",
"This will reset all settings to their default values and restart the application.\nDo you want to continue?", "This will reset all settings to their default values and restart the application.\n\nDo you want to continue?",
QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes | QMessageBox.No,
QMessageBox.No, QMessageBox.No,
) )