diff --git a/CHANGELOG.md b/CHANGELOG.md index ec7fcfa..9be9d70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ # 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. - Potential fix for #37 and #38, where the program was becoming slow while processing large files. - Fixed `Open folder` and `Open file` buttons in the queue manager GUI. +- Improvements in code structure. # v1.1.1 - Fixed adding wrong file in queue for EPUB and PDF files, ensuring the correct file is added to the queue. diff --git a/abogen/constants.py b/abogen/constants.py index 8b66e6b..fd230c8 100644 --- a/abogen/constants.py +++ b/abogen/constants.py @@ -10,6 +10,13 @@ VERSION = get_version() # Settings CHAPTER_OPTIONS_COUNTDOWN = 30 # Countdown seconds for chapter options +SUBTITLE_FORMATS = [ + ("srt", "SRT (standard)"), + ("ass_wide", "ASS (wide)"), + ("ass_narrow", "ASS (narrow)"), + ("ass_centered_wide", "ASS (centered wide)"), + ("ass_centered_narrow", "ASS (centered narrow)"), +] # Language description mapping LANGUAGE_DESCRIPTIONS = { diff --git a/abogen/conversion.py b/abogen/conversion.py index dfc62eb..596d83e 100644 --- a/abogen/conversion.py +++ b/abogen/conversion.py @@ -16,6 +16,7 @@ from abogen.constants import ( SAMPLE_VOICE_TEXTS, COLORS, CHAPTER_OPTIONS_COUNTDOWN, + SUBTITLE_FORMATS, ) from abogen.voice_formulas import get_new_voice import abogen.hf_tracker as hf_tracker @@ -244,137 +245,6 @@ class ConversionThread(QThread): return samples_processed - def _process_audio_segments( - self, - audio_segments, - output_path, - output_format, - use_ffmpeg=False, - ffmpeg_args=None, - ): - """ - Process audio segments to a target format with memory-efficient handling - - Args: - audio_segments: List of audio segments to process - output_path: Path for output file - output_format: Format of output (wav, mp3, flac, opus, m4b) - use_ffmpeg: Whether to use FFmpeg instead of soundfile - ffmpeg_args: Optional additional FFmpeg arguments when use_ffmpeg=True - - Returns: - Tuple of (success, output_path) - """ - self.log_updated.emit(f"\nProcessing audio data to {output_format.upper()}...") - segments_count = len(audio_segments) - - # Handle direct streaming for WAV (the only format supporting append mode) - if output_format == "wav" and not use_ffmpeg: - try: - # Write first segment - sf.write(output_path, audio_segments[0], 24000, format="wav") - - # Append remaining segments - for i, segment in enumerate(audio_segments[1:], 1): - with sf.SoundFile(output_path, mode="r+") as f: - f.seek(0, sf.SEEK_END) - f.write(segment) - return True, output_path - except Exception as e: - self.log_updated.emit((f"Error writing WAV file: {str(e)}", "red")) - return False, None - - # For formats requiring FFmpeg (opus, m4b) or when explicitly requested - if use_ffmpeg or output_format in ["opus", "m4b"]: - static_ffmpeg.add_paths() - - # Basic FFmpeg command - cmd = [ - "ffmpeg", - "-y", - "-thread_queue_size", - "32768", - "-f", - "f32le", - "-ar", - "24000", - "-ac", - "1", - "-i", - "pipe:0", - ] - - # Add custom FFmpeg arguments if provided - if ffmpeg_args: - cmd.extend(ffmpeg_args) - else: - # Default codec settings based on format - if output_format == "opus": - cmd.extend(["-c:a", "libopus", "-b:a", "24000"]) - elif output_format == "mp3": - cmd.extend(["-c:a", "libmp3lame", "-q:a", "2"]) - elif output_format == "flac": - cmd.extend(["-c:a", "flac", "-compression_level", "8"]) - else: - cmd.extend(["-c:a", "aac", "-q:a", "2"]) - - # Add output path - cmd.append(output_path) - - # Create process - proc = create_process(cmd, stdin=subprocess.PIPE, text=False) - - # Process segments - try: - # Use the unified streaming function - def process_chunk(chunk_bytes, is_last): - proc.stdin.write(chunk_bytes) - - self._stream_audio_in_chunks( - audio_segments, - process_chunk, - progress_prefix=f"Processing {output_format.upper()}", - ) - - # Close stdin and wait for process to complete - proc.stdin.close() - if proc.wait() != 0: - self.log_updated.emit( - (f"{output_format.upper()} conversion failed.", "red") - ) - return False, None - - return True, output_path - - except Exception as e: - self.log_updated.emit( - ( - f"Error during {output_format.upper()} conversion: {str(e)}", - "red", - ) - ) - proc.stdin.close() - try: - proc.terminate() - except: - pass - return False, None - - # For formats supported by soundfile (mp3, flac) - else: - try: - with sf.SoundFile( - output_path, "w", samplerate=24000, channels=1, format=output_format - ) as f: - for i, segment in enumerate(audio_segments): - f.write(segment) - return True, output_path - except Exception as e: - self.log_updated.emit( - (f"Error processing {output_format.upper()} file: {str(e)}", "red") - ) - return False, None - def run(self): print( f"\nVoice: {self.voice}\nLanguage: {self.lang_code}\nSpeed: {self.speed}\nGPU: {self.use_gpu}\nFile: {self.file_name}\nSubtitle mode: {self.subtitle_mode}\nOutput format: {self.output_format}\nSave option: {self.save_option}\n" @@ -408,7 +278,7 @@ class ConversionThread(QThread): self.log_updated.emit(f"- Subtitle mode: {self.subtitle_mode}") self.log_updated.emit(f"- Output format: {self.output_format}") self.log_updated.emit( - f"- Subtitle format: {getattr(self, 'subtitle_format', 'srt')}" + f"- Subtitle format: {next((label for value, label in SUBTITLE_FORMATS if value == getattr(self, 'subtitle_format', 'srt')), getattr(self, 'subtitle_format', 'srt'))}" ) self.log_updated.emit(f"- Save option: {self.save_option}") if self.replace_single_newlines: @@ -523,6 +393,12 @@ class ConversionThread(QThread): # If save_chapters_separately is enabled, find a unique suffix ONCE and use for both folder and merged file save_chapters_separately = getattr(self, "save_chapters_separately", False) + merge_chapters_at_end = getattr(self, "merge_chapters_at_end", True) + + # Ensure merge_chapters_at_end is True if not saving chapters separately + if not save_chapters_separately: + merge_chapters_at_end = True + chapters_out_dir = None suffix = "" @@ -562,58 +438,90 @@ class ConversionThread(QThread): ) if ( not os.path.exists(chapters_out_dir_candidate) - and not os.path.exists(merged_file_candidate) + and (not merge_chapters_at_end or not os.path.exists(merged_file_candidate)) and ( self.subtitle_mode == "Disabled" + or not merge_chapters_at_end or not os.path.exists(merged_srt_candidate) ) ): break counter += 1 if save_chapters_separately and total_chapters > 1: + separate_chapters_format = getattr(self, "separate_chapters_format", "wav") chapters_out_dir = chapters_out_dir_candidate os.makedirs(chapters_out_dir, exist_ok=True) - self.log_updated.emit(f"\nChapters output folder: {chapters_out_dir}") - - audio_segments = [] - subtitle_entries = [] - current_time = 0.0 - rate = 24000 - subtitle_mode = self.subtitle_mode - raw_tts_results = [] # Collect all raw tts Result objects - - # ETR timing starts here, after model loading but before processing - self.etr_start_time = time.time() - self.processed_char_count = 0 # Initialize processed character count - - # 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 - ] + self.log_updated.emit((f"\nChapters output folder: {chapters_out_dir}", "grey")) + # Prepare merged output file for incremental writing ONLY if merge_chapters_at_end is True + if merge_chapters_at_end: + out_dir = parent_dir + base_filepath_no_ext = os.path.join(out_dir, f"{base_name}{suffix}") + merged_out_path = f"{base_filepath_no_ext}.{self.output_format}" + subtitle_entries = [] + current_time = 0.0 + rate = 24000 + subtitle_mode = self.subtitle_mode + self.etr_start_time = time.time() + self.processed_char_count = 0 + current_segment = 0 + chapters_time = [ + {"chapter": chapter[0], "start": 0.0, "end": 0.0} + for chapter in chapters + ] + # Prepare output file/ffmpeg process for merged output + if self.output_format in ["wav", "mp3", "flac"]: + merged_out_file = sf.SoundFile(merged_out_path, "w", samplerate=24000, channels=1, format=self.output_format) + ffmpeg_proc = None + elif self.output_format == "m4b": + temp_wav_path = f"{base_filepath_no_ext}_temp.wav" + merged_out_file = sf.SoundFile(temp_wav_path, "w", samplerate=24000, channels=1, format="wav") + ffmpeg_proc = None + elif self.output_format == "opus": + static_ffmpeg.add_paths() + cmd = [ + "ffmpeg", "-y", "-thread_queue_size", "32768", "-f", "f32le", "-ar", "24000", "-ac", "1", "-i", "pipe:0" + ] + cmd.extend(["-c:a", "libopus", "-b:a", "24000"]) + cmd.append(merged_out_path) + ffmpeg_proc = create_process(cmd, stdin=subprocess.PIPE, text=False) + merged_out_file = None + else: + self.log_updated.emit((f"Unsupported output format: {self.output_format}", "red")) + self.conversion_finished.emit(("Audio generation failed.", "red"), None) + return + else: + # If not merging, set merged_out_file and related variables to None + merged_out_file = None + ffmpeg_proc = None + merged_out_path = None + subtitle_entries = [] + current_time = 0.0 + rate = 24000 + subtitle_mode = self.subtitle_mode + self.etr_start_time = time.time() + self.processed_char_count = 0 + current_segment = 0 + 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): + chapter_out_path = None if total_chapters > 1: self.log_updated.emit( ( f"\nChapter {chapter_idx}/{total_chapters}: {chapter_name}", - "green", + "blue", ) ) - - # Variables for per-chapter processing when save_chapters_separately is enabled - chapter_audio_segments = [] chapter_subtitle_entries = [] chapter_current_time = 0.0 - - # chapter start time + # Set chapter start time before processing chapter_time = chapters_time[chapter_idx - 1] - chapter_time["start"] = current_time - + if merge_chapters_at_end: + chapter_time["start"] = current_time # Set split_pattern to \n+ which will split on one or more newlines split_pattern = r"\n+" @@ -622,7 +530,37 @@ class ConversionThread(QThread): loaded_voice = get_new_voice(tts, self.voice, self.use_gpu) else: loaded_voice = self.voice - + # Prepare per-chapter output file if needed + if save_chapters_separately and total_chapters > 1: + sanitized = re.sub(r"[^\w\s\-]", "", chapter_name) + sanitized = re.sub(r"[\s\-]+", "_", sanitized).strip("_") + MAX_LEN = 80 + if len(sanitized) > MAX_LEN: + pos = sanitized[:MAX_LEN].rfind("_") + sanitized = sanitized[: pos if pos > 0 else MAX_LEN].rstrip("_") + chapter_filename = f"{chapter_idx:02d}_{sanitized}" + chapter_out_path = os.path.join( + chapters_out_dir, f"{chapter_filename}.{separate_chapters_format}" + ) + if separate_chapters_format in ["wav", "mp3", "flac"]: + chapter_out_file = sf.SoundFile(chapter_out_path, "w", samplerate=24000, channels=1, format=separate_chapters_format) + chapter_ffmpeg_proc = None + elif separate_chapters_format == "opus": + static_ffmpeg.add_paths() + cmd = [ + "ffmpeg", "-y", "-thread_queue_size", "32768", "-f", "f32le", "-ar", "24000", "-ac", "1", "-i", "pipe:0" + ] + cmd.extend(["-c:a", "libopus", "-b:a", "24000"]) + cmd.append(chapter_out_path) + chapter_ffmpeg_proc = create_process(cmd, stdin=subprocess.PIPE, text=False) + chapter_out_file = None + else: + self.log_updated.emit((f"Unsupported chapter format: {separate_chapters_format}", "red")) + continue + else: + chapter_out_file = None + chapter_out_path = None + chapter_ffmpeg_proc = None for result in tts( chapter_text, voice=loaded_voice, @@ -632,6 +570,10 @@ class ConversionThread(QThread): # Print the result for debugging # print(f"Result: {result}") if self.cancel_requested: + if chapter_out_file: + chapter_out_file.close() + if merged_out_file: + merged_out_file.close() self.conversion_finished.emit("Cancelled", None) return current_segment += 1 @@ -641,18 +583,27 @@ class ConversionThread(QThread): self.log_updated.emit( f"\n{self.processed_char_count:,}/{self.total_char_count:,}: {result.graphemes}" ) - raw_tts_results.append(result) chunk_dur = len(result.audio) / rate chunk_start = current_time - audio_segments.append(result.audio) - - # For per-chapter output - if save_chapters_separately and total_chapters > 1: - chapter_audio_segments.append(result.audio) - chapter_chunk_start = chapter_current_time - - # Process token timestamps for subtitle generation + # Write audio directly to merged file ONLY if merging + if merge_chapters_at_end and merged_out_file: + merged_out_file.write(result.audio) + elif merge_chapters_at_end and ffmpeg_proc: + if hasattr(result.audio, "numpy"): + audio_bytes = result.audio.numpy().astype("float32").tobytes() + else: + audio_bytes = result.audio.astype("float32").tobytes() + ffmpeg_proc.stdin.write(audio_bytes) + if chapter_out_file: + chapter_out_file.write(result.audio) + elif chapter_ffmpeg_proc: + if hasattr(result.audio, "numpy"): + audio_bytes = result.audio.numpy().astype("float32").tobytes() + else: + audio_bytes = result.audio.astype("float32").tobytes() + chapter_ffmpeg_proc.stdin.write(audio_bytes) + # Subtitle logic if self.subtitle_mode != "Disabled": tokens_list = getattr(result, "tokens", []) tokens_with_timestamps = [] @@ -668,46 +619,44 @@ class ConversionThread(QThread): "whitespace": tok.whitespace, } ) - if save_chapters_separately and total_chapters > 1: + if chapter_out_file or chapter_ffmpeg_proc: chapter_tokens_with_timestamps.append( { - "start": chapter_chunk_start - + (tok.start_ts or 0), - "end": chapter_chunk_start + (tok.end_ts or 0), + "start": chapter_current_time + (tok.start_ts or 0), + "end": chapter_current_time + (tok.end_ts or 0), "text": tok.text, "whitespace": tok.whitespace, } ) - # Process tokens according to subtitle mode - # Global subtitle processing - self._process_subtitle_tokens( - tokens_with_timestamps, - subtitle_entries, - self.max_subtitle_words, - ) - - # Per-chapter subtitle processing if enabled - if save_chapters_separately and total_chapters > 1: + # Global subtitle processing ONLY if merging + if merge_chapters_at_end: + self._process_subtitle_tokens( + tokens_with_timestamps, + subtitle_entries, + self.max_subtitle_words, + ) + # Per-chapter subtitle processing for both file and ffmpeg_proc + if chapter_out_file or chapter_ffmpeg_proc: self._process_subtitle_tokens( chapter_tokens_with_timestamps, chapter_subtitle_entries, self.max_subtitle_words, ) - - current_time += chunk_dur - - # Update chapter_current_time for per-chapter output - if save_chapters_separately and total_chapters > 1: - chapter_current_time += chunk_dur - + if merge_chapters_at_end: + current_time += chunk_dur + if chapter_out_file or chapter_ffmpeg_proc: + chapter_current_time += chunk_dur + else: + if chapter_out_file or chapter_ffmpeg_proc: + chapter_current_time += chunk_dur # Calculate percentage based on characters processed percent = min( int(self.processed_char_count / self.total_char_count * 100), 99 ) # Calculate ETR based on characters processed - etr_str = "Estimating..." + etr_str = "Processing..." chars_done = self.processed_char_count elapsed = time.time() - self.etr_start_time @@ -727,213 +676,183 @@ 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 - and total_chapters > 1 - and chapters_out_dir - and chapter_audio_segments - ): - - # strip any non‐alphanumeric or space/dash - sanitized = re.sub(r"[^\w\s\-]", "", chapter_name) - # collapse spaces or dashes to single underscore - sanitized = re.sub(r"[\s\-]+", "_", sanitized).strip("_") - # enforce max length - MAX_LEN = 80 - if len(sanitized) > MAX_LEN: - # Find last word boundary before limit - pos = sanitized[:MAX_LEN].rfind("_") - # Use word boundary if found, otherwise use hard limit - sanitized = sanitized[: pos if pos > 0 else MAX_LEN].rstrip("_") - chapter_filename = f"{chapter_idx:02d}_{sanitized}" - - # Use separate_chapters_format - separate_format = getattr(self, "separate_chapters_format", "wav") - - chapter_out_path = os.path.join( - chapters_out_dir, f"{chapter_filename}.{separate_format}" + # Set chapter end time after processing + if merge_chapters_at_end: + chapter_time["end"] = current_time + # Finalize chapter file for ffmpeg formats + if chapter_out_file or chapter_ffmpeg_proc: + self.log_updated.emit(("\nProcessing chapter audio...", "grey")) + if chapter_ffmpeg_proc: + chapter_ffmpeg_proc.stdin.close() + 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}" ) - - # Process audio segments using the unified function - success, chapter_out_path = self._process_audio_segments( - chapter_audio_segments, - chapter_out_path, - separate_format, - use_ffmpeg=(separate_format in ["opus", "m4b"]), - ) - - if not success: - self.log_updated.emit( - (f"Failed to write {separate_format.upper()} file.", "red") + if "ass" in subtitle_format: + is_centered = subtitle_format in ( + "ass_centered_wide", + "ass_centered_narrow", ) - continue - - # Generate subtitle file for chapter if not Disabled - if 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}" + is_narrow = subtitle_format in ( + "ass_narrow", + "ass_centered_narrow", ) - - if "ass" in subtitle_format: - # Generate ASS subtitle - 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: - # Generate SRT subtitle (default) - 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( - ( - f"\nChapter {chapter_idx} saved to: {chapter_out_path}\n\nSubtitle saved to: {chapter_subtitle_path}", - "green", - ) + self._write_ass_subtitle( + chapter_subtitle_path, + chapter_subtitle_entries, + is_centered, + is_narrow, ) else: - self.log_updated.emit( - ( - f"\nChapter {chapter_idx} saved to: {chapter_out_path}", - "green", - ) - ) - - # Set progress to 100% when processing is complete - self.progress_updated.emit(100, "00:00:00") - - # Only generate the merged output file if merge_chapters_at_end is True or save_chapters_separately is False - merge_chapters = ( - not hasattr(self, "save_chapters_separately") - or not self.save_chapters_separately - or getattr(self, "merge_chapters_at_end", True) - ) - - intended_output_format = self.output_format # Store the original choice - - if audio_segments and merge_chapters: - self.log_updated.emit("\nFinalizing audio file...") - - out_dir = parent_dir - base_filepath_no_ext = os.path.join(out_dir, f"{base_name}{suffix}") - - final_out_path = None - - # Use dedicated chapter processing for M4B when we have chapters - if intended_output_format == "m4b": - self.log_updated.emit("\nGenerating audio with chapters...") - final_out_path = self._generate_m4b_with_chapters( - audio_segments, chapters_time, base_filepath_no_ext - ) - else: - # Process audio segments using the unified function - success, final_out_path = self._process_audio_segments( - audio_segments, - f"{base_filepath_no_ext}.{intended_output_format}", - intended_output_format, - use_ffmpeg=(intended_output_format in ["opus", "m4b"]), - ) - - if not success: - final_out_path = None - - if not final_out_path: - self.log_updated.emit(("Audio generation failed.", "red")) - self.conversion_finished.emit( - ("Audio generation failed.", "red"), None - ) - return - - # Subtitle and final message logic - if final_out_path: - 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(final_out_path)[0] + f".{file_extension}" - ) - - if "ass" in subtitle_format: - # Generate ASS subtitle - 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: - # Generate SRT subtitle (default) - 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: {final_out_path}\n\nSubtitle saved to: {subtitle_path}", - "green", - ), - final_out_path, - ) - else: - self.conversion_finished.emit( - (f"\nAudiobook saved to: {final_out_path}", "green"), - final_out_path, - ) - 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( - ("Audio generation failed (final_out_path was not set).", "red") + ( + f"\nChapter {chapter_idx} saved to: {chapter_out_path}\n\nChapter subtitle saved to: {chapter_subtitle_path}", + "green", + ) ) + elif chapter_out_path: + self.log_updated.emit( + ( + f"\nChapter {chapter_idx} saved to: {chapter_out_path}", + "green", + ) + ) + + # Finalize merged output file ONLY if merging + if merge_chapters_at_end: + self.log_updated.emit(("\nFinalizing audio...", "grey")) + if self.output_format in ["wav", "mp3", "flac"]: + merged_out_file.close() + elif self.output_format == "m4b": + merged_out_file.close() + # Only generate chapters info if there are chapters + if total_chapters > 1: + chapters_info_path = f"{base_filepath_no_ext}_chapters.txt" + with open(chapters_info_path, "w", encoding="utf-8") as f: + f.write(";FFMETADATA1\n") + for chapter in chapters_time: + chapter_title = chapter["chapter"].replace("=", "\\=") + f.write(f"[CHAPTER]\n") + f.write(f"TIMEBASE=1/1000\n") + f.write(f"START={int(chapter['start']*1000)}\n") + f.write(f"END={int(chapter['end']*1000)}\n") + f.write(f"title={chapter_title}\n\n") + metadata_options = self._extract_and_add_metadata_tags_to_ffmpeg_cmd() + static_ffmpeg.add_paths() + cmd = [ + "ffmpeg", "-y", "-i", temp_wav_path, "-i", chapters_info_path, + "-map", "0:a", "-map_metadata", "1", "-map_chapters", "1", + *metadata_options, + "-c:a", "aac", "-q:a", "2", "-movflags", "+faststart+use_metadata_tags", + merged_out_path + ] + proc = create_process(cmd) + proc.wait() + self.log_updated.emit(("\nCleaning up temporary files...", "grey")) + if os.path.exists(chapters_info_path): + os.remove(chapters_info_path) + else: + # No chapters: skip chapters info and related ffmpeg args + metadata_options = self._extract_and_add_metadata_tags_to_ffmpeg_cmd() + static_ffmpeg.add_paths() + cmd = [ + "ffmpeg", "-y", "-i", temp_wav_path, + *metadata_options, + "-c:a", "aac", "-q:a", "2", "-movflags", "+faststart+use_metadata_tags", + merged_out_path + ] + proc = create_process(cmd) + proc.wait() + if os.path.exists(temp_wav_path): + os.remove(temp_wav_path) + elif self.output_format in ["opus"]: + ffmpeg_proc.stdin.close() + ffmpeg_proc.wait() + self.progress_updated.emit(100, "00:00:00") + + # 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( - ("Audio generation failed.", "red"), None + ( + f"\nAudiobook saved to: {merged_out_path}\n\nSubtitle saved to: {subtitle_path}", + "green", + ), + merged_out_path, + ) + else: + self.conversion_finished.emit( + (f"\nAudiobook saved to: {merged_out_path}", "green"), + merged_out_path, ) - elif audio_segments and not merge_chapters: - self.conversion_finished.emit( - ( - f"\nAll chapters processed successfully and saved to: {chapters_out_dir}", - "green", - ), - chapters_out_dir, - ) else: - self.log_updated.emit(("No audio segments were generated.", "red")) - self.conversion_finished.emit(("Audio generation failed.", "red"), None) + # If not merging, just finish after chapters + self.progress_updated.emit(100, "00:00:00") + self.conversion_finished.emit( + ("\nAll chapters saved.", "green"), + None, + ) except Exception as e: + # Cleanup ffmpeg subprocesses on error + try: + if 'ffmpeg_proc' in locals() and ffmpeg_proc: + ffmpeg_proc.stdin.close() + ffmpeg_proc.terminate() + ffmpeg_proc.wait() + except Exception: + pass + try: + if 'chapter_ffmpeg_proc' in locals() and chapter_ffmpeg_proc: + chapter_ffmpeg_proc.stdin.close() + chapter_ffmpeg_proc.terminate() + chapter_ffmpeg_proc.wait() + except Exception: + pass self.log_updated.emit((f"Error occurred: {str(e)}", "red")) self.conversion_finished.emit(("Audio generation failed.", "red"), None) @@ -944,132 +863,6 @@ class ConversionThread(QThread): self.waiting_for_user_input = False self._chapter_options_event.set() - def _generate_m4b_with_chapters( - self, audio_segments, chapters_time, base_filepath_no_ext - ): - """Generate M4B file with chapters from audio segments""" - final_wav_path = f"{base_filepath_no_ext}.wav" - output_m4b_path = f"{base_filepath_no_ext}.m4b" - chapters_info_path = f"{base_filepath_no_ext}_chapters.txt" - - # Early check for single/no chapter case - if not chapters_time or len(chapters_time) <= 1: - self.log_updated.emit( - ( - "\nFile contains only one chapter or no chapters were detected. Audio will be saved as a standard .wav file instead.", - "red", - ) - ) - # Use the established unified method to create a WAV file - success, wav_path = self._process_audio_segments( - audio_segments, final_wav_path, "wav", use_ffmpeg=False - ) - if success: - return wav_path - else: - self.log_updated.emit( - (f"\nFailed to save single/no chapter audio as WAV", "red") - ) - return None - - try: - # Write chapter metadata file - with open(chapters_info_path, "w", encoding="utf-8") as f: - f.write(";FFMETADATA1\n") - for chapter in chapters_time: - chapter_title = chapter["chapter"].replace("=", "\\=") - f.write(f"[CHAPTER]\n") - f.write(f"TIMEBASE=1/1000\n") - f.write(f"START={int(chapter['start']*1000)}\n") - f.write(f"END={int(chapter['end']*1000)}\n") - f.write(f"title={chapter_title}\n\n") - - # For M4B with chapters, we need to use input file for chapter information - static_ffmpeg.add_paths() - metadata_options = self._extract_and_add_metadata_tags_to_ffmpeg_cmd() - - # Use pipe-based approach for audio input with special args for M4B chapters - ffmpeg_args = [ - "-i", - chapters_info_path, - "-map", - "0:a", - "-map_metadata", - "1", - "-map_chapters", - "1", - *metadata_options, - "-c:a", - "aac", - "-q:a", - "2", # Quality-based VBR for better quality control - "-movflags", - "+faststart+use_metadata_tags", # Added for better compatibility - ] - - # Use the established unified method with M4B-specific args - success, out_path = self._process_audio_segments( - audio_segments, - output_m4b_path, - "m4b", - use_ffmpeg=True, - ffmpeg_args=ffmpeg_args, - ) - - # Clean up the temporary chapter metadata file - if os.path.exists(chapters_info_path): - try: - os.remove(chapters_info_path) - except Exception as e: - self.log_updated.emit( - (f"Warning: Could not delete chapters file: {e}", "orange") - ) - - if success: - return out_path - - # If M4B generation failed, fallback to WAV - self.log_updated.emit( - (f"M4B conversion failed. Falling back to WAV.\n", "red") - ) - success, wav_path = self._process_audio_segments( - audio_segments, final_wav_path, "wav", use_ffmpeg=False - ) - if success: - return wav_path - else: - self.log_updated.emit((f"Failed to write WAV file as fallback.", "red")) - return None - - except Exception as e: - # General error during M4B generation - create WAV file directly as final output - self.log_updated.emit( - ( - f"Error during M4B generation: {str(e)}.\n\nFalling back to WAV.\n", - "red", - ) - ) - - # Use the established unified method to create a WAV file as fallback - success, wav_path = self._process_audio_segments( - audio_segments, final_wav_path, "wav", use_ffmpeg=False - ) - - # Clean up temp files - if os.path.exists(chapters_info_path): - try: - os.remove(chapters_info_path) - except Exception: - pass - - if success: - return wav_path - else: - self.log_updated.emit( - (f"Critical error: Failed to save WAV fallback", "red") - ) - return None - def _extract_and_add_metadata_tags_to_ffmpeg_cmd(self): """Extract metadata tags from text content and add them to ffmpeg command""" metadata_options = [] @@ -1198,9 +991,7 @@ class ConversionThread(QThread): word_count += 1 # Split sentences based on separator or word count - if ( - re.search(separator, token["text"]) and token["whitespace"] == " " - ) or word_count >= max_subtitle_words: + if (re.search(separator, token["text"])) or word_count >= max_subtitle_words: if current_sentence: # Create subtitle entry for this sentence start_time = current_sentence[0]["start"] @@ -1306,6 +1097,21 @@ class ConversionThread(QThread): self.process.terminate() except Exception: pass + # Terminate ffmpeg subprocesses if running + try: + if hasattr(self, 'ffmpeg_proc') and self.ffmpeg_proc: + self.ffmpeg_proc.stdin.close() + self.ffmpeg_proc.terminate() + self.ffmpeg_proc.wait() + except Exception: + pass + try: + if hasattr(self, 'chapter_ffmpeg_proc') and self.chapter_ffmpeg_proc: + self.chapter_ffmpeg_proc.stdin.close() + self.chapter_ffmpeg_proc.terminate() + self.chapter_ffmpeg_proc.wait() + except Exception: + pass class VoicePreviewThread(QThread): diff --git a/abogen/gui.py b/abogen/gui.py index 4e963a5..52d4c4d 100644 --- a/abogen/gui.py +++ b/abogen/gui.py @@ -52,8 +52,6 @@ from PyQt5.QtGui import ( QPolygon, QColor, QMovie, - QFont, - QTextCharFormat, ) from abogen.utils import ( load_config, @@ -77,6 +75,7 @@ from abogen.constants import ( VOICES_INTERNAL, SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION, COLORS, + SUBTITLE_FORMATS, ) import threading from abogen.voice_formula_gui import VoiceFormulaDialog @@ -132,6 +131,7 @@ LOG_COLOR_MAP = { "green": COLORS["GREEN"], "orange": COLORS["ORANGE"], "blue": COLORS["BLUE"], + "grey": COLORS["LIGHT_DISABLED"], None: COLORS["LIGHT_DISABLED"], } @@ -953,14 +953,7 @@ class abogen(QWidget): self.subtitle_format_combo.setSizePolicy( QSizePolicy.Expanding, QSizePolicy.Fixed ) - subtitle_formats = [ - ("srt", "SRT (standard)"), - ("ass_wide", "ASS (wide)"), - ("ass_narrow", "ASS (narrow)"), - ("ass_centered_wide", "ASS (centered wide)"), - ("ass_centered_narrow", "ASS (centered narrow)"), - ] - for value, text in subtitle_formats: + for value, text in SUBTITLE_FORMATS: self.subtitle_format_combo.addItem(text, value) subtitle_format = self.config.get("subtitle_format", "ass_centered_narrow") idx = self.subtitle_format_combo.findData(subtitle_format) @@ -1892,7 +1885,7 @@ class abogen(QWidget): f"{idx}) {os.path.basename(item.file_name)}
" f"Language: {item.lang_code}
" f"Voice: {item.voice}
" - f"Speed: {item.speed * 100:.1f}%
" + f"Speed: {item.speed}
" f"Characters: {item.total_char_count}
" f"Input: {item.file_name}
" f"Output: {output}"