diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d982a6..4bacc34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # v1.1.2 (pre-release) - 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 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. diff --git a/abogen/book_handler.py b/abogen/book_handler.py index ced9c72..615ab7c 100644 --- a/abogen/book_handler.py +++ b/abogen/book_handler.py @@ -1908,5 +1908,13 @@ class HandlerDialog(QDialog): def closeEvent(self, event): 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() diff --git a/abogen/conversion.py b/abogen/conversion.py index a8da622..de48d37 100644 --- a/abogen/conversion.py +++ b/abogen/conversion.py @@ -305,8 +305,9 @@ class ConversionThread(QThread): if self.save_option == "Choose output folder": 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...") # 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.conversion_finished.emit(("Audio generation failed.", "red"), None) 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: # If not merging, set merged_out_file and related variables to None merged_out_file = None @@ -557,10 +582,37 @@ class ConversionThread(QThread): else: self.log_updated.emit((f"Unsupported chapter format: {separate_chapters_format}", "red")) 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: chapter_out_file = None chapter_out_path = None chapter_ffmpeg_proc = None + chapter_subtitle_file = None + chapter_subtitle_path = None for result in tts( chapter_text, voice=loaded_voice, @@ -631,18 +683,49 @@ class ConversionThread(QThread): # Process tokens according to subtitle mode # Global subtitle processing ONLY if merging if merge_chapters_at_end: + # Incremental subtitle writing for merged output + new_entries = [] self._process_subtitle_tokens( tokens_with_timestamps, - subtitle_entries, + new_entries, 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 if chapter_out_file or chapter_ffmpeg_proc: + new_chapter_entries = [] self._process_subtitle_tokens( chapter_tokens_with_timestamps, - chapter_subtitle_entries, + new_chapter_entries, 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: current_time += chunk_dur if chapter_out_file or chapter_ffmpeg_proc: @@ -687,41 +770,10 @@ class ConversionThread(QThread): chapter_ffmpeg_proc.wait() if chapter_out_file: chapter_out_file.close() - # Subtitle file for chapter - if save_chapters_separately and total_chapters > 1 and self.subtitle_mode != "Disabled" and chapter_subtitle_entries: - 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: - 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" - ) + # Close chapter subtitle file if open + if chapter_subtitle_file: + chapter_subtitle_file.close() + if save_chapters_separately and total_chapters > 1 and self.subtitle_mode != "Disabled" and chapter_subtitle_path: self.log_updated.emit( ( 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", ) ) - # Finalize merged output file ONLY if merging if merge_chapters_at_end: self.log_updated.emit(("\nFinalizing audio...", "grey")) @@ -787,40 +838,15 @@ class ConversionThread(QThread): ffmpeg_proc.stdin.close() ffmpeg_proc.wait() 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 if merge_chapters_at_end: 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( ( - 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", ), merged_out_path, @@ -991,7 +1017,7 @@ class ConversionThread(QThread): word_count += 1 # 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: # Create subtitle entry for this sentence start_time = current_sentence[0]["start"] diff --git a/abogen/gui.py b/abogen/gui.py index e433788..a18ff34 100644 --- a/abogen/gui.py +++ b/abogen/gui.py @@ -2808,10 +2808,22 @@ class abogen(QWidget): save_config(self.config) 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( self, "Restart Required", - "Changing Kokoro's internet access requires restarting the application.\nDo you want to continue?", + + message, QMessageBox.Yes | QMessageBox.No, QMessageBox.No, ) @@ -2834,7 +2846,7 @@ class abogen(QWidget): reply = QMessageBox.question( self, "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.No, )