diff --git a/CHANGELOG.md b/CHANGELOG.md index ff8bb2f..d9e8189 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# v1.1.1 +- Fixed adding wrong file in queue for EPUB and PDF files, ensuring the correct file is added to the queue. +- Reformatted the code using Black. + # v1.1.0 - Added queue system for processing multiple items, allowing users to add multiple files and process them in a queue, mentioned by @jborza in #30 (Special thanks to @jborza for implementing this feature in PR #35) - Added a feature that allows selecting multiple items in book handler (in right click menu) by @jborza in #31, that fixes #28 diff --git a/abogen/VERSION b/abogen/VERSION index 1cc5f65..8cfbc90 100644 --- a/abogen/VERSION +++ b/abogen/VERSION @@ -1 +1 @@ -1.1.0 \ No newline at end of file +1.1.1 \ No newline at end of file diff --git a/abogen/book_handler.py b/abogen/book_handler.py index e5bf8ac..5c582cb 100644 --- a/abogen/book_handler.py +++ b/abogen/book_handler.py @@ -849,7 +849,7 @@ class HandlerDialog(QDialog): if iterator.value(): has_parents = True self.treeWidget.setRootIsDecorated(has_parents) - + def _update_checkbox_states(self): """Update the checkbox states based on the current checked chapters.""" for i in range(self.treeWidget.topLevelItemCount()): @@ -997,10 +997,21 @@ class HandlerDialog(QDialog): bookmark_item.setData(0, Qt.UserRole, page_id) # only allow checking if this chapter has content if self.content_lengths.get(page_id, 0) > 0: - bookmark_item.setFlags(bookmark_item.flags() | Qt.ItemIsUserCheckable) - bookmark_item.setCheckState(0, Qt.Checked if page_id in self.checked_chapters else Qt.Unchecked) + bookmark_item.setFlags( + bookmark_item.flags() | Qt.ItemIsUserCheckable + ) + bookmark_item.setCheckState( + 0, + ( + Qt.Checked + if page_id in self.checked_chapters + else Qt.Unchecked + ), + ) else: - bookmark_item.setFlags(bookmark_item.flags() & ~Qt.ItemIsUserCheckable) + bookmark_item.setFlags( + bookmark_item.flags() & ~Qt.ItemIsUserCheckable + ) # map for uncategorized pages self.bookmark_items_map[page_num] = bookmark_item @@ -1027,10 +1038,21 @@ class HandlerDialog(QDialog): page_item.setData(0, Qt.UserRole, page_id) # only allow checking if this sub-page has content if self.content_lengths.get(page_id, 0) > 0: - page_item.setFlags(page_item.flags() | Qt.ItemIsUserCheckable) - page_item.setCheckState(0, Qt.Checked if page_id in self.checked_chapters else Qt.Unchecked) + page_item.setFlags( + page_item.flags() | Qt.ItemIsUserCheckable + ) + page_item.setCheckState( + 0, + ( + Qt.Checked + if page_id in self.checked_chapters + else Qt.Unchecked + ), + ) else: - page_item.setFlags(page_item.flags() & ~Qt.ItemIsUserCheckable) + page_item.setFlags( + page_item.flags() & ~Qt.ItemIsUserCheckable + ) added_pages.add(sub_page_num) @@ -1038,11 +1060,15 @@ class HandlerDialog(QDialog): covered_pages = set(added_pages) # attach any pages without direct bookmarks under nearest preceding chapter - uncategorized_pages = [i for i in range(len(self.pdf_doc)) if i not in covered_pages] + uncategorized_pages = [ + i for i in range(len(self.pdf_doc)) if i not in covered_pages + ] for page_num in uncategorized_pages: # find nearest previous bookmark prev_nums = [n for n in sorted(self.bookmark_items_map) if n < page_num] - parent_item = self.bookmark_items_map[prev_nums[-1]] if prev_nums else self.treeWidget + parent_item = ( + self.bookmark_items_map[prev_nums[-1]] if prev_nums else self.treeWidget + ) page_id = f"page_{page_num+1}" title = f"Page {page_num+1}" text = self.content_texts.get(page_id, "").strip() @@ -1055,7 +1081,9 @@ class HandlerDialog(QDialog): # only allow checking if uncategorized page has content if self.content_lengths.get(page_id, 0) > 0: page_item.setFlags(page_item.flags() | Qt.ItemIsUserCheckable) - page_item.setCheckState(0, Qt.Checked if page_id in self.checked_chapters else Qt.Unchecked) + page_item.setCheckState( + 0, Qt.Checked if page_id in self.checked_chapters else Qt.Unchecked + ) else: page_item.setFlags(page_item.flags() & ~Qt.ItemIsUserCheckable) @@ -1081,7 +1109,9 @@ class HandlerDialog(QDialog): # only allow checking if standalone page has content if self.content_lengths.get(page_id, 0) > 0: page_item.setFlags(page_item.flags() | Qt.ItemIsUserCheckable) - page_item.setCheckState(0, Qt.Checked if page_id in self.checked_chapters else Qt.Unchecked) + page_item.setCheckState( + 0, Qt.Checked if page_id in self.checked_chapters else Qt.Unchecked + ) else: page_item.setFlags(page_item.flags() & ~Qt.ItemIsUserCheckable) @@ -1182,7 +1212,7 @@ class HandlerDialog(QDialog): self.save_chapters_checkbox = QCheckBox(checkbox_text, self) self.save_chapters_checkbox.setChecked(self.save_chapters_separately) self.save_chapters_checkbox.stateChanged.connect(self.on_save_chapters_changed) - leftLayout.addWidget(self.save_chapters_checkbox) + leftLayout.addWidget(self.save_chapters_checkbox) self.merge_chapters_checkbox = QCheckBox( "Create a merged version at the end", self ) @@ -1191,7 +1221,7 @@ class HandlerDialog(QDialog): self.on_merge_chapters_changed ) leftLayout.addWidget(self.merge_chapters_checkbox) - + self.save_as_project_checkbox = QCheckBox( "Save in a project folder with metadata", self ) @@ -1501,7 +1531,9 @@ class HandlerDialog(QDialog): authors_text = ", ".join(self.book_metadata["authors"]) html_content += f"

By {authors_text}

" - if self.book_metadata["publisher"] or self.book_metadata.get("publication_year"): + if self.book_metadata["publisher"] or self.book_metadata.get( + "publication_year" + ): pub_info = [] if self.book_metadata["publisher"]: pub_info.append(f"Published by {self.book_metadata['publisher']}") @@ -1543,7 +1575,9 @@ class HandlerDialog(QDialog): try: author_items = self.book.get_metadata("DC", "creator") if author_items: - metadata["authors"] = [author[0] for author in author_items if len(author) > 0] + metadata["authors"] = [ + author[0] for author in author_items if len(author) > 0 + ] except Exception as e: logging.warning(f"Error extracting author metadata: {e}") @@ -1560,14 +1594,14 @@ class HandlerDialog(QDialog): metadata["publisher"] = publisher_items[0][0] except Exception as e: logging.warning(f"Error extracting publisher metadata: {e}") - + # Try to extract publication year try: date_items = self.book.get_metadata("DC", "date") if date_items and len(date_items) > 0: date_str = date_items[0][0] # Try to extract just the year from the date string - year_match = re.search(r'\b(19|20)\d{2}\b', date_str) + year_match = re.search(r"\b(19|20)\d{2}\b", date_str) if year_match: metadata["publication_year"] = year_match.group(0) else: @@ -1603,16 +1637,16 @@ class HandlerDialog(QDialog): metadata["description"] = f"Keywords: {keywords}" metadata["publisher"] = pdf_info.get("creator", None) - + # Try to extract publication date from PDF metadata if "creationDate" in pdf_info: date_str = pdf_info["creationDate"] - year_match = re.search(r'D:(\d{4})', date_str) + year_match = re.search(r"D:(\d{4})", date_str) if year_match: metadata["publication_year"] = year_match.group(1) elif "modDate" in pdf_info: date_str = pdf_info["modDate"] - year_match = re.search(r'D:(\d{4})', date_str) + year_match = re.search(r"D:(\d{4})", date_str) if year_match: metadata["publication_year"] = year_match.group(1) @@ -1634,22 +1668,26 @@ class HandlerDialog(QDialog): def _format_metadata_tags(self): """Format metadata tags for insertion at the beginning of the text""" import datetime - + metadata = self.book_metadata filename = os.path.splitext(os.path.basename(self.book_path))[0] current_year = str(datetime.datetime.now().year) - + # Get values with fallbacks title = metadata.get("title") or filename authors = metadata.get("authors") or ["Unknown"] authors_text = ", ".join(authors) album_artist = authors_text or "Unknown" - year = metadata.get("publication_year") or current_year # Use publication year if available - + year = ( + metadata.get("publication_year") or current_year + ) # Use publication year if available + # Count chapters/pages total_chapters = len(self.checked_chapters) - chapter_text = f"{total_chapters} {'Chapters' if self.file_type == 'epub' else 'Pages'}" - + chapter_text = ( + f"{total_chapters} {'Chapters' if self.file_type == 'epub' else 'Pages'}" + ) + # Format metadata tags metadata_tags = [ f"<>", @@ -1658,9 +1696,9 @@ class HandlerDialog(QDialog): f"<>", f"<>", f"<>", - f"<>" + f"<>", ] - + return "\n".join(metadata_tags) def _get_epub_selected_text(self): @@ -1669,7 +1707,7 @@ class HandlerDialog(QDialog): # Add metadata tags at the beginning metadata_tags = self._format_metadata_tags() - + item_order_counter = 0 ordered_checked_items = [] @@ -1730,7 +1768,10 @@ class HandlerDialog(QDialog): if text: all_content.append(text) included_text_ids.add(page_id) - return metadata_tags + "\n\n" + "\n\n".join(all_content), all_checked_identifiers + return ( + metadata_tags + "\n\n" + "\n\n".join(all_content), + all_checked_identifiers, + ) iterator = QTreeWidgetItemIterator(self.treeWidget) while iterator.value(): @@ -1788,7 +1829,10 @@ class HandlerDialog(QDialog): included_text_ids.add(identifier) iterator += 1 - return metadata_tags + "\n\n" + "\n\n".join([t[1] for t in section_titles]), all_checked_identifiers + return ( + metadata_tags + "\n\n" + "\n\n".join([t[1] for t in section_titles]), + all_checked_identifiers, + ) def on_save_chapters_changed(self, state): self.save_chapters_separately = bool(state) @@ -1815,21 +1859,21 @@ class HandlerDialog(QDialog): def get_save_as_project(self): return self.save_as_project - + def check_selected_items(self): self.set_selected_items_checked(True) - + def uncheck_selected_items(self): self.set_selected_items_checked(False) - + def set_selected_items_checked(self, state: bool): print(f"Checking selected items: {state}") self.treeWidget.blockSignals(True) for item in self.treeWidget.selectedItems(): - if item.flags() & Qt.ItemIsUserCheckable: - item.setCheckState(0, Qt.Checked if state else Qt.Unchecked) + if item.flags() & Qt.ItemIsUserCheckable: + item.setCheckState(0, Qt.Checked if state else Qt.Unchecked) self.treeWidget.blockSignals(False) - self._update_checked_set_from_tree() + self._update_checked_set_from_tree() def on_tree_context_menu(self, pos): item = self.treeWidget.itemAt(pos) @@ -1842,7 +1886,7 @@ class HandlerDialog(QDialog): action.triggered.connect(self.uncheck_selected_items) menu.exec_(self.treeWidget.mapToGlobal(pos)) return - + if ( not item or item.childCount() == 0 diff --git a/abogen/constants.py b/abogen/constants.py index a403302..b7074e1 100644 --- a/abogen/constants.py +++ b/abogen/constants.py @@ -129,4 +129,4 @@ COLORS = { "DARK_DISABLED": "#535353", "LIGHT_BG": "#eff0f1", "LIGHT_DISABLED": "#9a9999", -} \ No newline at end of file +} diff --git a/abogen/conversion.py b/abogen/conversion.py index 0a31e60..dfc62eb 100644 --- a/abogen/conversion.py +++ b/abogen/conversion.py @@ -10,13 +10,20 @@ from PyQt5.QtCore import QThread, pyqtSignal, Qt, QTimer from PyQt5.QtWidgets import QCheckBox, QVBoxLayout, QDialog, QLabel, QDialogButtonBox import soundfile as sf from abogen.utils import clean_text, create_process -from abogen.constants import PROGRAM_NAME, LANGUAGE_DESCRIPTIONS, SAMPLE_VOICE_TEXTS, COLORS, CHAPTER_OPTIONS_COUNTDOWN +from abogen.constants import ( + PROGRAM_NAME, + LANGUAGE_DESCRIPTIONS, + SAMPLE_VOICE_TEXTS, + COLORS, + CHAPTER_OPTIONS_COUNTDOWN, +) from abogen.voice_formulas import get_new_voice import abogen.hf_tracker as hf_tracker import static_ffmpeg import threading # for efficient waiting import subprocess + def get_sample_voice_text(lang_code): return SAMPLE_VOICE_TEXTS.get(lang_code, SAMPLE_VOICE_TEXTS["a"]) @@ -73,7 +80,9 @@ class ChapterOptionsDialog(QDialog): # Countdown label self.countdown_seconds = CHAPTER_OPTIONS_COUNTDOWN - self.countdown_label = QLabel(f"Auto-accepting in {self.countdown_seconds} seconds...") + self.countdown_label = QLabel( + f"Auto-accepting in {self.countdown_seconds} seconds..." + ) self.countdown_label.setStyleSheet(f"color: {COLORS['GREEN']};") layout.addWidget(self.countdown_label) @@ -96,7 +105,9 @@ class ChapterOptionsDialog(QDialog): def _on_timer_tick(self): self.countdown_seconds -= 1 if self.countdown_seconds > 0: - self.countdown_label.setText(f"Auto-accepting in {self.countdown_seconds} seconds...") + self.countdown_label.setText( + f"Auto-accepting in {self.countdown_seconds} seconds..." + ) else: self._timer.stop() self._button_box.accepted.emit() # Simulate OK click @@ -152,7 +163,7 @@ class ConversionThread(QThread): start_time, total_char_count, use_gpu=True, - from_queue=False + from_queue=False, ): # Add use_gpu parameter super().__init__() self._chapter_options_event = threading.Event() @@ -182,15 +193,17 @@ class ConversionThread(QThread): self.use_gpu = use_gpu # Store the GPU setting self.max_subtitle_words = 50 # Default value, will be overridden from GUI - def _stream_audio_in_chunks(self, segments, process_func, progress_prefix="Processing"): + def _stream_audio_in_chunks( + self, segments, process_func, progress_prefix="Processing" + ): """ Process audio segments in memory-efficient chunks - + Args: segments: List of audio segments to process process_func: Function that takes (segment_bytes, is_last) and processes a chunk progress_prefix: Prefix for progress messages - + Returns: Total samples processed """ @@ -199,83 +212,98 @@ class ConversionThread(QThread): samples_processed = 0 self.log_updated.emit(f"\n{progress_prefix} segments...") - + # Stream each segment individually for i, segment in enumerate(segments): try: # Handle both NumPy arrays and PyTorch tensors - if hasattr(segment, 'astype'): + if hasattr(segment, "astype"): segment_bytes = segment.astype("float32").tobytes() else: segment_bytes = segment.cpu().numpy().astype("float32").tobytes() - is_last = (i == len(segments) - 1) - + is_last = i == len(segments) - 1 + # Update progress periodically - skip if there's only one segment if (i % 20 == 0 or is_last) and len(segments) > 1: progress_percent = int((samples_processed / total_samples) * 100) - self.log_updated.emit(f"{progress_prefix} segment {i+1}/{len(segments)} ({progress_percent}% complete)") - + self.log_updated.emit( + f"{progress_prefix} segment {i+1}/{len(segments)} ({progress_percent}% complete)" + ) + # Process this segment process_func(segment_bytes, is_last) - + # Update samples processed samples_processed += len(segment) - + # Clear segment bytes from memory del segment_bytes except Exception as e: self.log_updated.emit(f"Error processing segment {i}: {str(e)}") raise - + return samples_processed - def _process_audio_segments(self, audio_segments, output_path, output_format, use_ffmpeg=False, ffmpeg_args=None): + 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: + 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" + "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) @@ -289,48 +317,62 @@ class ConversionThread(QThread): 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()}") - + + 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")) + 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")) + 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: + 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")) + self.log_updated.emit( + (f"Error processing {output_format.upper()} file: {str(e)}", "red") + ) return False, None def run(self): @@ -341,12 +383,14 @@ class ConversionThread(QThread): hf_tracker.set_log_callback(lambda msg: self.log_updated.emit(msg)) # Show configuration self.log_updated.emit("Configuration:") - + # Use file_name for logs if from_queue, otherwise use display_path if available if getattr(self, "from_queue", False): display_file = self.file_name else: - display_file = self.display_path if self.display_path else self.file_name + display_file = ( + self.display_path if self.display_path else self.file_name + ) self.log_updated.emit(f"- Input File: {display_file}") @@ -363,7 +407,9 @@ class ConversionThread(QThread): self.log_updated.emit(f"- Speed: {self.speed}") 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')}") + self.log_updated.emit( + f"- Subtitle format: {getattr(self, 'subtitle_format', 'srt')}" + ) self.log_updated.emit(f"- Save option: {self.save_option}") if self.replace_single_newlines: self.log_updated.emit(f"- Replace single newlines: Yes") @@ -382,8 +428,10 @@ class ConversionThread(QThread): f"- Merge chapters at the end: {'Yes' if merge_at_end else 'No'}" ) # Display the separate chapters format if it's set - separate_format = getattr(self, 'separate_chapters_format', 'wav') - self.log_updated.emit(f"- Separate chapters format: {separate_format}") + separate_format = getattr(self, "separate_chapters_format", "wav") + self.log_updated.emit( + f"- Separate chapters format: {separate_format}" + ) if self.save_option == "Choose output folder": self.log_updated.emit( @@ -408,7 +456,7 @@ class ConversionThread(QThread): # Clean up text using utility function text = clean_text(text) - + # Remove metadata markers from the text to be processed metadata_pattern = r"<]*>>" text = re.sub(metadata_pattern, "", text) @@ -478,7 +526,6 @@ class ConversionThread(QThread): chapters_out_dir = None suffix = "" - # Use file_name for logs if from_queue, otherwise use display_path if available if getattr(self, "from_queue", False): base_path = self.file_name @@ -701,42 +748,61 @@ class ConversionThread(QThread): # 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("_") + 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') - + separate_format = getattr(self, "separate_chapters_format", "wav") + chapter_out_path = os.path.join( chapters_out_dir, f"{chapter_filename}.{separate_format}" ) # 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"]) + 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")) + self.log_updated.emit( + (f"Failed to write {separate_format.upper()} file.", "red") + ) 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' + 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: + + 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) + 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" + chapter_subtitle_path, + "w", + encoding="utf-8", + errors="replace", ) as srt_file: for i, (start, end, text) in enumerate( chapter_subtitle_entries, 1 @@ -768,8 +834,8 @@ class ConversionThread(QThread): or not self.save_chapters_separately or getattr(self, "merge_chapters_at_end", True) ) - - intended_output_format = self.output_format # Store the original choice + + intended_output_format = self.output_format # Store the original choice if audio_segments and merge_chapters: self.log_updated.emit("\nFinalizing audio file...") @@ -778,7 +844,7 @@ class ConversionThread(QThread): 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...") @@ -788,35 +854,52 @@ class ConversionThread(QThread): 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"]) + 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) + 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: + 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) + 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): + 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" ) @@ -829,11 +912,16 @@ class ConversionThread(QThread): ) else: self.conversion_finished.emit( - (f"\nAudiobook saved to: {final_out_path}", "green"), final_out_path + (f"\nAudiobook saved to: {final_out_path}", "green"), + final_out_path, ) else: - self.log_updated.emit(("Audio generation failed (final_out_path was not set).", "red")) - self.conversion_finished.emit(("Audio generation failed.", "red"), None) + self.log_updated.emit( + ("Audio generation failed (final_out_path was not set).", "red") + ) + self.conversion_finished.emit( + ("Audio generation failed.", "red"), None + ) elif audio_segments and not merge_chapters: self.conversion_finished.emit( ( @@ -856,12 +944,14 @@ 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): + 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( @@ -877,7 +967,9 @@ class ConversionThread(QThread): if success: return wav_path else: - self.log_updated.emit((f"\nFailed to save single/no chapter audio as WAV", "red")) + self.log_updated.emit( + (f"\nFailed to save single/no chapter audio as WAV", "red") + ) return None try: @@ -885,46 +977,61 @@ class ConversionThread(QThread): 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('=', '\\=') + 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", + "-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 + "-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 + 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")) + 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")) + 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 ) @@ -936,30 +1043,37 @@ class ConversionThread(QThread): 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")) - + 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")) + 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 = [] - + # Get the input text (either direct or from file) text = "" if self.is_direct_text: @@ -967,64 +1081,77 @@ class ConversionThread(QThread): else: try: encoding = detect_encoding(self.file_name) - with open(self.file_name, "r", encoding=encoding, errors="replace") as file: + with open( + self.file_name, "r", encoding=encoding, errors="replace" + ) as file: text = file.read() except Exception as e: - self.log_updated.emit(f"Warning: Could not read file for metadata extraction: {e}") + self.log_updated.emit( + f"Warning: Could not read file for metadata extraction: {e}" + ) return [] - + # Extract metadata tags using regex title_match = re.search(r"<]*)>>", text) - artist_match = re.search(r"<]*)>>", text) + artist_match = re.search(r"<]*)>>", text) album_match = re.search(r"<]*)>>", text) year_match = re.search(r"<]*)>>", text) album_artist_match = re.search(r"<]*)>>", text) composer_match = re.search(r"<]*)>>", text) genre_match = re.search(r"<]*)>>", text) - + # Use display path or filename as fallback for title - + # Use file_name for logs if from_queue, otherwise use display_path if available if getattr(self, "from_queue", False): filename = os.path.splitext(os.path.basename(self.file_name))[0] else: - filename = os.path.splitext(os.path.basename(self.display_path if self.display_path else self.file_name))[0] + filename = os.path.splitext( + os.path.basename( + self.display_path if self.display_path else self.file_name + ) + )[0] if title_match: metadata_options.extend(["-metadata", f"title={title_match.group(1)}"]) else: metadata_options.extend(["-metadata", f"title={filename}"]) - + # Add artist metadata if artist_match: metadata_options.extend(["-metadata", f"artist={artist_match.group(1)}"]) else: metadata_options.extend(["-metadata", f"artist=Unknown"]) - + # Add album metadata if album_match: metadata_options.extend(["-metadata", f"album={album_match.group(1)}"]) else: metadata_options.extend(["-metadata", f"album={filename}"]) - + # Add year metadata if year_match: metadata_options.extend(["-metadata", f"date={year_match.group(1)}"]) else: # Use current year if year is not specified import datetime + current_year = datetime.datetime.now().year metadata_options.extend(["-metadata", f"date={current_year}"]) # Add album artist metadata if album_artist_match: - metadata_options.extend(["-metadata", f"album_artist={album_artist_match.group(1)}"]) + metadata_options.extend( + ["-metadata", f"album_artist={album_artist_match.group(1)}"] + ) else: metadata_options.extend(["-metadata", f"album_artist=Unknown"]) # Add composer metadata if composer_match: - metadata_options.extend(["-metadata", f"composer={composer_match.group(1)}"]) + metadata_options.extend( + ["-metadata", f"composer={composer_match.group(1)}"] + ) else: metadata_options.extend(["-metadata", f"composer=Narrator"]) @@ -1033,7 +1160,7 @@ class ConversionThread(QThread): metadata_options.extend(["-metadata", f"genre={genre_match.group(1)}"]) else: metadata_options.extend(["-metadata", f"genre=Audiobook"]) - + # Add these to ffmpeg command return metadata_options @@ -1044,7 +1171,7 @@ class ConversionThread(QThread): s = int(t % 60) ms = int((t - int(t)) * 1000) return f"{h:02}:{m:02}:{s:02},{ms:03}" - + def _ass_time(self, t): """Helper function to format time for ASS files""" h = int(t // 3600) @@ -1139,17 +1266,21 @@ class ConversionThread(QThread): (group[0]["start"], group[-1]["end"], text.strip()) ) - def _write_ass_subtitle(self, file_path, subtitle_entries, is_centered=False, is_narrow=False): + def _write_ass_subtitle( + self, file_path, subtitle_entries, is_centered=False, is_narrow=False + ): with open(file_path, "w", encoding="utf-8", errors="replace") as f: # Minimal ASS header f.write("[Script Info]\n") f.write("Title: Generated by Abogen\n") f.write("ScriptType: v4.00+\n\n") - + # Only events section, use override tags for positioning f.write("[Events]\n") - f.write("Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n") - + f.write( + "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n" + ) + # Set margin based on is_narrow parameter margin = "90" if is_narrow else "" alignment_tag = "" @@ -1161,7 +1292,9 @@ class ConversionThread(QThread): for i, (start, end, text) in enumerate(subtitle_entries, 1): start_time = self._ass_time(start) end_time = self._ass_time(end) - f.write(f"Dialogue: 0,{start_time},{end_time},Default,,{margin},{margin},0,,{alignment_tag}{text}\n") + f.write( + f"Dialogue: 0,{start_time},{end_time},Default,,{margin},{margin},0,,{alignment_tag}{text}\n" + ) def cancel(self): self.cancel_requested = True @@ -1196,11 +1329,13 @@ class VoicePreviewThread(QThread): self.voice = voice self.speed = speed self.use_gpu = use_gpu - + # Cache location for preview audio - self.cache_dir = os.path.join(tempfile.gettempdir(), PROGRAM_NAME, "preview_cache") + self.cache_dir = os.path.join( + tempfile.gettempdir(), PROGRAM_NAME, "preview_cache" + ) os.makedirs(self.cache_dir, exist_ok=True) - + # Calculate cache path self.cache_path = self._get_cache_path() @@ -1208,10 +1343,12 @@ class VoicePreviewThread(QThread): """Generate a unique filename for the voice with its parameters""" # For a voice formula, use a hash of the formula if "*" in self.voice: - voice_id = f"voice_formula_{hashlib.md5(self.voice.encode()).hexdigest()[:8]}" + voice_id = ( + f"voice_formula_{hashlib.md5(self.voice.encode()).hexdigest()[:8]}" + ) else: voice_id = self.voice - + # Create a unique filename based on voice_id, language, and speed filename = f"{voice_id}_{self.lang_code}_{self.speed:.2f}.wav" return os.path.join(self.cache_dir, filename) @@ -1220,7 +1357,7 @@ class VoicePreviewThread(QThread): print( f"\nVoice: {self.voice}\nLanguage: {self.lang_code}\nSpeed: {self.speed}\nGPU: {self.use_gpu}\n" ) - + # Generate the preview and save to cache try: device = "cuda" if self.use_gpu else "cpu" @@ -1268,7 +1405,7 @@ class PlayAudioThread(QThread): # Wait until playback is finished or canceled while pygame.mixer.music.get_busy() and not self.is_canceled: _time.sleep(0.2) - + # Make sure to clean up regardless of how we exited the loop try: pygame.mixer.music.stop() @@ -1277,21 +1414,24 @@ class PlayAudioThread(QThread): except Exception: # Ignore any errors during cleanup pass - + self.finished.emit() except Exception as e: # Handle initialization errors separately to give better error messages if "mixer not initialized" in str(e): - self.error.emit("Audio playback error: The audio system was not properly initialized") + self.error.emit( + "Audio playback error: The audio system was not properly initialized" + ) else: self.error.emit(f"Audio playback error: {str(e)}") - + def stop(self): """Safely stop playback""" self.is_canceled = True # Try to stop pygame if it's running, but catch all exceptions try: import pygame + if pygame.mixer.get_init(): if pygame.mixer.music.get_busy(): pygame.mixer.music.stop() diff --git a/abogen/gui.py b/abogen/gui.py index ff22794..066ac44 100644 --- a/abogen/gui.py +++ b/abogen/gui.py @@ -74,7 +74,7 @@ from abogen.constants import ( LANGUAGE_DESCRIPTIONS, VOICES_INTERNAL, SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION, - COLORS + COLORS, ) import threading from abogen.voice_formula_gui import VoiceFormulaDialog @@ -85,6 +85,7 @@ if platform.system() == "Windows": import ctypes import winreg + class DarkTitleBarEventFilter(QObject): def __init__(self, is_windows, get_dark_mode_func, set_title_bar_dark_mode_func): super().__init__() @@ -100,6 +101,7 @@ class DarkTitleBarEventFilter(QObject): self.set_title_bar_dark_mode(obj, True) return False + class ShowWarningSignalEmitter(QObject): # New class to handle signal emission show_warning_signal = pyqtSignal(str, str) @@ -126,12 +128,16 @@ class InputBox(QLabel): # Define CSS styles as class constants STYLE_DEFAULT = f"border:2px dashed #aaa; border-radius:5px; padding:20px; background:{COLORS['BLUE_BG']}; min-height:100px;" STYLE_DEFAULT_HOVER = f"background:{COLORS['BLUE_BG_HOVER']}; border-color:{COLORS['BLUE_BORDER_HOVER']};" - + STYLE_ACTIVE = f"border:2px dashed {COLORS['GREEN']}; border-radius:5px; padding:20px; background:{COLORS['GREEN_BG']}; min-height:100px;" - STYLE_ACTIVE_HOVER = f"background:{COLORS['GREEN_BG_HOVER']}; border-color:{COLORS['GREEN_BORDER']};" - + STYLE_ACTIVE_HOVER = ( + f"background:{COLORS['GREEN_BG_HOVER']}; border-color:{COLORS['GREEN_BORDER']};" + ) + STYLE_ERROR = f"border:2px dashed {COLORS['RED']}; border-radius:5px; padding:20px; background:{COLORS['RED_BG']}; min-height:100px; color:{COLORS['RED']};" - STYLE_ERROR_HOVER = f"background:{COLORS['RED_BG_HOVER']}; border-color:{COLORS['RED']};" + STYLE_ERROR_HOVER = ( + f"background:{COLORS['RED_BG_HOVER']}; border-color:{COLORS['RED']};" + ) def __init__(self, parent=None): super().__init__(parent) @@ -140,7 +146,9 @@ class InputBox(QLabel): self.setText( "Drag and drop your file here or click to browse.\n(.txt, .epub, .pdf)" ) - self.setStyleSheet(f"QLabel {{ {self.STYLE_DEFAULT} }} QLabel:hover {{ {self.STYLE_DEFAULT_HOVER} }}") + self.setStyleSheet( + f"QLabel {{ {self.STYLE_DEFAULT} }} QLabel:hover {{ {self.STYLE_DEFAULT_HOVER} }}" + ) self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.setCursor(Qt.PointingHandCursor) @@ -172,7 +180,9 @@ class InputBox(QLabel): # Add Go to folder button self.go_to_folder_btn = QPushButton("Go to folder", self) self.go_to_folder_btn.setStyleSheet("QPushButton { padding: 6px 10px; }") - self.go_to_folder_btn.setToolTip("Open the folder that contains the converted file") + self.go_to_folder_btn.setToolTip( + "Open the folder that contains the converted file" + ) self.go_to_folder_btn.clicked.connect(self.on_go_to_folder_clicked) self.go_to_folder_btn.hide() @@ -215,7 +225,6 @@ class InputBox(QLabel): return float(match.group(1)) raise ValueError(f"Invalid size format: {size_str}") - # Format numbers with commas def format_num(n): try: @@ -265,7 +274,9 @@ class InputBox(QLabel): ) # Set fixed width to force wrapping self.setWordWrap(True) - self.setStyleSheet(f"QLabel {{ {self.STYLE_ACTIVE} }} QLabel:hover {{ {self.STYLE_ACTIVE_HOVER} }}") + self.setStyleSheet( + f"QLabel {{ {self.STYLE_ACTIVE} }} QLabel:hover {{ {self.STYLE_ACTIVE_HOVER} }}" + ) self.clear_btn.show() is_document = self.window().selected_file_type in ["epub", "pdf"] self.chapters_btn.setVisible(is_document) @@ -278,7 +289,6 @@ class InputBox(QLabel): else: # PDF - always use Pages self.chapters_btn.setText(f"Pages ({chapter_count})") - # Hide textbox and show edit only for .txt files self.textbox_btn.hide() # Show edit button for txt files directly @@ -296,21 +306,23 @@ class InputBox(QLabel): self.go_to_folder_btn.show() # Enable add to queue button only when file is accepted (input box is green) self.resizeEvent(None) - if hasattr(self.window(), 'btn_add_to_queue'): + if hasattr(self.window(), "btn_add_to_queue"): self.window().btn_add_to_queue.setEnabled(True) self.chapters_btn.adjustSize() # Reset the input_box_cleared_by_queue flag after setting file info - if hasattr(self.window(), 'input_box_cleared_by_queue'): + if hasattr(self.window(), "input_box_cleared_by_queue"): self.window().input_box_cleared_by_queue = False def set_error(self, message): self.setText(message) - self.setStyleSheet(f"QLabel {{ {self.STYLE_ERROR} }} QLabel:hover {{ {self.STYLE_ERROR_HOVER} }}") + self.setStyleSheet( + f"QLabel {{ {self.STYLE_ERROR} }} QLabel:hover {{ {self.STYLE_ERROR_HOVER} }}" + ) # Show textbox button in error state as well self.textbox_btn.show() # Disable add to queue button on error - if hasattr(self.window(), 'btn_add_to_queue'): + if hasattr(self.window(), "btn_add_to_queue"): self.window().btn_add_to_queue.setEnabled(False) def clear_input(self): @@ -321,7 +333,9 @@ class InputBox(QLabel): self.setText( "Drag and drop your file here or click to browse.\n(.txt, .epub, .pdf)" ) - self.setStyleSheet(f"QLabel {{ {self.STYLE_DEFAULT} }} QLabel:hover {{ {self.STYLE_DEFAULT_HOVER} }}") + self.setStyleSheet( + f"QLabel {{ {self.STYLE_DEFAULT} }} QLabel:hover {{ {self.STYLE_DEFAULT_HOVER} }}" + ) self.clear_btn.hide() self.chapters_btn.hide() self.chapters_btn.setText("Chapters") # Reset text @@ -330,10 +344,10 @@ class InputBox(QLabel): self.edit_btn.hide() self.go_to_folder_btn.hide() # Disable add to queue button when input is cleared - if hasattr(self.window(), 'btn_add_to_queue'): + if hasattr(self.window(), "btn_add_to_queue"): self.window().btn_add_to_queue.setEnabled(False) # Reset the input_box_cleared_by_queue flag after setting file info - if hasattr(self.window(), 'input_box_cleared_by_queue'): + if hasattr(self.window(), "input_box_cleared_by_queue"): self.window().input_box_cleared_by_queue = True def _human_readable_size(self, size, decimal_places=2): @@ -352,26 +366,42 @@ class InputBox(QLabel): urls = event.mimeData().urls() if urls: ext = urls[0].toLocalFile().lower() - if ext.endswith(".txt") or ext.endswith(".epub") or ext.endswith(".pdf"): + if ( + ext.endswith(".txt") + or ext.endswith(".epub") + or ext.endswith(".pdf") + ): event.acceptProposedAction() # Set hover style based on current state if self.styleSheet().find(self.STYLE_ACTIVE) != -1: - self.setStyleSheet(f"QLabel {{ {self.STYLE_ACTIVE} }} QLabel:hover {{ {self.STYLE_ACTIVE_HOVER} }} {self.STYLE_ACTIVE_HOVER}") + self.setStyleSheet( + f"QLabel {{ {self.STYLE_ACTIVE} }} QLabel:hover {{ {self.STYLE_ACTIVE_HOVER} }} {self.STYLE_ACTIVE_HOVER}" + ) elif self.styleSheet().find(self.STYLE_ERROR) != -1: - self.setStyleSheet(f"QLabel {{ {self.STYLE_ERROR} }} QLabel:hover {{ {self.STYLE_ERROR_HOVER} }} {self.STYLE_ERROR_HOVER}") + self.setStyleSheet( + f"QLabel {{ {self.STYLE_ERROR} }} QLabel:hover {{ {self.STYLE_ERROR_HOVER} }} {self.STYLE_ERROR_HOVER}" + ) else: - self.setStyleSheet(f"QLabel {{ {self.STYLE_DEFAULT} }} QLabel:hover {{ {self.STYLE_DEFAULT_HOVER} }} {self.STYLE_DEFAULT_HOVER}") + self.setStyleSheet( + f"QLabel {{ {self.STYLE_DEFAULT} }} QLabel:hover {{ {self.STYLE_DEFAULT_HOVER} }} {self.STYLE_DEFAULT_HOVER}" + ) return event.ignore() def dragLeaveEvent(self, event): # Restore the style based on current state if self.styleSheet().find(self.STYLE_ACTIVE) != -1: - self.setStyleSheet(f"QLabel {{ {self.STYLE_ACTIVE} }} QLabel:hover {{ {self.STYLE_ACTIVE_HOVER} }}") + self.setStyleSheet( + f"QLabel {{ {self.STYLE_ACTIVE} }} QLabel:hover {{ {self.STYLE_ACTIVE_HOVER} }}" + ) elif self.styleSheet().find(self.STYLE_ERROR) != -1: - self.setStyleSheet(f"QLabel {{ {self.STYLE_ERROR} }} QLabel:hover {{ {self.STYLE_ERROR_HOVER} }}") + self.setStyleSheet( + f"QLabel {{ {self.STYLE_ERROR} }} QLabel:hover {{ {self.STYLE_ERROR_HOVER} }}" + ) else: - self.setStyleSheet(f"QLabel {{ {self.STYLE_DEFAULT} }} QLabel:hover {{ {self.STYLE_DEFAULT_HOVER} }}") + self.setStyleSheet( + f"QLabel {{ {self.STYLE_DEFAULT} }} QLabel:hover {{ {self.STYLE_DEFAULT_HOVER} }}" + ) event.accept() def dropEvent(self, event): @@ -432,8 +462,12 @@ class InputBox(QLabel): win = self.window() # win.selected_file holds the path to the text that is converted. file_to_check = win.selected_file - - if file_to_check and os.path.exists(file_to_check) and os.path.isfile(file_to_check): + + if ( + file_to_check + and os.path.exists(file_to_check) + and os.path.isfile(file_to_check) + ): folder_path = os.path.dirname(file_to_check) QDesktopServices.openUrl(QUrl.fromLocalFile(folder_path)) else: @@ -582,6 +616,7 @@ class TextboxDialog(QDialog): self.update_char_count() self.text_edit.setFocus() + def migrate_subtitle_format(config): """Convert old subtitle_format values to new internal keys.""" old_to_new = { @@ -596,6 +631,7 @@ def migrate_subtitle_format(config): config["subtitle_format"] = old_to_new[val] save_config(config) + class abogen(QWidget): def __init__(self): super().__init__() @@ -631,7 +667,9 @@ class abogen(QWidget): "max_subtitle_words", 50 ) # Default max words per subtitle self.selected_format = self.config.get("selected_format", "wav") - self.separate_chapters_format = self.config.get("separate_chapters_format", "wav") # Format for individual chapter files + self.separate_chapters_format = self.config.get( + "separate_chapters_format", "wav" + ) # Format for individual chapter files self.use_gpu = self.config.get( "use_gpu", True # Load GPU setting with default True ) @@ -645,7 +683,9 @@ class abogen(QWidget): # Create warning signal emitter self.warning_signal_emitter = ShowWarningSignalEmitter() - self.warning_signal_emitter.show_warning_signal.connect(self.show_model_download_warning) + self.warning_signal_emitter.show_warning_signal.connect( + self.show_model_download_warning + ) hf_tracker.set_show_warning_signal_emitter(self.warning_signal_emitter) # Set application icon @@ -793,6 +833,7 @@ class abogen(QWidget): self.btn_voice_formula_mixer.setStyleSheet("QPushButton { padding: 6px 12px; }") self.btn_voice_formula_mixer.clicked.connect(self.show_voice_formula_dialog) voice_layout.addWidget(self.btn_voice_formula_mixer) + # Play/Stop icons def make_icon(color, shape): pix = QPixmap(20, 20) @@ -900,13 +941,15 @@ class abogen(QWidget): self.subtitle_format_combo.setStyleSheet( "QComboBox { min-height: 20px; padding: 6px 12px; }" ) - self.subtitle_format_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + 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)") + ("ass_centered_narrow", "ASS (centered narrow)"), ] for value, text in subtitle_formats: self.subtitle_format_combo.addItem(text, value) @@ -927,13 +970,19 @@ class abogen(QWidget): replace_newlines_layout.addWidget(replace_newlines_label) self.replace_newlines_combo = QComboBox(self) self.replace_newlines_combo.addItems(["Disabled", "Enabled"]) - self.replace_newlines_combo.setToolTip("Replace single newlines in the input text with spaces before processing.") + self.replace_newlines_combo.setToolTip( + "Replace single newlines in the input text with spaces before processing." + ) self.replace_newlines_combo.setStyleSheet( "QComboBox { min-height: 20px; padding: 6px 12px; }" ) - self.replace_newlines_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.replace_newlines_combo.setSizePolicy( + QSizePolicy.Expanding, QSizePolicy.Fixed + ) # Set initial value based on config - self.replace_newlines_combo.setCurrentIndex(1 if self.replace_single_newlines else 0) + self.replace_newlines_combo.setCurrentIndex( + 1 if self.replace_single_newlines else 0 + ) self.replace_newlines_combo.currentIndexChanged.connect( lambda idx: self.toggle_replace_single_newlines(idx == 1) ) @@ -969,9 +1018,7 @@ class abogen(QWidget): selected_folder_label = QLabel("Selected folder:", self.save_path_row_widget) save_path_row.addWidget(selected_folder_label) self.save_path_label = QLabel("", self.save_path_row_widget) - self.save_path_label.setStyleSheet( - f"QLabel {{ color: {COLORS['GREEN']}; }}" - ) + self.save_path_label.setStyleSheet(f"QLabel {{ color: {COLORS['GREEN']}; }}") self.save_path_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) save_path_row.addWidget(self.save_path_label) self.save_path_row_widget.hide() # Hide the whole row by default @@ -1118,7 +1165,8 @@ class abogen(QWidget): ) dialog.setWindowModality(Qt.NonModal) dialog.setModal(False) - dialog.show() # We'll handle the dialog result asynchronously + dialog.show() # We'll handle the dialog result asynchronously + def on_dialog_finished(result): if result != QDialog.Accepted: return False @@ -1142,7 +1190,7 @@ class abogen(QWidget): # Use "abogen" prefix for temporary files # Extract base name without extension base_name = os.path.splitext(os.path.basename(book_path))[0] - + if self.save_as_project: # Get project directory from user project_dir = QFileDialog.getExistingDirectory( @@ -1158,27 +1206,54 @@ class abogen(QWidget): project_dir = os.path.join(project_dir, project_name) temp_dir = os.path.join(project_dir, "text") os.makedirs(temp_dir, exist_ok=True) - + # Save metadata if available meta_dir = os.path.join(project_dir, "metadata") - os.makedirs(meta_dir, exist_ok=True) # Save book metadata if available + os.makedirs( + meta_dir, exist_ok=True + ) # Save book metadata if available if hasattr(dialog, "book_metadata"): meta_path = os.path.join(meta_dir, "book_info.txt") with open(meta_path, "w", encoding="utf-8") as f: # Clean HTML tags from metadata - title = re.sub(r'<[^>]+>', '', str(dialog.book_metadata.get('title', 'Unknown'))) - publisher = re.sub(r'<[^>]+>', '', str(dialog.book_metadata.get('publisher', 'Unknown'))) - authors = [re.sub(r'<[^>]+>', '', str(author)) for author in dialog.book_metadata.get('authors', ['Unknown'])] - publication_year = re.sub(r'<[^>]+>', '', str(dialog.book_metadata.get('publication_year', 'Unknown'))) - + title = re.sub( + r"<[^>]+>", + "", + str(dialog.book_metadata.get("title", "Unknown")), + ) + publisher = re.sub( + r"<[^>]+>", + "", + str(dialog.book_metadata.get("publisher", "Unknown")), + ) + authors = [ + re.sub(r"<[^>]+>", "", str(author)) + for author in dialog.book_metadata.get( + "authors", ["Unknown"] + ) + ] + publication_year = re.sub( + r"<[^>]+>", + "", + str( + dialog.book_metadata.get( + "publication_year", "Unknown" + ) + ), + ) + f.write(f"Title: {title}\n") f.write(f"Authors: {', '.join(authors)}\n") f.write(f"Publisher: {publisher}\n") f.write(f"Publication Year: {publication_year}\n") - if dialog.book_metadata.get('description'): - description = re.sub(r'<[^>]+>', '', str(dialog.book_metadata.get('description'))) + if dialog.book_metadata.get("description"): + description = re.sub( + r"<[^>]+>", + "", + str(dialog.book_metadata.get("description")), + ) f.write(f"\nDescription:\n{description}\n") - + # Save cover image if available if dialog.book_metadata.get("cover_image"): cover_path = os.path.join(meta_dir, "cover.png") @@ -1404,7 +1479,7 @@ class abogen(QWidget): self._update_log_main_thread(message) def _update_log_main_thread(self, message): - if not hasattr(self, '_log_lines'): + if not hasattr(self, "_log_lines"): self._log_lines = [] # Always produce a single HTML line (with color if tuple) @@ -1423,7 +1498,7 @@ class abogen(QWidget): # Trim buffer if needed if len(self._log_lines) > self.log_window_max_lines: - self._log_lines = self._log_lines[-self.log_window_max_lines:] + self._log_lines = self._log_lines[-self.log_window_max_lines :] self.log_text.clear() for line in self._log_lines: self.log_text.insertHtml(line) @@ -1438,7 +1513,11 @@ class abogen(QWidget): def _get_queue_progress_format(self, value=None): """Return the progress bar format string for queue mode.""" - if hasattr(self, 'queued_items') and self.queued_items and hasattr(self, 'current_queue_index'): + if ( + hasattr(self, "queued_items") + and self.queued_items + and hasattr(self, "current_queue_index") + ): N = self.current_queue_index + 1 M = len(self.queued_items) percent = value if value is not None else self.progress_bar.value() @@ -1453,7 +1532,11 @@ class abogen(QWidget): value = 99 self.progress_bar.setValue(value) # Show queue progress if in queue mode - if hasattr(self, 'queued_items') and self.queued_items and hasattr(self, 'current_queue_index'): + if ( + hasattr(self, "queued_items") + and self.queued_items + and hasattr(self, "current_queue_index") + ): N = self.current_queue_index + 1 M = len(self.queued_items) self.progress_bar.setFormat(f"{value}% ({N}/{M})") @@ -1499,9 +1582,9 @@ class abogen(QWidget): pass self.btn_start.clicked.connect(self.start_conversion) - def enqueue(self, item : QueuedItem): + def enqueue(self, item: QueuedItem): self.queued_items.append(item) - #self.update_log((f"Enqueued: {item.file_name}", True)) + # self.update_log((f"Enqueued: {item.file_name}", True)) # enable start queue button, manage queue button self.enable_disable_queue_buttons() @@ -1509,8 +1592,15 @@ class abogen(QWidget): return self.queued_items def add_to_queue(self): - # Use the file currently displayed in the input box - file_to_queue = self.displayed_file_path if self.displayed_file_path else self.selected_file + # For epub/pdf, always use the converted txt file (selected_file) + if self.selected_file_type in ["epub", "pdf"]: + file_to_queue = self.selected_file + else: + file_to_queue = ( + self.displayed_file_path + if self.displayed_file_path + else self.selected_file + ) if not file_to_queue: self.input_box.set_error("Please add a file.") @@ -1529,23 +1619,26 @@ class abogen(QWidget): subtitle_mode=actual_subtitle_mode, output_format=self.selected_format, total_char_count=self.char_count, - replace_single_newlines=self.replace_single_newlines + replace_single_newlines=self.replace_single_newlines, ) # Prevent adding duplicate items to the queue for queued_item in self.queued_items: if ( - queued_item.file_name == item_queue.file_name and - queued_item.lang_code == item_queue.lang_code and - queued_item.speed == item_queue.speed and - queued_item.voice == item_queue.voice and - queued_item.save_option == item_queue.save_option and - queued_item.output_folder == item_queue.output_folder and - queued_item.subtitle_mode == item_queue.subtitle_mode and - queued_item.output_format == item_queue.output_format and - getattr(queued_item, "replace_single_newlines", False) == item_queue.replace_single_newlines + queued_item.file_name == item_queue.file_name + and queued_item.lang_code == item_queue.lang_code + and queued_item.speed == item_queue.speed + and queued_item.voice == item_queue.voice + and queued_item.save_option == item_queue.save_option + and queued_item.output_folder == item_queue.output_folder + and queued_item.subtitle_mode == item_queue.subtitle_mode + and queued_item.output_format == item_queue.output_format + and getattr(queued_item, "replace_single_newlines", False) + == item_queue.replace_single_newlines ): - QMessageBox.warning(self, "Duplicate Item", "This item is already in the queue.") + QMessageBox.warning( + self, "Duplicate Item", "This item is already in the queue." + ) return self.enqueue(item_queue) @@ -1562,7 +1655,7 @@ class abogen(QWidget): "Confirm Clear Queue", f"Are you sure you want to clear {len(self.queued_items)} items from the queue?", QMessageBox.Yes | QMessageBox.No, - QMessageBox.No + QMessageBox.No, ) if reply != QMessageBox.Yes: return @@ -1598,7 +1691,9 @@ class abogen(QWidget): self.subtitle_mode = queued_item.subtitle_mode self.selected_format = queued_item.output_format self.char_count = queued_item.total_char_count - self.replace_single_newlines = getattr(queued_item, "replace_single_newlines", False) + self.replace_single_newlines = getattr( + queued_item, "replace_single_newlines", False + ) self.start_conversion(from_queue=True) else: # Queue finished, reset index @@ -1612,7 +1707,6 @@ class abogen(QWidget): else: self.current_queue_index = 0 # Reset for next time - def get_voice_formula(self) -> str: if self.mixed_voice_state: formula_components = [ @@ -1625,22 +1719,19 @@ class abogen(QWidget): def get_selected_lang(self, voice_formula) -> str: if self.selected_profile_name: from abogen.voice_profiles import load_profiles + entry = load_profiles().get(self.selected_profile_name, {}) selected_lang = entry.get("language") else: selected_lang = self.selected_voice[0] if self.selected_voice else None # fallback: extract from formula if missing if not selected_lang: - m = re.search(r"\b([a-z])", voice_formula) - selected_lang = m.group(1) if m else None + m = re.search(r"\b([a-z])", voice_formula) + selected_lang = m.group(1) if m else None return selected_lang def get_actual_subtitle_mode(self) -> str: - return ( - "Disabled" - if not self.subtitle_combo.isEnabled() - else self.subtitle_mode - ) + return "Disabled" if not self.subtitle_combo.isEnabled() else self.subtitle_mode def start_conversion(self, from_queue=False): if not self.selected_file: @@ -1651,7 +1742,12 @@ class abogen(QWidget): self.convert_input_box_to_log() self.progress_bar.setValue(0) # Show queue progress if in queue mode - if from_queue and hasattr(self, 'queued_items') and self.queued_items and hasattr(self, 'current_queue_index'): + if ( + from_queue + and hasattr(self, "queued_items") + and self.queued_items + and hasattr(self, "current_queue_index") + ): N = self.current_queue_index + 1 M = len(self.queued_items) self.progress_bar.setFormat(f"0% ({N}/{M})") @@ -1725,9 +1821,13 @@ class abogen(QWidget): self.replace_single_newlines ) # Pass separate_chapters_format setting - self.conversion_thread.separate_chapters_format = self.separate_chapters_format + self.conversion_thread.separate_chapters_format = ( + self.separate_chapters_format + ) # Pass subtitle format setting - self.conversion_thread.subtitle_format = self.config.get("subtitle_format", "ass_centered_narrow") + self.conversion_thread.subtitle_format = self.config.get( + "subtitle_format", "ass_centered_narrow" + ) # Pass chapter count for EPUB or PDF files if self.selected_file_type in ["epub", "pdf"] and hasattr( self, "selected_chapters" @@ -1780,8 +1880,7 @@ class abogen(QWidget): f"

Queue finished

" f"Processed {len(self.queued_items)} items:

" ) - - + for idx, item in enumerate(self.queued_items, 1): output = getattr(item, "output_path", None) if not output: @@ -1834,7 +1933,7 @@ class abogen(QWidget): else self.selected_file ) # Only repopulate if not cleared by queue - if not getattr(self, 'input_box_cleared_by_queue', False): + if not getattr(self, "input_box_cleared_by_queue", False): if display_path and os.path.exists(display_path): self.input_box.set_file_info(display_path) else: @@ -1873,7 +1972,10 @@ class abogen(QWidget): self.open_file_btn.setVisible(show_open_file_button) # Only show finish_widget if queue is done - if self.current_queue_index + 1 >= len(self.queued_items) or not self.queued_items: + if ( + self.current_queue_index + 1 >= len(self.queued_items) + or not self.queued_items + ): # Queue finished, show finish screen self.controls_widget.hide() self.finish_widget.show() @@ -1938,7 +2040,7 @@ class abogen(QWidget): ) # Only repopulate if not cleared by queue - if not getattr(self, 'input_box_cleared_by_queue', False): + if not getattr(self, "input_box_cleared_by_queue", False): if display_path and os.path.exists(display_path): self.input_box.set_file_info(display_path) else: @@ -2031,6 +2133,7 @@ class abogen(QWidget): voice_to_cache = voice_formula if self.selected_profile_name: from abogen.voice_profiles import load_profiles + entry = load_profiles().get(self.selected_profile_name, {}) lang_to_cache = entry.get("language") else: @@ -2051,12 +2154,14 @@ class abogen(QWidget): return None cache_dir = os.path.join(tempfile.gettempdir(), PROGRAM_NAME, "preview_cache") - + if "*" in voice_to_cache: # Voice formula - voice_id = f"voice_formula_{hashlib.md5(voice_to_cache.encode()).hexdigest()[:8]}" + voice_id = ( + f"voice_formula_{hashlib.md5(voice_to_cache.encode()).hexdigest()[:8]}" + ) else: # Single voice voice_id = voice_to_cache - + filename = f"{voice_id}_{lang_to_cache}_{speed:.2f}.wav" return os.path.join(cache_dir, filename) @@ -2083,7 +2188,7 @@ class abogen(QWidget): self.voice_combo.setEnabled(False) self.btn_voice_formula_mixer.setEnabled(False) self.btn_start.setEnabled(False) - + # Directly play from cache self.preview_playing = True self.btn_preview.setIcon(self.stop_icon) @@ -2096,13 +2201,17 @@ class abogen(QWidget): try: # Ensure pygame mixer is initialized for the audio thread import pygame + if not pygame.mixer.get_init(): pygame.mixer.init() self.play_audio_thread = PlayAudioThread(cached_path) self.play_audio_thread.finished.connect(cleanup_cached_play) self.play_audio_thread.error.connect( - lambda msg: (self._show_preview_error_box(msg), cleanup_cached_play()) + lambda msg: ( + self._show_preview_error_box(msg), + cleanup_cached_play(), + ) ) self.play_audio_thread.start() except Exception as e: @@ -2192,31 +2301,38 @@ class abogen(QWidget): def _play_preview_audio(self, from_cache=True): # from_cache default is now False # If preview_thread is the source, get temp_wav from it - if hasattr(self, 'preview_thread') and not from_cache: + if hasattr(self, "preview_thread") and not from_cache: temp_wav = self.preview_thread.temp_wav elif from_cache: # This case is now handled before calling _play_preview_audio cached_path = self._get_preview_cache_path() if cached_path and os.path.exists(cached_path): temp_wav = cached_path else: # Should not happen if cache check was done - self._show_error_message_box("Preview Error", "Cache file expected but not found, please try again.") + self._show_error_message_box( + "Preview Error", + "Cache file expected but not found, please try again.", + ) self._preview_cleanup() return else: # Should have temp_wav from preview_thread or handled by cache check - self._show_error_message_box("Preview Error", "Preview audio path not found.") + self._show_error_message_box( + "Preview Error", "Preview audio path not found." + ) self._preview_cleanup() return if not temp_wav: - if hasattr(self, 'loading_movie'): self.loading_movie.stop() + if hasattr(self, "loading_movie"): + self.loading_movie.stop() self._show_error_message_box( "Preview Error", "Preview error: No audio generated." ) self._preview_cleanup() return - + # stop loading animation, switch to stop icon - if hasattr(self, 'loading_movie'): self.loading_movie.stop() + if hasattr(self, "loading_movie"): + self.loading_movie.stop() self.preview_playing = True self.btn_preview.setIcon(self.stop_icon) self.btn_preview.setToolTip("Stop preview") @@ -2224,9 +2340,16 @@ class abogen(QWidget): def cleanup(): # Only remove if not from cache AND it's a temp file from VoicePreviewThread - if not from_cache and hasattr(self, 'preview_thread') and hasattr(self.preview_thread, 'temp_wav') and self.preview_thread.temp_wav == temp_wav: + if ( + not from_cache + and hasattr(self, "preview_thread") + and hasattr(self.preview_thread, "temp_wav") + and self.preview_thread.temp_wav == temp_wav + ): try: - if os.path.exists(temp_wav): # Ensure it exists before trying to remove + if os.path.exists( + temp_wav + ): # Ensure it exists before trying to remove os.remove(temp_wav) except Exception: pass @@ -2235,6 +2358,7 @@ class abogen(QWidget): try: # Ensure pygame mixer is initialized for the audio thread import pygame + if not pygame.mixer.get_init(): pygame.mixer.init() @@ -2266,9 +2390,11 @@ class abogen(QWidget): def _preview_cleanup(self): self.preview_playing = False - if hasattr(self, 'loading_movie'): self.loading_movie.stop() + if hasattr(self, "loading_movie"): + self.loading_movie.stop() try: - if hasattr(self, 'loading_movie'): self.loading_movie.frameChanged.disconnect() + if hasattr(self, "loading_movie"): + self.loading_movie.frameChanged.disconnect() except Exception: pass # Ignore error if not connected self.btn_preview.setIcon(self.play_icon) @@ -2301,10 +2427,12 @@ class abogen(QWidget): ): if not hasattr(self, "_conversion_lock"): self._conversion_lock = threading.Lock() + def _cancel(): with self._conversion_lock: self.conversion_thread.cancel() # <-- Use cancel() method self.conversion_thread.wait() + threading.Thread(target=_cancel, daemon=True).start() self.is_converting = False @@ -2323,7 +2451,7 @@ class abogen(QWidget): else self.selected_file ) # Only repopulate if not cleared by queue - if not getattr(self, 'input_box_cleared_by_queue', False): + if not getattr(self, "input_box_cleared_by_queue", False): if display_path and os.path.exists(display_path): self.input_box.set_file_info(display_path) else: @@ -2409,9 +2537,10 @@ class abogen(QWidget): def is_windows_dark_mode(): try: import winreg + with winreg.OpenKey( winreg.HKEY_CURRENT_USER, - r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize" + r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize", ) as key: value, _ = winreg.QueryValueEx(key, "AppsUseLightTheme") return value == 0 @@ -2436,13 +2565,22 @@ class abogen(QWidget): palette.setColor(QPalette.ColorRole.Button, button_bg) palette.setColor(QPalette.ColorRole.ButtonText, Qt.GlobalColor.white) # Disabled roles - palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.WindowText, disabled_fg) - palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Text, disabled_fg) - palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.ButtonText, disabled_fg) - palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Base, dark_bg) - palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Button, dark_bg) + palette.setColor( + QPalette.ColorGroup.Disabled, QPalette.ColorRole.WindowText, disabled_fg + ) + palette.setColor( + QPalette.ColorGroup.Disabled, QPalette.ColorRole.Text, disabled_fg + ) + palette.setColor( + QPalette.ColorGroup.Disabled, QPalette.ColorRole.ButtonText, disabled_fg + ) + palette.setColor( + QPalette.ColorGroup.Disabled, QPalette.ColorRole.Base, dark_bg + ) + palette.setColor( + QPalette.ColorGroup.Disabled, QPalette.ColorRole.Button, dark_bg + ) app.setPalette(palette) - def set_light_palette(): palette = QPalette() @@ -2457,11 +2595,25 @@ class abogen(QWidget): palette.setColor(QPalette.ColorRole.Button, Qt.GlobalColor.white) palette.setColor(QPalette.ColorRole.ButtonText, Qt.GlobalColor.black) # Disabled roles - palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.WindowText, disabled_fg) - palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Text, disabled_fg) - palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.ButtonText, disabled_fg) - palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Base, Qt.GlobalColor.white) - palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Button, Qt.GlobalColor.white) + palette.setColor( + QPalette.ColorGroup.Disabled, QPalette.ColorRole.WindowText, disabled_fg + ) + palette.setColor( + QPalette.ColorGroup.Disabled, QPalette.ColorRole.Text, disabled_fg + ) + palette.setColor( + QPalette.ColorGroup.Disabled, QPalette.ColorRole.ButtonText, disabled_fg + ) + palette.setColor( + QPalette.ColorGroup.Disabled, + QPalette.ColorRole.Base, + Qt.GlobalColor.white, + ) + palette.setColor( + QPalette.ColorGroup.Disabled, + QPalette.ColorRole.Button, + Qt.GlobalColor.white, + ) app.setPalette(palette) # --- Dark title bar support for Windows --- @@ -2473,12 +2625,19 @@ class abogen(QWidget): set_window_attribute = ctypes.windll.dwmapi.DwmSetWindowAttribute hwnd = int(window.winId()) value = ctypes.c_int(2 if enable else 0) - set_window_attribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, ctypes.byref(value), ctypes.sizeof(value)) + set_window_attribute( + hwnd, + DWMWA_USE_IMMERSIVE_DARK_MODE, + ctypes.byref(value), + ctypes.sizeof(value), + ) except Exception: pass # Main logic - dark_mode = theme == "dark" or (theme == "system" and is_windows and is_windows_dark_mode()) + dark_mode = theme == "dark" or ( + theme == "system" and is_windows and is_windows_dark_mode() + ) if dark_mode: app.setStyle("Fusion") set_dark_palette() @@ -2512,7 +2671,10 @@ class abogen(QWidget): delattr(app, "_dark_titlebar_event_filter") def get_dark_mode(): - return theme == "dark" or (theme == "system" and is_windows and is_windows_dark_mode()) + return theme == "dark" or ( + theme == "system" and is_windows and is_windows_dark_mode() + ) + app._dark_titlebar_event_filter = DarkTitleBarEventFilter( is_windows, get_dark_mode, set_title_bar_dark_mode ) @@ -2554,26 +2716,32 @@ class abogen(QWidget): # Add separate chapters format option separate_chapters_format_menu = QMenu("Separate chapters audio format", self) - separate_chapters_format_menu.setToolTip("Choose the format for individual chapter files") - + separate_chapters_format_menu.setToolTip( + "Choose the format for individual chapter files" + ) + format_group = QActionGroup(self) format_group.setExclusive(True) - + for format_option in ["wav", "flac", "mp3", "opus"]: format_action = QAction(format_option, self) format_action.setCheckable(True) format_action.setChecked(self.separate_chapters_format == format_option) - format_action.triggered.connect(lambda checked, fmt=format_option: self.set_separate_chapters_format(fmt)) + format_action.triggered.connect( + lambda checked, fmt=format_option: self.set_separate_chapters_format( + fmt + ) + ) format_group.addAction(format_action) separate_chapters_format_menu.addAction(format_action) - + menu.addMenu(separate_chapters_format_menu) # Add max words per subtitle option max_words_action = QAction("Configure max words per subtitle", self) max_words_action.triggered.connect(self.set_max_subtitle_words) menu.addAction(max_words_action) - + max_lines_action = QAction("Configure max lines in log window", self) max_lines_action.triggered.connect(self.set_max_log_lines) menu.addAction(max_lines_action) @@ -2584,7 +2752,11 @@ class abogen(QWidget): # Add shortcut to desktop (Windows or Linux) if platform.system() == "Windows" or platform.system() == "Linux": # Use extended label on Linux - label = "Create desktop shortcut and install" if platform.system() == "Linux" else "Create desktop shortcut" + label = ( + "Create desktop shortcut and install" + if platform.system() == "Linux" + else "Create desktop shortcut" + ) add_shortcut_action = QAction(label, self) add_shortcut_action.triggered.connect(self.add_shortcut_to_desktop) menu.addAction(add_shortcut_action) @@ -2673,25 +2845,33 @@ class abogen(QWidget): target = os.path.join(python_dir, "Scripts", "abogen.exe") if not os.path.exists(target): QMessageBox.critical( - self, "Shortcut Error", f"Could not find abogen.exe at:\n{target}" + self, + "Shortcut Error", + f"Could not find abogen.exe at:\n{target}", ) return # icon (fallback to exe if missing) icon = get_resource_path("abogen.assets", "icon.ico") if not icon or not os.path.exists(icon): - icon = target # Create a more direct PowerShell command + icon = target # Create a more direct PowerShell command shortcut_ps = shortcut_path.replace("'", "''").replace("\\", "\\\\") target_ps = target.replace("'", "''").replace("\\", "\\\\") - workdir_ps = os.path.dirname(target).replace("'", "''").replace("\\", "\\\\") + workdir_ps = ( + os.path.dirname(target).replace("'", "''").replace("\\", "\\\\") + ) icon_ps = icon.replace("'", "''").replace("\\", "\\\\") - # Create PowerShell script as a single line with no line breaks (more reliable) + # Create PowerShell script as a single line with no line breaks (more reliable) ps_cmd = f"$s=New-Object -ComObject WScript.Shell; $lnk=$s.CreateShortcut('{shortcut_ps}'); $lnk.TargetPath='{target_ps}'; $lnk.WorkingDirectory='{workdir_ps}'; $lnk.IconLocation='{icon_ps}'; $lnk.Save()" - + # Run PowerShell with the command directly - proc = create_process("powershell -NoProfile -ExecutionPolicy Bypass -Command \"" + ps_cmd + "\"") + proc = create_process( + 'powershell -NoProfile -ExecutionPolicy Bypass -Command "' + + ps_cmd + + '"' + ) proc.wait() - + if proc.returncode == 0: QMessageBox.information( self, @@ -2702,17 +2882,20 @@ class abogen(QWidget): QMessageBox.critical( self, "Shortcut Error", - f"PowerShell failed with exit code: {proc.returncode}" + f"PowerShell failed with exit code: {proc.returncode}", ) elif platform.system() == "Linux": desktop = user_desktop_dir() if not desktop or not os.path.isdir(desktop): - QMessageBox.critical(self, "Shortcut Error", "Could not determine desktop directory.") + QMessageBox.critical( + self, "Shortcut Error", "Could not determine desktop directory." + ) return shortcut_path = os.path.join(desktop, "abogen.desktop") import shutil + found = shutil.which("abogen") if found: target = found @@ -2728,7 +2911,11 @@ class abogen(QWidget): if os.path.exists(target_fallback): target = target_fallback else: - QMessageBox.critical(self, "Shortcut Error", "Could not find abogen executable in PATH or common installation directories.") + QMessageBox.critical( + self, + "Shortcut Error", + "Could not find abogen executable in PATH or common installation directories.", + ) return icon_path = get_resource_path("abogen.assets", "icon.png") @@ -2763,6 +2950,7 @@ Categories=AudioVideo;Audio;Utility; ) if reply == QMessageBox.Yes: import shutil + user_app_dir = os.path.expanduser("~/.local/share/applications") os.makedirs(user_app_dir, exist_ok=True) user_entry = os.path.join(user_app_dir, "abogen.desktop") @@ -2782,7 +2970,9 @@ Categories=AudioVideo;Audio;Utility; ) else: QMessageBox.information( - self, "Unsupported OS", "Desktop shortcut creation is not supported on this operating system." + self, + "Unsupported OS", + "Desktop shortcut creation is not supported on this operating system.", ) except Exception as e: @@ -2996,9 +3186,9 @@ Categories=AudioVideo;Audio;Utility; if os.path.exists(preview_cache_dir): preview_pattern = os.path.join(preview_cache_dir, "*.wav") preview_files = glob.glob(preview_pattern) - + preview_count = len(preview_files) - + if file_count == 0 and preview_count == 0: QMessageBox.information( self, "No Temporary Files", "No temporary files were found." @@ -3009,26 +3199,26 @@ Categories=AudioVideo;Audio;Utility; msg_box = QMessageBox(self) msg_box.setIcon(QMessageBox.Question) msg_box.setWindowTitle("Clear Temporary Files") - + msg_text = f"Found {file_count} temporary file{'s' if file_count != 1 else ''} in the {PROGRAM_NAME} temp folder." if preview_count > 0: msg_text += f"\nAlso found {preview_count} preview cache file{'s' if preview_count != 1 else ''}." - + msg_box.setText(msg_text + "\nDo you want to delete them?") - + # Add checkbox for preview cache preview_cache_checkbox = QCheckBox("Clean preview cache", msg_box) preview_cache_checkbox.setChecked(False) # Only enable checkbox if preview files exist preview_cache_checkbox.setEnabled(preview_count > 0) - + # Add the checkbox to the layout msg_box.setCheckBox(preview_cache_checkbox) - + # Add buttons msg_box.setStandardButtons(QMessageBox.Yes | QMessageBox.No) msg_box.setDefaultButton(QMessageBox.Yes) - + if msg_box.exec_() != QMessageBox.Yes: return @@ -3040,7 +3230,7 @@ Categories=AudioVideo;Audio;Utility; deleted_count += 1 except Exception as e: print(f"Error deleting {file_path}: {e}") - + # Delete preview cache files if checkbox is checked deleted_preview_count = 0 if preview_cache_checkbox.isChecked() and preview_count > 0: @@ -3055,11 +3245,9 @@ Categories=AudioVideo;Audio;Utility; result_msg = f"Successfully deleted {deleted_count} temporary file{'s' if deleted_count != 1 else ''}." if preview_cache_checkbox.isChecked() and deleted_preview_count > 0: result_msg += f"\nAlso deleted {deleted_preview_count} preview cache file{'s' if deleted_preview_count != 1 else ''}." - + # Show results - QMessageBox.information( - self, "Temporary Files Cleared", result_msg - ) + QMessageBox.information(self, "Temporary Files Cleared", result_msg) # If currently selected file is in the temp directory, clear the UI if ( @@ -3139,4 +3327,3 @@ Categories=AudioVideo;Audio;Utility; def show_model_download_warning(self, title, message): QMessageBox.information(self, title, message) - diff --git a/abogen/hf_tracker.py b/abogen/hf_tracker.py index a0f760e..84c6350 100644 --- a/abogen/hf_tracker.py +++ b/abogen/hf_tracker.py @@ -1,16 +1,20 @@ log_callback = None show_warning_signal_emitter = None # Renamed for clarity + def set_log_callback(cb): global log_callback log_callback = cb + def set_show_warning_signal_emitter(emitter): # Renamed for clarity global show_warning_signal_emitter show_warning_signal_emitter = emitter + from huggingface_hub import hf_hub_download + def tracked_hf_hub_download(*args, **kwargs): try: local_kwargs = dict(kwargs) @@ -22,7 +26,10 @@ def tracked_hf_hub_download(*args, **kwargs): if filename.endswith(".pth"): msg = f"\nDownloading model '{filename}' from Hugging Face ({repo_id}). This may take a while. Please wait..." if show_warning_signal_emitter: # Check if the emitter is set - show_warning_signal_emitter.emit("Downloading Model", f"Downloading model '{filename}' from Hugging Face repository '{repo_id}'. This may take a while, please wait.") + show_warning_signal_emitter.emit( + "Downloading Model", + f"Downloading model '{filename}' from Hugging Face repository '{repo_id}'. This may take a while, please wait.", + ) else: msg = f"\nDownloading '{filename}' from Hugging Face ({repo_id}). Please wait..." if log_callback: @@ -32,5 +39,7 @@ def tracked_hf_hub_download(*args, **kwargs): print(msg, flush=True) return hf_hub_download(*args, **kwargs) + import huggingface_hub -huggingface_hub.hf_hub_download = tracked_hf_hub_download \ No newline at end of file + +huggingface_hub.hf_hub_download = tracked_hf_hub_download diff --git a/abogen/is_nvidia.py b/abogen/is_nvidia.py index 78bd8cf..2f81351 100644 --- a/abogen/is_nvidia.py +++ b/abogen/is_nvidia.py @@ -1,20 +1,22 @@ import gpustat + def check(): try: stats = gpustat.new_query() except Exception: return False - nvidia_keywords = ['nvidia', 'rtx', 'gtx', 'quadro', 'tesla', 'titan', 'mx'] + nvidia_keywords = ["nvidia", "rtx", "gtx", "quadro", "tesla", "titan", "mx"] for gpu in stats.gpus: name = gpu.name.lower() if any(keyword in name for keyword in nvidia_keywords): return True return False + if __name__ == "__main__": stats = gpustat.new_query() for gpu in stats.gpus: print(gpu.name) - print(check()) \ No newline at end of file + print(check()) diff --git a/abogen/main.py b/abogen/main.py index d1d4a41..6933249 100644 --- a/abogen/main.py +++ b/abogen/main.py @@ -25,6 +25,7 @@ if sys.stderr is None: if platform.system() == "Darwin" and platform.processor() == "arm": os.environ["PYTORCH_ENABLE_MPS_FALLBACK"] = "1" + # Custom message handler to filter out specific Qt warnings def qt_message_handler(mode, context, message): if "Wayland does not support QWindow::requestActivate()" in message: diff --git a/abogen/queue_manager_gui.py b/abogen/queue_manager_gui.py index 8e9ab1d..d310299 100644 --- a/abogen/queue_manager_gui.py +++ b/abogen/queue_manager_gui.py @@ -20,6 +20,7 @@ from abogen.constants import COLORS from copy import deepcopy from PyQt5.QtGui import QFontMetrics + class ElidedLabel(QLabel): def __init__(self, text, parent=None): super().__init__(text, parent) @@ -40,6 +41,7 @@ class ElidedLabel(QLabel): def fullText(self): return self._full_text + class QueueListItemWidget(QWidget): def __init__(self, file_name, char_count): super().__init__() @@ -47,6 +49,7 @@ class QueueListItemWidget(QWidget): layout.setContentsMargins(12, 0, 6, 0) layout.setSpacing(0) import os + name_label = ElidedLabel(os.path.basename(file_name)) char_label = QLabel(f"Chars: {char_count}") char_label.setStyleSheet(f"color: {COLORS['LIGHT_DISABLED']};") @@ -56,6 +59,7 @@ class QueueListItemWidget(QWidget): layout.addWidget(char_label, 0) self.setLayout(layout) + class DroppableQueueListWidget(QListWidget): def __init__(self, parent_dialog): super().__init__() @@ -73,7 +77,7 @@ class DroppableQueueListWidget(QListWidget): def dragEnterEvent(self, event): if event.mimeData().hasUrls(): for url in event.mimeData().urls(): - if url.isLocalFile() and url.toLocalFile().lower().endswith('.txt'): + if url.isLocalFile() and url.toLocalFile().lower().endswith(".txt"): self.drag_overlay.resize(self.size()) self.drag_overlay.setVisible(True) event.acceptProposedAction() @@ -84,7 +88,7 @@ class DroppableQueueListWidget(QListWidget): def dragMoveEvent(self, event): if event.mimeData().hasUrls(): for url in event.mimeData().urls(): - if url.isLocalFile() and url.toLocalFile().lower().endswith('.txt'): + if url.isLocalFile() and url.toLocalFile().lower().endswith(".txt"): event.acceptProposedAction() return event.ignore() @@ -96,7 +100,11 @@ class DroppableQueueListWidget(QListWidget): def dropEvent(self, event): self.drag_overlay.setVisible(False) if event.mimeData().hasUrls(): - file_paths = [url.toLocalFile() for url in event.mimeData().urls() if url.isLocalFile() and url.toLocalFile().lower().endswith('.txt')] + file_paths = [ + url.toLocalFile() + for url in event.mimeData().urls() + if url.isLocalFile() and url.toLocalFile().lower().endswith(".txt") + ] if file_paths: self.parent_dialog.add_files_from_paths(file_paths) event.acceptProposedAction() @@ -107,14 +115,17 @@ class DroppableQueueListWidget(QListWidget): def resizeEvent(self, event): super().resizeEvent(event) - if hasattr(self, 'drag_overlay'): + if hasattr(self, "drag_overlay"): self.drag_overlay.resize(self.size()) + class QueueManager(QDialog): def __init__(self, parent, queue: list, title="Queue Manager", size=(600, 700)): super().__init__() self.queue = queue - self._original_queue = deepcopy(queue) # Store a deep copy of the original queue + self._original_queue = deepcopy( + queue + ) # Store a deep copy of the original queue self.parent = parent layout = QVBoxLayout() layout.setContentsMargins(15, 15, 15, 15) # set main layout margins @@ -141,10 +152,12 @@ class QueueManager(QDialog): # Overlay label for empty queue self.empty_overlay = QLabel( "Drag and drop your text files here or use the 'Add files' button.", - self.listwidget + self.listwidget, ) self.empty_overlay.setAlignment(Qt.AlignCenter) - self.empty_overlay.setStyleSheet(f"color: {COLORS['LIGHT_DISABLED']}; background: transparent; padding: 20px;") + self.empty_overlay.setStyleSheet( + f"color: {COLORS['LIGHT_DISABLED']}; background: transparent; padding: 20px;" + ) self.empty_overlay.setWordWrap(True) self.empty_overlay.setAttribute(Qt.WA_TransparentForMouseEvents, True) self.empty_overlay.hide() @@ -208,13 +221,14 @@ class QueueManager(QDialog): file_name = item.file_name display_name = file_name import os + if os.path.sep in file_name: display_name = os.path.basename(file_name) # Get icon for the file icon = icon_provider.icon(QFileInfo(file_name)) list_item = QListWidgetItem() # Set tooltip with detailed info - output_folder = getattr(item, 'output_folder', '') + output_folder = getattr(item, "output_folder", "") tooltip = ( f"Path: {file_name}
" f"Language: {getattr(item, 'lang_code', '')}
" @@ -222,7 +236,7 @@ class QueueManager(QDialog): f"Voice: {getattr(item, 'voice', '')}
" f"Save Option: {getattr(item, 'save_option', '')}
" ) - if output_folder not in (None, '', 'None'): + if output_folder not in (None, "", "None"): tooltip += f"Output Folder: {output_folder}
" tooltip += ( f"Subtitle Mode: {getattr(item, 'subtitle_mode', '')}
" @@ -233,7 +247,7 @@ class QueueManager(QDialog): list_item.setToolTip(tooltip) list_item.setIcon(icon) # Use custom widget for display - char_count = getattr(item, 'total_char_count', 0) + char_count = getattr(item, "total_char_count", 0) widget = QueueListItemWidget(file_name, char_count) self.listwidget.addItem(list_item) self.listwidget.setItemWidget(list_item, widget) @@ -244,6 +258,7 @@ class QueueManager(QDialog): if not items: return from PyQt5.QtWidgets import QMessageBox + # Remove by index to ensure correct mapping rows = sorted([self.listwidget.row(item) for item in items], reverse=True) # Warn user if removing multiple files @@ -253,7 +268,7 @@ class QueueManager(QDialog): "Confirm Remove", f"Are you sure you want to remove {len(rows)} selected items from the queue?", QMessageBox.Yes | QMessageBox.No, - QMessageBox.No + QMessageBox.No, ) if reply != QMessageBox.Yes: return @@ -265,19 +280,22 @@ class QueueManager(QDialog): def clear_queue(self): from PyQt5.QtWidgets import QMessageBox + if len(self.queue) > 1: reply = QMessageBox.question( self, "Confirm Clear Queue", f"Are you sure you want to clear {len(self.queue)} items from the queue?", QMessageBox.Yes | QMessageBox.No, - QMessageBox.No + QMessageBox.No, ) if reply != QMessageBox.Yes: return self.queue.clear() self.listwidget.clear() - self.empty_overlay.resize(self.listwidget.size()) # Ensure overlay is sized correctly + self.empty_overlay.resize( + self.listwidget.size() + ) # Ensure overlay is sized correctly self.empty_overlay.show() # Show the overlay when queue is empty self.update_button_states() @@ -290,56 +308,74 @@ class QueueManager(QDialog): parent = self.parent if parent is not None: # lang_code: use parent's get_voice_formula and get_selected_lang - if hasattr(parent, 'get_voice_formula') and hasattr(parent, 'get_selected_lang'): + if hasattr(parent, "get_voice_formula") and hasattr( + parent, "get_selected_lang" + ): voice_formula = parent.get_voice_formula() - attrs['lang_code'] = parent.get_selected_lang(voice_formula) - attrs['voice'] = voice_formula + attrs["lang_code"] = parent.get_selected_lang(voice_formula) + attrs["voice"] = voice_formula else: - attrs['lang_code'] = getattr(parent, 'selected_lang', '') - attrs['voice'] = getattr(parent, 'selected_voice', '') + attrs["lang_code"] = getattr(parent, "selected_lang", "") + attrs["voice"] = getattr(parent, "selected_voice", "") # speed - if hasattr(parent, 'speed_slider'): - attrs['speed'] = parent.speed_slider.value() / 100.0 + if hasattr(parent, "speed_slider"): + attrs["speed"] = parent.speed_slider.value() / 100.0 else: - attrs['speed'] = getattr(parent, 'speed', 1.0) + attrs["speed"] = getattr(parent, "speed", 1.0) # save_option - attrs['save_option'] = getattr(parent, 'save_option', '') + attrs["save_option"] = getattr(parent, "save_option", "") # output_folder - attrs['output_folder'] = getattr(parent, 'selected_output_folder', '') + attrs["output_folder"] = getattr(parent, "selected_output_folder", "") # subtitle_mode - if hasattr(parent, 'get_actual_subtitle_mode'): - attrs['subtitle_mode'] = parent.get_actual_subtitle_mode() + if hasattr(parent, "get_actual_subtitle_mode"): + attrs["subtitle_mode"] = parent.get_actual_subtitle_mode() else: - attrs['subtitle_mode'] = getattr(parent, 'subtitle_mode', '') + attrs["subtitle_mode"] = getattr(parent, "subtitle_mode", "") # output_format - attrs['output_format'] = getattr(parent, 'selected_format', '') + attrs["output_format"] = getattr(parent, "selected_format", "") # total_char_count - attrs['total_char_count'] = getattr(parent, 'char_count', '') + attrs["total_char_count"] = getattr(parent, "char_count", "") # replace_single_newlines - attrs['replace_single_newlines'] = getattr(parent, 'replace_single_newlines', False) + attrs["replace_single_newlines"] = getattr( + parent, "replace_single_newlines", False + ) else: # fallback: empty values - attrs = {k: '' for k in [ - 'lang_code', 'speed', 'voice', 'save_option', - 'output_folder', 'subtitle_mode', 'output_format', 'total_char_count', 'replace_single_newlines']} + attrs = { + k: "" + for k in [ + "lang_code", + "speed", + "voice", + "save_option", + "output_folder", + "subtitle_mode", + "output_format", + "total_char_count", + "replace_single_newlines", + ] + } return attrs def add_files_from_paths(self, file_paths): from abogen.utils import calculate_text_length from PyQt5.QtWidgets import QMessageBox import os + current_attrs = self.get_current_attributes() duplicates = [] for file_path in file_paths: + class QueueItem: pass + item = QueueItem() item.file_name = file_path for attr, value in current_attrs.items(): setattr(item, attr, value) # Read file content and calculate total_char_count using calculate_text_length try: - with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + with open(file_path, "r", encoding="utf-8", errors="ignore") as f: file_content = f.read() item.total_char_count = calculate_text_length(file_content) except Exception: @@ -348,16 +384,26 @@ class QueueManager(QDialog): is_duplicate = False for queued_item in self.queue: if ( - getattr(queued_item, 'file_name', None) == getattr(item, 'file_name', None) and - getattr(queued_item, 'lang_code', None) == getattr(item, 'lang_code', None) and - getattr(queued_item, 'speed', None) == getattr(item, 'speed', None) and - getattr(queued_item, 'voice', None) == getattr(item, 'voice', None) and - getattr(queued_item, 'save_option', None) == getattr(item, 'save_option', None) and - getattr(queued_item, 'output_folder', None) == getattr(item, 'output_folder', None) and - getattr(queued_item, 'subtitle_mode', None) == getattr(item, 'subtitle_mode', None) and - getattr(queued_item, 'output_format', None) == getattr(item, 'output_format', None) and - getattr(queued_item, 'total_char_count', None) == getattr(item, 'total_char_count', None) and - getattr(queued_item, 'replace_single_newlines', False) == getattr(item, 'replace_single_newlines', False) + getattr(queued_item, "file_name", None) + == getattr(item, "file_name", None) + and getattr(queued_item, "lang_code", None) + == getattr(item, "lang_code", None) + and getattr(queued_item, "speed", None) + == getattr(item, "speed", None) + and getattr(queued_item, "voice", None) + == getattr(item, "voice", None) + and getattr(queued_item, "save_option", None) + == getattr(item, "save_option", None) + and getattr(queued_item, "output_folder", None) + == getattr(item, "output_folder", None) + and getattr(queued_item, "subtitle_mode", None) + == getattr(item, "subtitle_mode", None) + and getattr(queued_item, "output_format", None) + == getattr(item, "output_format", None) + and getattr(queued_item, "total_char_count", None) + == getattr(item, "total_char_count", None) + and getattr(queued_item, "replace_single_newlines", False) + == getattr(item, "replace_single_newlines", False) ): is_duplicate = True break @@ -369,7 +415,7 @@ class QueueManager(QDialog): QMessageBox.warning( self, "Duplicate Item(s)", - f"Skipping {len(duplicates)} file(s) with the same attributes, already in the queue." + f"Skipping {len(duplicates)} file(s) with the same attributes, already in the queue.", ) self.process_queue() self.update_button_states() @@ -377,20 +423,23 @@ class QueueManager(QDialog): def add_more_files(self): from PyQt5.QtWidgets import QFileDialog from abogen.utils import calculate_text_length # import the function + # Only allow .txt files - files, _ = QFileDialog.getOpenFileNames(self, "Select .txt files", "", "Text Files (*.txt)") + files, _ = QFileDialog.getOpenFileNames( + self, "Select .txt files", "", "Text Files (*.txt)" + ) if not files: return self.add_files_from_paths(files) def resizeEvent(self, event): super().resizeEvent(event) - if hasattr(self, 'empty_overlay'): + if hasattr(self, "empty_overlay"): self.empty_overlay.resize(self.listwidget.size()) def update_button_states(self): # Enable Remove if at least one item is selected, else disable - if hasattr(self, 'remove_button'): + if hasattr(self, "remove_button"): selected_count = len(self.listwidget.selectedItems()) self.remove_button.setEnabled(selected_count > 0) if selected_count > 1: @@ -398,7 +447,7 @@ class QueueManager(QDialog): else: self.remove_button.setText("Remove selected") # Disable Clear if queue is empty - if hasattr(self, 'clear_button'): + if hasattr(self, "clear_button"): self.clear_button.setEnabled(bool(self.queue)) def show_context_menu(self, pos): @@ -406,6 +455,7 @@ class QueueManager(QDialog): from PyQt5.QtGui import QDesktopServices from PyQt5.QtCore import QUrl import os + global_pos = self.listwidget.viewport().mapToGlobal(pos) selected_items = self.listwidget.selectedItems() menu = QMenu(self) @@ -417,35 +467,45 @@ class QueueManager(QDialog): # Add Open file action open_file_action = QAction("Open file", self) + def open_file(): from PyQt5.QtWidgets import QMessageBox + item = selected_items[0] display_name = item.text() for q in self.queue: if os.path.basename(q.file_name) == display_name: if not os.path.exists(q.file_name): - QMessageBox.warning(self, "File Not Found", f"The file does not exist.") + QMessageBox.warning( + self, "File Not Found", f"The file does not exist." + ) return QDesktopServices.openUrl(QUrl.fromLocalFile(q.file_name)) break + open_file_action.triggered.connect(open_file) menu.addAction(open_file_action) # Add Go to folder action go_to_folder_action = QAction("Go to folder", self) + def go_to_folder(): from PyQt5.QtWidgets import QMessageBox + item = selected_items[0] display_name = item.text() for q in self.queue: if os.path.basename(q.file_name) == display_name: if not os.path.exists(q.file_name): - QMessageBox.warning(self, "File Not Found", f"The file does not exist.") + QMessageBox.warning( + self, "File Not Found", f"The file does not exist." + ) return folder = os.path.dirname(q.file_name) if os.path.exists(folder): QDesktopServices.openUrl(QUrl.fromLocalFile(folder)) break + go_to_folder_action.triggered.connect(go_to_folder) menu.addAction(go_to_folder_action) @@ -466,6 +526,7 @@ class QueueManager(QDialog): def reject(self): # Cancel: restore original queue from PyQt5.QtWidgets import QMessageBox + # Warn if user changed a lot (e.g., more than 1 items difference) original_count = len(self._original_queue) current_count = len(self.queue) @@ -475,7 +536,7 @@ class QueueManager(QDialog): "Confirm Cancel", f"Are you sure you want to cancel and discard all changes?", QMessageBox.Yes | QMessageBox.No, - QMessageBox.No + QMessageBox.No, ) if reply != QMessageBox.Yes: return @@ -485,7 +546,8 @@ class QueueManager(QDialog): def keyPressEvent(self, event): from PyQt5.QtCore import Qt + if event.key() == Qt.Key_Delete: self.remove_item() else: - super().keyPressEvent(event) \ No newline at end of file + super().keyPressEvent(event) diff --git a/abogen/queued_item.py b/abogen/queued_item.py index 9e676a2..9c9fd00 100644 --- a/abogen/queued_item.py +++ b/abogen/queued_item.py @@ -1,6 +1,7 @@ # represents a queued item - book, chapters, voice, etc. from dataclasses import dataclass + @dataclass class QueuedItem: file_name: str @@ -12,4 +13,4 @@ class QueuedItem: subtitle_mode: str output_format: str total_char_count: int - replace_single_newlines: bool = False \ No newline at end of file + replace_single_newlines: bool = False diff --git a/abogen/utils.py b/abogen/utils.py index fd24176..8c32c33 100644 --- a/abogen/utils.py +++ b/abogen/utils.py @@ -105,19 +105,21 @@ def clean_text(text, *args, **kwargs): default_encoding = sys.getfilesystemencoding() + def create_process(cmd, stdin=None, text=True, capture_output=False): import logging + logger = logging.getLogger(__name__) - + # Configure root logger to output to console if not already configured root = logging.getLogger() if not root.handlers: handler = logging.StreamHandler(sys.stdout) - formatter = logging.Formatter('%(message)s') + formatter = logging.Formatter("%(message)s") handler.setFormatter(formatter) root.addHandler(handler) root.setLevel(logging.INFO) - + # Determine shell usage: use shell only for string commands use_shell = isinstance(cmd, str) kwargs = { @@ -151,11 +153,12 @@ def create_process(cmd, stdin=None, text=True, capture_output=False): # Print the command being executed print(f"Executing: {cmd if isinstance(cmd, str) else ' '.join(cmd)}") - + proc = subprocess.Popen(cmd, **kwargs) - + # Stream output to console in real-time if not capturing if proc.stdout and not capture_output: + def _stream_output(stream): if text: # For text mode, read character by character for real-time output @@ -174,12 +177,14 @@ def create_process(cmd, stdin=None, text=True, capture_output=False): break try: # Try to decode binary data for display - sys.stdout.write(chunk.decode(default_encoding, errors='replace')) + sys.stdout.write( + chunk.decode(default_encoding, errors="replace") + ) sys.stdout.flush() except Exception: pass stream.close() - + # Start a daemon thread to handle output streaming Thread(target=_stream_output, args=(proc.stdout,), daemon=True).start() @@ -220,6 +225,7 @@ def get_gpu_acceleration(enabled): try: import torch from torch.cuda import is_available + if not enabled: return "CUDA GPU available but using CPU.", False if is_available(): @@ -227,7 +233,11 @@ def get_gpu_acceleration(enabled): # Gather more diagnostic info if CUDA is not available try: cuda_devices = torch.cuda.device_count() - cuda_error = torch.cuda.get_device_name(0) if cuda_devices > 0 else "No devices found" + cuda_error = ( + torch.cuda.get_device_name(0) + if cuda_devices > 0 + else "No devices found" + ) except Exception as e: cuda_error = str(e) return f"CUDA GPU is not available. Using CPU. ({cuda_error})", False @@ -239,6 +249,7 @@ def prevent_sleep_start(): system = platform.system() if system == "Windows": import ctypes + ctypes.windll.kernel32.SetThreadExecutionState( 0x80000000 | 0x00000001 | 0x00000040 ) @@ -246,19 +257,22 @@ def prevent_sleep_start(): _sleep_procs["Darwin"] = create_process(["caffeinate"]) elif system == "Linux": # use a sleep that never exits so inhibition stays active - _sleep_procs["Linux"] = create_process([ - "systemd-inhibit", - "--what=handle-lid-switch:sleep", - "--mode=block", - "sleep", - "infinity", - ]) + _sleep_procs["Linux"] = create_process( + [ + "systemd-inhibit", + "--what=handle-lid-switch:sleep", + "--mode=block", + "sleep", + "infinity", + ] + ) def prevent_sleep_end(): system = platform.system() if system == "Windows": import ctypes + ctypes.windll.kernel32.SetThreadExecutionState(0x80000000) # ES_CONTINUOUS elif system in ("Darwin", "Linux") and _sleep_procs[system]: try: diff --git a/abogen/voice_formula_gui.py b/abogen/voice_formula_gui.py index a6e09aa..ae361a3 100644 --- a/abogen/voice_formula_gui.py +++ b/abogen/voice_formula_gui.py @@ -230,13 +230,15 @@ class VoiceMixer(QWidget): appstyle = QApplication.instance().style().objectName().lower() if appstyle != "windowsvista": # Set custom groove color for disabled state using COLORS["GREY_BACKGROUND"] - self.slider.setStyleSheet(f""" + self.slider.setStyleSheet( + f""" QSlider::groove:vertical:disabled {{ background: {COLORS.get("GREY_BACKGROUND")}; width: 4px; border-radius: 4px; }} - """) + """ + ) # Connect controls self.slider.valueChanged.connect(lambda val: self.spin_box.setValue(val / 100)) @@ -330,7 +332,9 @@ class VoiceFormulaDialog(QDialog): self._original_mixed_voice_state = None if parent is not None: self._original_profile_name = getattr(parent, "selected_profile_name", None) - self._original_mixed_voice_state = getattr(parent, "mixed_voice_state", None) + self._original_mixed_voice_state = getattr( + parent, "mixed_voice_state", None + ) profiles = load_profiles() self._virtual_new_profile = False if not profiles: @@ -369,7 +373,9 @@ class VoiceFormulaDialog(QDialog): self.profile_list = QListWidget() self.profile_list.setSelectionMode(QListWidget.SingleSelection) self.profile_list.setSelectionBehavior(QListWidget.SelectRows) - self.profile_list.setStyleSheet("QListWidget::item:selected { background: palette(highlight); color: palette(highlighted-text); }") + self.profile_list.setStyleSheet( + "QListWidget::item:selected { background: palette(highlight); color: palette(highlighted-text); }" + ) icon = QIcon(get_resource_path("abogen.assets", "profile.png")) if self._virtual_new_profile: item = QListWidgetItem(icon, "New profile") @@ -976,6 +982,7 @@ class VoiceFormulaDialog(QDialog): def _parse_rgba_to_qcolor(self, rgba_str): from PyQt5.QtCore import Qt from PyQt5.QtGui import QColor + """Helper to convert 'rgba(R,G,B,A_float)' string to QColor.""" match = re.match(r"rgba\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)", rgba_str) if match: @@ -1372,6 +1379,7 @@ class VoiceFormulaDialog(QDialog): def update_profile_list_colors(self): from PyQt5.QtCore import Qt + profiles = load_profiles() for i in range(self.profile_list.count()): item = self.profile_list.item(i) @@ -1383,7 +1391,9 @@ class VoiceFormulaDialog(QDialog): color = self._parse_rgba_to_qcolor(COLORS.get("YELLOW_BACKGROUND")) item.setData(Qt.BackgroundRole, color) else: - item.setData(Qt.BackgroundRole, self.profile_list.palette().base().color()) + item.setData( + Qt.BackgroundRole, self.profile_list.palette().base().color() + ) weights = profiles.get(name, {}).get("voices", []) total = 0 if isinstance(weights, list): diff --git a/abogen/voice_formulas.py b/abogen/voice_formulas.py index af18c52..813d27c 100644 --- a/abogen/voice_formulas.py +++ b/abogen/voice_formulas.py @@ -1,6 +1,7 @@ import re from abogen.constants import VOICES_INTERNAL + # Calls parsing and loads the voice to gpu or cpu def get_new_voice(pipeline, formula, use_gpu): try: