diff --git a/abogen/pyqt/conversion.py b/abogen/pyqt/conversion.py index 2195b8a..8aa301e 100644 --- a/abogen/pyqt/conversion.py +++ b/abogen/pyqt/conversion.py @@ -42,6 +42,10 @@ from abogen.subtitle_utils import ( get_sample_voice_text, sanitize_name_for_os, _CHAPTER_MARKER_SEARCH_PATTERN, + _VOICE_MARKER_PATTERN, + _VOICE_MARKER_SEARCH_PATTERN, + split_text_by_voice_markers, + validate_voice_name, ) class CountdownDialog(QDialog): @@ -296,6 +300,31 @@ class ConversionThread(QThread): self.use_spacy_segmentation = True # Default, will be overridden from GUI # Set split pattern based on language and subtitle mode self.split_pattern = self._get_split_pattern(lang_code, subtitle_mode) + self.voice_cache = {} # Cache for loaded voices + + def load_voice_cached(self, voice_name, tts): + """Load voice with caching to avoid reloading same voice. + + Args: + voice_name: Voice name or formula string + tts: TTS pipeline instance + + Returns: + Loaded voice tensor or voice name string + """ + # Check cache first + if voice_name in self.voice_cache: + return self.voice_cache[voice_name] + + # Load voice + if "*" in voice_name: + loaded_voice = get_new_voice(tts, voice_name, self.use_gpu) + else: + loaded_voice = voice_name + + # Cache it + self.voice_cache[voice_name] = loaded_voice + return loaded_voice def _stream_audio_in_chunks( self, segments, process_func, progress_prefix="Processing" @@ -524,6 +553,26 @@ class ConversionThread(QThread): # Clean up text using utility function text = clean_text(text) + # Apply word substitutions if enabled + if getattr(self, "word_substitutions_enabled", False): + from abogen.word_substitution import apply_word_substitutions + + self.log_updated.emit("Applying word substitutions...") + + substitutions_list = getattr(self, "word_substitutions_list", "") + case_sensitive = getattr(self, "case_sensitive_substitutions", False) + replace_caps = getattr(self, "replace_all_caps", False) + replace_nums = getattr(self, "replace_numerals", False) + fix_punct = getattr(self, "fix_nonstandard_punctuation", False) + + text = apply_word_substitutions( + text, + substitutions_list, + case_sensitive, + replace_caps, + replace_nums, + fix_punct, + ) # --- Chapter splitting logic --- # Use pre-compiled pattern for better performance @@ -550,6 +599,42 @@ class ConversionThread(QThread): chapters = [("text", text)] total_chapters = len(chapters) + # --- Voice marker splitting logic --- + # Split each chapter by voice markers, preserving voice state across chapters + chapters_with_voices = [] + current_voice = self.voice # Start with default voice + total_valid_markers = 0 + total_invalid_markers = 0 + + for chapter_name, chapter_text in chapters: + # Use current_voice as the starting voice for this chapter + voice_segments, last_voice, valid_count, invalid_count = split_text_by_voice_markers(chapter_text, current_voice) + chapters_with_voices.append((chapter_name, voice_segments)) + + # Update current_voice so next chapter continues with this voice + current_voice = last_voice + + # Track total valid/invalid markers + total_valid_markers += valid_count + total_invalid_markers += invalid_count + + # Log voice marker information with accurate counts + total_markers = total_valid_markers + total_invalid_markers + if total_markers > 0: + if total_invalid_markers == 0: + # All markers were valid + self.log_updated.emit( + (f"\nDetected {total_markers} voice marker(s) - all valid", "grey") + ) + else: + # Some markers were invalid + self.log_updated.emit( + (f"\nDetected {total_markers} voice marker(s) - {total_valid_markers} valid, {total_invalid_markers} invalid (using previous voice)", "orange") + ) + + # Replace chapters with the new structure + chapters = chapters_with_voices + # For text files with chapters, prompt user for options if not already set is_txt_file = not self.is_direct_text and ( self.file_name.lower().endswith(".txt") @@ -842,7 +927,7 @@ class ConversionThread(QThread): ] srt_index = 1 # SRT numbering fix for chapter-only mode # Instead of processing the whole text, process by chapter - for chapter_idx, (chapter_name, chapter_text) in enumerate(chapters, 1): + for chapter_idx, (chapter_name, voice_segments) in enumerate(chapters, 1): chapter_out_path = None chapter_out_file = None chapter_ffmpeg_proc = None @@ -862,11 +947,6 @@ class ConversionThread(QThread): if merge_chapters_at_end: chapter_time["start"] = current_time - # Check if the voice is a formula and load it if necessary - if "*" in self.voice: - loaded_voice = get_new_voice(tts, self.voice, self.use_gpu) - else: - loaded_voice = self.voice # Prepare per-chapter output file if needed if save_chapters_separately and total_chapters > 1: # First pass: keep alphanumeric, spaces, hyphens, and underscores @@ -986,286 +1066,302 @@ class ConversionThread(QThread): chapter_subtitle_path = None chapter_subtitle_file = None - # Determine if spaCy segmentation should be used for PRE-TTS segmentation - # Only non-English languages use spaCy for pre-segmentation - # English uses spaCy only for subtitle generation (post-TTS) - # spaCy is disabled when subtitle mode is "Disabled" or "Line" - # spaCy is also disabled when input is a subtitle file - is_subtitle_input = ( - not self.is_direct_text - and self.file_name - and os.path.splitext(self.file_name)[1].lower() - in [".srt", ".ass", ".vtt"] - ) - use_spacy = ( - getattr(self, "use_spacy_segmentation", False) - and self.subtitle_mode not in ["Disabled", "Line"] - and not is_subtitle_input - ) - spacy_sentences = None - active_split_pattern = self.split_pattern - spacing_pattern = r"\s*" if self.lang_code in ["z", "j"] else r"\s+" - # Pre-load spaCy model for English if it will be needed for subtitle generation - if ( - use_spacy - and self.lang_code in ["a", "b"] - and self.subtitle_mode in ["Sentence", "Sentence + Comma"] - ): - from abogen.spacy_utils import get_spacy_model - - nlp = get_spacy_model( - self.lang_code, - log_callback=lambda msg: self.log_updated.emit(msg), - ) - if nlp: + # Process each voice segment within the chapter + for segment_idx, (voice_name, segment_text) in enumerate(voice_segments): + # Load voice for this segment (with caching) + try: + loaded_voice = self.load_voice_cached(voice_name, tts) + if segment_idx > 0: + voice_display = voice_name if len(voice_name) < 50 else voice_name[:47] + "..." + self.log_updated.emit((f" → Voice: {voice_display}", "grey")) + except Exception: self.log_updated.emit( - ( - "\nUsing spaCy for sentence segmentation (only for subtitles)...", - "grey", - ) + (f"⚠ Voice loading error for '{voice_name}', continuing with previous", "orange") ) + if segment_idx == 0: + loaded_voice = self.load_voice_cached(self.voice, tts) - if use_spacy and self.lang_code not in ["a", "b"]: - # Non-English: use spaCy for pre-TTS segmentation - self.log_updated.emit( - ("\nUsing spaCy for sentence segmentation (pre-TTS)...", "grey") + # Determine if spaCy segmentation should be used for PRE-TTS segmentation + # Only non-English languages use spaCy for pre-segmentation + # English uses spaCy only for subtitle generation (post-TTS) + # spaCy is disabled when subtitle mode is "Disabled" or "Line" + # spaCy is also disabled when input is a subtitle file + is_subtitle_input = ( + not self.is_direct_text + and self.file_name + and os.path.splitext(self.file_name)[1].lower() + in [".srt", ".ass", ".vtt"] ) - from abogen.spacy_utils import segment_sentences - - spacy_sentences = segment_sentences( - chapter_text, - self.lang_code, - log_callback=lambda msg: self.log_updated.emit(msg), + use_spacy = ( + getattr(self, "use_spacy_segmentation", False) + and self.subtitle_mode not in ["Disabled", "Line"] + and not is_subtitle_input ) - if spacy_sentences: - self.log_updated.emit( - ( - f"\nspaCy: Text segmented into {len(spacy_sentences)} sentences...", - "grey", - ) - ) - # For Sentence + Comma mode, still split on commas within spaCy sentences - if self.subtitle_mode == "Sentence + Comma": - active_split_pattern = r"(?<=[{}]){}|\n+".format( - self.PUNCTUATION_COMMAS, spacing_pattern - ) - else: - active_split_pattern = ( - "\n" # Use newline splitting for Sentence mode - ) - else: - self.log_updated.emit( - ("\nspaCy: Fallback to default segmentation...", "grey") - ) + spacy_sentences = None + active_split_pattern = self.split_pattern + spacing_pattern = r"\s*" if self.lang_code in ["z", "j"] else r"\s+" - # Process text - either as spaCy sentences or as single text - text_segments = spacy_sentences if spacy_sentences else [chapter_text] - - # Print active split pattern used by the TTS engine once for this batch - try: - print(f"Using split pattern: {active_split_pattern!r}") - except Exception: - # Print must never break processing - print("Using split pattern: (unprintable)") - - for text_segment in text_segments: - for result in tts( - text_segment, - voice=loaded_voice, - speed=self.speed, - split_pattern=active_split_pattern, + # Pre-load spaCy model for English if it will be needed for subtitle generation + if ( + use_spacy + and self.lang_code in ["a", "b"] + and self.subtitle_mode in ["Sentence", "Sentence + Comma"] ): - # Print the result for debugging - # print(f"Result: {result}") - if self.cancel_requested: - if chapter_out_file: - chapter_out_file.close() - if merged_out_file: - merged_out_file.close() - self.conversion_finished.emit("Cancelled", None) - return - current_segment += 1 - grapheme_len = len(result.graphemes) - self.processed_char_count += grapheme_len - # Log progress with both character counts and the graphemes content - self.log_updated.emit( - f"\n{self.processed_char_count:,}/{self.total_char_count:,}: {result.graphemes}" + from abogen.spacy_utils import get_spacy_model + + nlp = get_spacy_model( + self.lang_code, + log_callback=lambda msg: self.log_updated.emit(msg), ) + if nlp: + self.log_updated.emit( + ( + "\nUsing spaCy for sentence segmentation (only for subtitles)...", + "grey", + ) + ) - chunk_dur = len(result.audio) / rate - chunk_start = current_time - # Write audio directly to merged file ONLY if merging - if merge_chapters_at_end and merged_out_file: - merged_out_file.write(result.audio) - elif merge_chapters_at_end and ffmpeg_proc: - if hasattr(result.audio, "numpy"): - audio_bytes = ( - result.audio.numpy().astype("float32").tobytes() + if use_spacy and self.lang_code not in ["a", "b"]: + # Non-English: use spaCy for pre-TTS segmentation + self.log_updated.emit( + ("\nUsing spaCy for sentence segmentation (pre-TTS)...", "grey") + ) + from abogen.spacy_utils import segment_sentences + + spacy_sentences = segment_sentences( + segment_text, + self.lang_code, + log_callback=lambda msg: self.log_updated.emit(msg), + ) + if spacy_sentences: + self.log_updated.emit( + ( + f"\nspaCy: Text segmented into {len(spacy_sentences)} sentences...", + "grey", + ) + ) + # For Sentence + Comma mode, still split on commas within spaCy sentences + if self.subtitle_mode == "Sentence + Comma": + active_split_pattern = r"(?<=[{}]){}|\n+".format( + self.PUNCTUATION_COMMAS, spacing_pattern ) else: - audio_bytes = result.audio.astype("float32").tobytes() - ffmpeg_proc.stdin.write(audio_bytes) - if chapter_out_file: - chapter_out_file.write(result.audio) - elif chapter_ffmpeg_proc: - if hasattr(result.audio, "numpy"): - audio_bytes = ( - result.audio.numpy().astype("float32").tobytes() + active_split_pattern = ( + "\n" # Use newline splitting for Sentence mode ) - else: - audio_bytes = result.audio.astype("float32").tobytes() - chapter_ffmpeg_proc.stdin.write(audio_bytes) - # Subtitle logic - if self.subtitle_mode != "Disabled": - tokens_list = getattr(result, "tokens", []) + else: + self.log_updated.emit( + ("\nspaCy: Fallback to default segmentation...", "grey") + ) - # Fallback for languages without token support (non-English) - # Create a single token representing the entire segment duration - if not tokens_list and result.graphemes: + # Process text - either as spaCy sentences or as single text + text_segments = spacy_sentences if spacy_sentences else [segment_text] - class FakeToken: - def __init__(self, text, start, end): - self.text = text - self.start_ts = start - self.end_ts = end - self.whitespace = "" + # Print active split pattern used by the TTS engine once for this batch + try: + print(f"Using split pattern: {active_split_pattern!r}") + except Exception: + # Print must never break processing + print("Using split pattern: (unprintable)") - tokens_list = [ - FakeToken(result.graphemes, 0, chunk_dur) - ] + for text_segment in text_segments: + for result in tts( + text_segment, + voice=loaded_voice, + speed=self.speed, + split_pattern=active_split_pattern, + ): + # Print the result for debugging + # print(f"Result: {result}") + if self.cancel_requested: + if chapter_out_file: + chapter_out_file.close() + if merged_out_file: + merged_out_file.close() + self.conversion_finished.emit("Cancelled", None) + return + current_segment += 1 + grapheme_len = len(result.graphemes) + self.processed_char_count += grapheme_len + # Log progress with both character counts and the graphemes content + self.log_updated.emit( + f"\n{self.processed_char_count:,}/{self.total_char_count:,}: {result.graphemes}" + ) - tokens_with_timestamps = [] - chapter_tokens_with_timestamps = [] + chunk_dur = len(result.audio) / rate + chunk_start = current_time + # Write audio directly to merged file ONLY if merging + if merge_chapters_at_end and merged_out_file: + merged_out_file.write(result.audio) + elif merge_chapters_at_end and ffmpeg_proc: + if hasattr(result.audio, "numpy"): + audio_bytes = ( + result.audio.numpy().astype("float32").tobytes() + ) + else: + audio_bytes = result.audio.astype("float32").tobytes() + ffmpeg_proc.stdin.write(audio_bytes) + if chapter_out_file: + chapter_out_file.write(result.audio) + elif chapter_ffmpeg_proc: + if hasattr(result.audio, "numpy"): + audio_bytes = ( + result.audio.numpy().astype("float32").tobytes() + ) + else: + audio_bytes = result.audio.astype("float32").tobytes() + chapter_ffmpeg_proc.stdin.write(audio_bytes) + # Subtitle logic + if self.subtitle_mode != "Disabled": + tokens_list = getattr(result, "tokens", []) - # Process every token, regardless of text or timestamps - for tok in tokens_list: - tokens_with_timestamps.append( - { - "start": chunk_start + (tok.start_ts or 0), - "end": chunk_start + (tok.end_ts or 0), - "text": tok.text, - "whitespace": tok.whitespace, - } - ) - if chapter_out_file or chapter_ffmpeg_proc: - chapter_tokens_with_timestamps.append( + # Fallback for languages without token support (non-English) + # Create a single token representing the entire segment duration + if not tokens_list and result.graphemes: + + class FakeToken: + def __init__(self, text, start, end): + self.text = text + self.start_ts = start + self.end_ts = end + self.whitespace = "" + + tokens_list = [ + FakeToken(result.graphemes, 0, chunk_dur) + ] + + tokens_with_timestamps = [] + chapter_tokens_with_timestamps = [] + + # Process every token, regardless of text or timestamps + for tok in tokens_list: + tokens_with_timestamps.append( { - "start": chapter_current_time - + (tok.start_ts or 0), - "end": chapter_current_time - + (tok.end_ts or 0), + "start": chunk_start + (tok.start_ts or 0), + "end": chunk_start + (tok.end_ts or 0), "text": tok.text, "whitespace": tok.whitespace, } ) - # Process tokens according to subtitle mode - # Global subtitle processing ONLY if merging + if chapter_out_file or chapter_ffmpeg_proc: + chapter_tokens_with_timestamps.append( + { + "start": chapter_current_time + + (tok.start_ts or 0), + "end": chapter_current_time + + (tok.end_ts or 0), + "text": tok.text, + "whitespace": tok.whitespace, + } + ) + # Process tokens according to subtitle mode + # Global subtitle processing ONLY if merging + if merge_chapters_at_end: + # Incremental subtitle writing for merged output + new_entries = [] + self._process_subtitle_tokens( + tokens_with_timestamps, + new_entries, + self.max_subtitle_words, + fallback_end_time=chunk_start + chunk_dur, + ) + if merged_subtitle_file: + subtitle_format = getattr( + self, "subtitle_format", "srt" + ) + if "ass" in subtitle_format: + for start, end, text in new_entries: + start_time = self._ass_time(start) + end_time = self._ass_time(end) + # Use karaoke effect for highlighting mode + effect = ( + "karaoke" + if self.subtitle_mode + == "Sentence + Highlighting" + else "" + ) + merged_subtitle_file.write( + f"Dialogue: 0,{start_time},{end_time},Default,,{merged_subtitle_margin},{merged_subtitle_margin},0,{effect},{merged_subtitle_alignment_tag}{text}\n" + ) + else: + for entry in new_entries: + start, end, text = entry + merged_subtitle_file.write( + f"{merged_srt_index}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n" + ) + merged_srt_index += 1 + # Per-chapter subtitle processing for both file and ffmpeg_proc + if chapter_out_file or chapter_ffmpeg_proc: + new_chapter_entries = [] + self._process_subtitle_tokens( + chapter_tokens_with_timestamps, + new_chapter_entries, + self.max_subtitle_words, + fallback_end_time=chapter_current_time + chunk_dur, + ) + if chapter_subtitle_file: + subtitle_format = getattr( + self, "subtitle_format", "srt" + ) + if "ass" in subtitle_format: + for start, end, text in new_chapter_entries: + start_time = self._ass_time(start) + end_time = self._ass_time(end) + # Use karaoke effect for highlighting mode + effect = ( + "karaoke" + if self.subtitle_mode + == "Sentence + Highlighting" + else "" + ) + chapter_subtitle_file.write( + f"Dialogue: 0,{start_time},{end_time},Default,,{chapter_subtitle_margin},{chapter_subtitle_margin},0,{effect},{chapter_subtitle_alignment_tag}{text}\n" + ) + else: + for entry in new_chapter_entries: + start, end, text = entry + chapter_subtitle_file.write( + f"{chapter_srt_index}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n" + ) + chapter_srt_index += 1 if merge_chapters_at_end: - # Incremental subtitle writing for merged output - new_entries = [] - self._process_subtitle_tokens( - tokens_with_timestamps, - new_entries, - self.max_subtitle_words, - fallback_end_time=chunk_start + chunk_dur, - ) - if merged_subtitle_file: - subtitle_format = getattr( - self, "subtitle_format", "srt" - ) - if "ass" in subtitle_format: - for start, end, text in new_entries: - start_time = self._ass_time(start) - end_time = self._ass_time(end) - # Use karaoke effect for highlighting mode - effect = ( - "karaoke" - if self.subtitle_mode - == "Sentence + Highlighting" - else "" - ) - merged_subtitle_file.write( - f"Dialogue: 0,{start_time},{end_time},Default,,{merged_subtitle_margin},{merged_subtitle_margin},0,{effect},{merged_subtitle_alignment_tag}{text}\n" - ) - else: - for entry in new_entries: - start, end, text = entry - merged_subtitle_file.write( - f"{merged_srt_index}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n" - ) - merged_srt_index += 1 - # Per-chapter subtitle processing for both file and ffmpeg_proc - if chapter_out_file or chapter_ffmpeg_proc: - new_chapter_entries = [] - self._process_subtitle_tokens( - chapter_tokens_with_timestamps, - new_chapter_entries, - self.max_subtitle_words, - fallback_end_time=chapter_current_time + chunk_dur, - ) - if chapter_subtitle_file: - subtitle_format = getattr( - self, "subtitle_format", "srt" - ) - if "ass" in subtitle_format: - for start, end, text in new_chapter_entries: - start_time = self._ass_time(start) - end_time = self._ass_time(end) - # Use karaoke effect for highlighting mode - effect = ( - "karaoke" - if self.subtitle_mode - == "Sentence + Highlighting" - else "" - ) - chapter_subtitle_file.write( - f"Dialogue: 0,{start_time},{end_time},Default,,{chapter_subtitle_margin},{chapter_subtitle_margin},0,{effect},{chapter_subtitle_alignment_tag}{text}\n" - ) - else: - for entry in new_chapter_entries: - start, end, text = entry - chapter_subtitle_file.write( - f"{chapter_srt_index}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n" - ) - chapter_srt_index += 1 - if merge_chapters_at_end: - current_time += chunk_dur - if chapter_out_file or chapter_ffmpeg_proc: - chapter_current_time += chunk_dur - else: - if chapter_out_file or chapter_ffmpeg_proc: - chapter_current_time += chunk_dur - # Calculate percentage based on characters processed - percent = min( - int( - self.processed_char_count / self.total_char_count * 100 - ), - 99, - ) - - # Calculate ETR based on characters processed - etr_str = "Processing..." - chars_done = self.processed_char_count - elapsed = time.time() - self.etr_start_time - - # Calculate ETR if enough data is available - if ( - chars_done > 0 and elapsed > 0.5 - ): # Check elapsed > 0.5 to avoid instability - avg_time_per_char = elapsed / chars_done - remaining = ( - self.total_char_count - self.processed_char_count + current_time += chunk_dur + if chapter_out_file or chapter_ffmpeg_proc: + chapter_current_time += chunk_dur + else: + if chapter_out_file or chapter_ffmpeg_proc: + chapter_current_time += chunk_dur + # Calculate percentage based on characters processed + percent = min( + int( + self.processed_char_count / self.total_char_count * 100 + ), + 99, ) - if remaining > 0: - secs = avg_time_per_char * remaining - h = int(secs // 3600) - m = int((secs % 3600) // 60) - s = int(secs % 60) - etr_str = f"{h:02d}:{m:02d}:{s:02d}" - # Update progress more frequently (after each result) - self.progress_updated.emit(percent, etr_str) + # Calculate ETR based on characters processed + etr_str = "Processing..." + chars_done = self.processed_char_count + elapsed = time.time() - self.etr_start_time + + # Calculate ETR if enough data is available + if ( + chars_done > 0 and elapsed > 0.5 + ): # Check elapsed > 0.5 to avoid instability + avg_time_per_char = elapsed / chars_done + remaining = ( + self.total_char_count - self.processed_char_count + ) + if remaining > 0: + secs = avg_time_per_char * remaining + h = int(secs // 3600) + m = int((secs % 3600) // 60) + s = int(secs % 60) + etr_str = f"{h:02d}:{m:02d}:{s:02d}" + + # Update progress more frequently (after each result) + self.progress_updated.emit(percent, etr_str) # Add silence between chapters for merged output (except after the last chapter) if merge_chapters_at_end and chapter_idx < total_chapters: diff --git a/abogen/pyqt/gui.py b/abogen/pyqt/gui.py index e0aa2e5..4ff103e 100644 --- a/abogen/pyqt/gui.py +++ b/abogen/pyqt/gui.py @@ -665,6 +665,11 @@ class TextboxDialog(QDialog): self.insert_chapter_btn.clicked.connect(self.insert_chapter_marker) button_layout.addWidget(self.insert_chapter_btn) + self.insert_voice_btn = QPushButton("Insert Voice Marker", self) + self.insert_voice_btn.setToolTip("Insert a voice change marker at the cursor position") + self.insert_voice_btn.clicked.connect(self.insert_voice_marker) + button_layout.addWidget(self.insert_voice_btn) + self.cancel_button = QPushButton("Cancel", self) self.cancel_button.clicked.connect(self.reject) @@ -767,6 +772,23 @@ class TextboxDialog(QDialog): self.update_char_count() self.text_edit.setFocus() + def insert_voice_marker(self): + """Insert a voice marker template at cursor position.""" + cursor = self.text_edit.textCursor() + # Use the currently selected voice as the default + try: + parent_window = self.parent() + if parent_window and hasattr(parent_window, 'selected_voice'): + default_voice = parent_window.selected_voice or "af_heart" + else: + default_voice = "af_heart" + except Exception: + default_voice = "af_heart" + cursor.insertText(f"\n<>\n") + self.text_edit.setTextCursor(cursor) + self.update_char_count() + self.text_edit.setFocus() + def migrate_subtitle_format(config): """Convert old subtitle_format values to new internal keys.""" @@ -783,6 +805,108 @@ def migrate_subtitle_format(config): save_config(config) +class WordSubstitutionsDialog(QDialog): + """Dialog for configuring word substitutions and text preprocessing options.""" + + def __init__( + self, + parent=None, + initial_list="", + initial_case_sensitive=False, + initial_caps=False, + initial_numerals=False, + initial_punctuation=False, + ): + super().__init__(parent) + self.setWindowTitle("Word Substitutions Settings") + self.setWindowFlags( + Qt.WindowType.Window + | Qt.WindowType.WindowCloseButtonHint + | Qt.WindowType.WindowMaximizeButtonHint + ) + self.resize(600, 500) + + layout = QVBoxLayout(self) + + # Instructions + instructions = QLabel( + "Enter word substitutions (one per line) in format: Word|NewWord\n" + " - If nothing after |, the word will be erased completely\n" + " - Substitutions match whole words only (e.g., \"tree\" won't match \"trees\" but will match \"tree's\")\n" + " - By default, matching is case-insensitive (e.g., \"gonna\" matches \"Gonna\", \"GONNA\", etc.)", + self, + ) + instructions.setStyleSheet( + "padding: 10px; background-color: #f0f0f0; border-radius: 5px;" + ) + instructions.setWordWrap(True) + layout.addWidget(instructions) + + # Text edit area + self.text_edit = QTextEdit(self) + self.text_edit.setAcceptRichText(False) + self.text_edit.setPlaceholderText("Word|NewWord") + self.text_edit.setPlainText(initial_list) + layout.addWidget(self.text_edit) + + # Checkboxes + self.case_sensitive_checkbox = QCheckBox( + "Case-sensitive word matching", self + ) + self.case_sensitive_checkbox.setChecked(initial_case_sensitive) + layout.addWidget(self.case_sensitive_checkbox) + + self.caps_checkbox = QCheckBox("Replace ALL CAPS with lowercase", self) + self.caps_checkbox.setChecked(initial_caps) + layout.addWidget(self.caps_checkbox) + + self.numerals_checkbox = QCheckBox( + "Replace Numerals with Words (e.g., 309 \u2192 three hundred and nine)", self + ) + self.numerals_checkbox.setChecked(initial_numerals) + layout.addWidget(self.numerals_checkbox) + + self.punctuation_checkbox = QCheckBox( + "Fix Nonstandard Punctuation (curly quotes and other Unicode punctuation that may affect how words sound)", + self, + ) + self.punctuation_checkbox.setChecked(initial_punctuation) + layout.addWidget(self.punctuation_checkbox) + + # Buttons + button_layout = QHBoxLayout() + self.cancel_button = QPushButton("Cancel", self) + self.cancel_button.clicked.connect(self.reject) + self.ok_button = QPushButton("OK", self) + self.ok_button.setDefault(True) + self.ok_button.clicked.connect(self.accept) + + button_layout.addStretch() + button_layout.addWidget(self.cancel_button) + button_layout.addWidget(self.ok_button) + layout.addLayout(button_layout) + + def get_substitutions_list(self): + """Get the substitutions list as plain text.""" + return self.text_edit.toPlainText() + + def get_case_sensitive(self): + """Get whether case-sensitive matching is enabled.""" + return self.case_sensitive_checkbox.isChecked() + + def get_replace_all_caps(self): + """Get whether ALL CAPS replacement is enabled.""" + return self.caps_checkbox.isChecked() + + def get_replace_numerals(self): + """Get whether numeral-to-word conversion is enabled.""" + return self.numerals_checkbox.isChecked() + + def get_fix_nonstandard_punctuation(self): + """Get whether nonstandard punctuation fixing is enabled.""" + return self.punctuation_checkbox.isChecked() + + class abogen(QWidget): def __init__(self): super().__init__() @@ -833,6 +957,19 @@ class abogen(QWidget): self.use_silent_gaps = self.config.get("use_silent_gaps", True) self.subtitle_speed_method = self.config.get("subtitle_speed_method", "tts") self.use_spacy_segmentation = self.config.get("use_spacy_segmentation", True) + # Word substitution settings + self.word_substitutions_enabled = self.config.get( + "word_substitutions_enabled", False + ) + self.word_substitutions_list = self.config.get("word_substitutions_list", "") + self.case_sensitive_substitutions = self.config.get( + "case_sensitive_substitutions", False + ) + self.replace_all_caps = self.config.get("replace_all_caps", False) + self.replace_numerals = self.config.get("replace_numerals", False) + self.fix_nonstandard_punctuation = self.config.get( + "fix_nonstandard_punctuation", False + ) self._pending_close_event = None self.gpu_ok = False # Initialize GPU availability status @@ -1071,6 +1208,35 @@ class abogen(QWidget): subtitle_layout.addWidget(self.subtitle_combo) controls_layout.addLayout(subtitle_layout) + # Word Substitutions section + word_sub_layout = QHBoxLayout() + word_sub_layout.setSpacing(7) + word_sub_label = QLabel("Word Substitutions:", self) + word_sub_layout.addWidget(word_sub_label) + + self.word_sub_combo = QComboBox(self) + self.word_sub_combo.addItems(["Disabled", "Enabled"]) + self.word_sub_combo.setStyleSheet( + "QComboBox { min-height: 20px; padding: 6px 12px; }" + ) + self.word_sub_combo.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + self.word_sub_combo.setCurrentText( + "Enabled" if self.word_substitutions_enabled else "Disabled" + ) + self.word_sub_combo.currentTextChanged.connect(self.on_word_sub_changed) + word_sub_layout.addWidget(self.word_sub_combo) + + self.btn_word_sub_settings = QPushButton("Settings", self) + self.btn_word_sub_settings.setFixedSize(80, 36) + self.btn_word_sub_settings.setStyleSheet("QPushButton { padding: 6px 12px; }") + self.btn_word_sub_settings.clicked.connect(self.show_word_sub_dialog) + self.btn_word_sub_settings.setEnabled(self.word_substitutions_enabled) + word_sub_layout.addWidget(self.btn_word_sub_settings) + + controls_layout.addLayout(word_sub_layout) + # Output voice format format_layout = QHBoxLayout() format_layout.setSpacing(7) @@ -2015,15 +2181,37 @@ class abogen(QWidget): self.subtitle_speed_method = getattr( queued_item, "subtitle_speed_method", "tts" ) + # Word substitution settings + self.word_substitutions_enabled = getattr( + queued_item, "word_substitutions_enabled", False + ) + self.word_substitutions_list = getattr( + queued_item, "word_substitutions_list", "" + ) + self.case_sensitive_substitutions = getattr( + queued_item, "case_sensitive_substitutions", False + ) + self.replace_all_caps = getattr(queued_item, "replace_all_caps", False) + self.replace_numerals = getattr(queued_item, "replace_numerals", False) + self.fix_nonstandard_punctuation = getattr( + queued_item, "fix_nonstandard_punctuation", False + ) - # This ensures that if conversion.py (or utils) reads from config/disk + # This ensures that if conversion.py (or utils) reads from config/disk # instead of using passed arguments, it sees the correct queue values. self.config["replace_single_newlines"] = self.replace_single_newlines self.config["subtitle_mode"] = self.subtitle_mode self.config["selected_format"] = self.selected_format self.config["use_silent_gaps"] = self.use_silent_gaps self.config["subtitle_speed_method"] = self.subtitle_speed_method - + # Word substitution settings + self.config["word_substitutions_enabled"] = self.word_substitutions_enabled + self.config["word_substitutions_list"] = self.word_substitutions_list + self.config["case_sensitive_substitutions"] = self.case_sensitive_substitutions + self.config["replace_all_caps"] = self.replace_all_caps + self.config["replace_numerals"] = self.replace_numerals + self.config["fix_nonstandard_punctuation"] = self.fix_nonstandard_punctuation + # Sync Voice/Profile in config self.config["selected_voice"] = self.selected_voice if "selected_profile_name" in self.config: @@ -2179,6 +2367,21 @@ class abogen(QWidget): self.conversion_thread.subtitle_speed_method = self.subtitle_speed_method # Pass use_spacy_segmentation setting self.conversion_thread.use_spacy_segmentation = self.use_spacy_segmentation + # Pass word substitution settings + self.conversion_thread.word_substitutions_enabled = ( + self.word_substitutions_enabled + ) + self.conversion_thread.word_substitutions_list = ( + self.word_substitutions_list + ) + self.conversion_thread.case_sensitive_substitutions = ( + self.case_sensitive_substitutions + ) + self.conversion_thread.replace_all_caps = self.replace_all_caps + self.conversion_thread.replace_numerals = self.replace_numerals + self.conversion_thread.fix_nonstandard_punctuation = ( + self.fix_nonstandard_punctuation + ) # Pass separate_chapters_format setting self.conversion_thread.separate_chapters_format = ( self.separate_chapters_format @@ -2927,6 +3130,41 @@ class abogen(QWidget): self.config["use_gpu"] = self.use_gpu save_config(self.config) + def on_word_sub_changed(self, text): + """Handle word substitution dropdown change.""" + self.word_substitutions_enabled = text == "Enabled" + self.btn_word_sub_settings.setEnabled(self.word_substitutions_enabled) + + # Save to config + self.config["word_substitutions_enabled"] = self.word_substitutions_enabled + save_config(self.config) + + def show_word_sub_dialog(self): + """Show word substitutions settings dialog.""" + dialog = WordSubstitutionsDialog( + self, + initial_list=self.word_substitutions_list, + initial_case_sensitive=self.case_sensitive_substitutions, + initial_caps=self.replace_all_caps, + initial_numerals=self.replace_numerals, + initial_punctuation=self.fix_nonstandard_punctuation, + ) + + if dialog.exec() == QDialog.DialogCode.Accepted: + self.word_substitutions_list = dialog.get_substitutions_list() + self.case_sensitive_substitutions = dialog.get_case_sensitive() + self.replace_all_caps = dialog.get_replace_all_caps() + self.replace_numerals = dialog.get_replace_numerals() + self.fix_nonstandard_punctuation = dialog.get_fix_nonstandard_punctuation() + + # Save all settings to config + self.config["word_substitutions_list"] = self.word_substitutions_list + self.config["case_sensitive_substitutions"] = self.case_sensitive_substitutions + self.config["replace_all_caps"] = self.replace_all_caps + self.config["replace_numerals"] = self.replace_numerals + self.config["fix_nonstandard_punctuation"] = self.fix_nonstandard_punctuation + save_config(self.config) + def cleanup_conversion_thread(self): # Stop conversion thread if ( diff --git a/abogen/pyqt/queue_manager_gui.py b/abogen/pyqt/queue_manager_gui.py index 384be79..748d6a3 100644 --- a/abogen/pyqt/queue_manager_gui.py +++ b/abogen/pyqt/queue_manager_gui.py @@ -35,6 +35,12 @@ OVERRIDE_FIELDS = [ "replace_single_newlines", "use_silent_gaps", "subtitle_speed_method", + "word_substitutions_enabled", + "word_substitutions_list", + "case_sensitive_substitutions", + "replace_all_caps", + "replace_numerals", + "fix_nonstandard_punctuation", ] @@ -474,6 +480,21 @@ class QueueManager(QDialog): attrs["subtitle_speed_method"] = getattr( parent, "subtitle_speed_method", "tts" ) + # word substitutions + attrs["word_substitutions_enabled"] = getattr( + parent, "word_substitutions_enabled", False + ) + attrs["word_substitutions_list"] = getattr( + parent, "word_substitutions_list", "" + ) + attrs["case_sensitive_substitutions"] = getattr( + parent, "case_sensitive_substitutions", False + ) + attrs["replace_all_caps"] = getattr(parent, "replace_all_caps", False) + attrs["replace_numerals"] = getattr(parent, "replace_numerals", False) + attrs["fix_nonstandard_punctuation"] = getattr( + parent, "fix_nonstandard_punctuation", False + ) # book handler options attrs["save_chapters_separately"] = getattr( parent, "save_chapters_separately", None diff --git a/abogen/pyqt/queued_item.py b/abogen/pyqt/queued_item.py index 38d4054..3c134d1 100644 --- a/abogen/pyqt/queued_item.py +++ b/abogen/pyqt/queued_item.py @@ -19,3 +19,10 @@ class QueuedItem: save_base_path: str = None save_chapters_separately: bool = None merge_chapters_at_end: bool = None + # Word Substitution fields + word_substitutions_enabled: bool = False + word_substitutions_list: str = "" + case_sensitive_substitutions: bool = False + replace_all_caps: bool = False + replace_numerals: bool = False + fix_nonstandard_punctuation: bool = False diff --git a/abogen/subtitle_utils.py b/abogen/subtitle_utils.py index 5455c55..8d76ad6 100644 --- a/abogen/subtitle_utils.py +++ b/abogen/subtitle_utils.py @@ -15,6 +15,8 @@ _ASS_STYLING_PATTERN = re.compile(r"\{[^}]+\}") _ASS_NEWLINE_N_PATTERN = re.compile(r"\\N") _ASS_NEWLINE_LOWER_N_PATTERN = re.compile(r"\\n") _CHAPTER_MARKER_SEARCH_PATTERN = re.compile(r"<>") +_VOICE_MARKER_PATTERN = re.compile(r"<]*>>") +_VOICE_MARKER_SEARCH_PATTERN = re.compile(r"<>") _WEBVTT_HEADER_PATTERN = re.compile(r"^WEBVTT.*?\n", re.MULTILINE) _VTT_STYLE_PATTERN = re.compile(r"STYLE\s*\n.*?(?=\n\n|$)", re.DOTALL) _VTT_NOTE_PATTERN = re.compile(r"NOTE\s*\n.*?(?=\n\n|$)", re.DOTALL) @@ -31,17 +33,19 @@ _LINUX_ILLEGAL_CHARS_PATTERN = re.compile(r"[/\x00]") def clean_subtitle_text(text): - """Remove chapter markers and metadata tags from subtitle text.""" + """Remove chapter markers, voice markers, and metadata tags from subtitle text.""" # Use pre-compiled patterns for better performance text = _METADATA_TAG_PATTERN.sub("", text) text = _CHAPTER_MARKER_PATTERN.sub("", text) + text = _VOICE_MARKER_PATTERN.sub("", text) return text.strip() def calculate_text_length(text): # Use pre-compiled patterns for better performance - # Ignore chapter markers and metadata patterns in a single pass + # Ignore chapter markers, voice markers, and metadata patterns in a single pass text = _CHAPTER_MARKER_PATTERN.sub("", text) + text = _VOICE_MARKER_PATTERN.sub("", text) text = _METADATA_TAG_PATTERN.sub("", text) # Ignore newlines and leading/trailing spaces text = text.replace("\n", "").strip() @@ -459,3 +463,122 @@ def sanitize_name_for_os(name, is_folder=True): sanitized = sanitized[:255].rstrip(". ") return sanitized + + +def validate_voice_name(voice_name): + """Validate voice name against VOICES_INTERNAL list (case-insensitive). + Handles both single voices and formulas like 'af_heart*0.5 + am_echo*0.5'. + + Args: + voice_name: Voice name or formula string to validate + + Returns: + Tuple of (is_valid, invalid_voice_name): + - is_valid: True if all voices in the name/formula are valid + - invalid_voice_name: The first invalid voice found, or None if all valid + """ + from abogen.constants import VOICES_INTERNAL + + # Create case-insensitive lookup set (done once per call) + voice_lookup_lower = {v.lower() for v in VOICES_INTERNAL} + voice_name = voice_name.strip() + + # Check if it's a formula (contains *) + if "*" in voice_name: + # Extract voice names from formula + voices = voice_name.split("+") + for term in voices: + if "*" in term: + base_voice = term.split("*")[0].strip() + # Case-insensitive comparison + if base_voice.lower() not in voice_lookup_lower: + return False, base_voice + return True, None + else: + # Single voice - case-insensitive comparison + if voice_name.lower() not in voice_lookup_lower: + return False, voice_name + return True, None + + +def split_text_by_voice_markers(text, default_voice): + """Split text by voice markers, returning list of (voice, text) tuples. + + IMPORTANT: Returns the last voice used so it can persist across chapters. + Voice names are normalized to lowercase to match VOICES_INTERNAL. + + Args: + text: Text potentially containing <> markers + default_voice: Voice to use if no markers found or before first marker + + Returns: + Tuple of (segments_list, last_voice_used, valid_count, invalid_count): + - segments_list: List of (voice_name, segment_text) tuples + - last_voice_used: The voice that should continue into next chapter + - valid_count: Number of valid voice markers processed + - invalid_count: Number of invalid voice markers skipped + """ + from abogen.constants import VOICES_INTERNAL + + voice_splits = list(_VOICE_MARKER_SEARCH_PATTERN.finditer(text)) + + if not voice_splits: + # No voice markers, return entire text with default voice + return [(default_voice, text)], default_voice, 0, 0 + + segments = [] + current_voice = default_voice + valid_markers = 0 + invalid_markers = 0 + + # Text before first marker uses default voice + first_start = voice_splits[0].start() + if first_start > 0: + intro_text = text[:first_start].strip() + if intro_text: + segments.append((current_voice, intro_text)) + + # Process each voice marker + for idx, match in enumerate(voice_splits): + voice_name = match.group(1).strip() + start = match.end() + end = voice_splits[idx + 1].start() if idx + 1 < len(voice_splits) else len(text) + segment_text = text[start:end].strip() + + # Validate voice name + is_valid, invalid_voice = validate_voice_name(voice_name) + if is_valid: + # Normalize to lowercase to match canonical form + # Handle both single voices and formulas + if "*" in voice_name: + # Normalize each voice in the formula + normalized_parts = [] + for part in voice_name.split("+"): + part = part.strip() + if "*" in part: + voice_part, weight = part.split("*", 1) + # Find the canonical (lowercase) voice name + voice_part_lower = voice_part.strip().lower() + canonical_voice = next( + (v for v in VOICES_INTERNAL if v.lower() == voice_part_lower), + voice_part.strip() + ) + normalized_parts.append(f"{canonical_voice}*{weight.strip()}") + current_voice = " + ".join(normalized_parts) + else: + # Find the canonical (lowercase) voice name + voice_name_lower = voice_name.lower() + current_voice = next( + (v for v in VOICES_INTERNAL if v.lower() == voice_name_lower), + voice_name + ) + valid_markers += 1 + else: + # Invalid voice - stay with previous voice + invalid_markers += 1 + + if segment_text: + segments.append((current_voice, segment_text)) + + # Return segments, last voice, and counts + return segments, current_voice, valid_markers, invalid_markers diff --git a/abogen/word_substitution.py b/abogen/word_substitution.py new file mode 100644 index 0000000..a0f5705 --- /dev/null +++ b/abogen/word_substitution.py @@ -0,0 +1,254 @@ +""" +Word substitution module for text-to-speech preprocessing. + +This module provides functionality to: +- Replace words/phrases with custom text +- Convert ALL CAPS to lowercase +- Convert numerals to words +- Fix nonstandard punctuation for TTS compatibility + +All substitutions preserve special markers (chapter, voice, metadata, timestamps). +""" + +import re + +from abogen.subtitle_utils import ( + _CHAPTER_MARKER_PATTERN, + _VOICE_MARKER_PATTERN, + _METADATA_TAG_PATTERN, + _TIMESTAMP_ONLY_PATTERN, +) + + +def apply_word_substitutions( + text, + substitutions_list_str, + case_sensitive=False, + replace_all_caps=False, + replace_numerals=False, + fix_nonstandard_punctuation=False, +): + """ + Apply word substitutions to text while preserving markers. + + Args: + text: Input text + substitutions_list_str: Newline-separated "Word|NewWord" pairs + case_sensitive: If True, match words case-sensitively + replace_all_caps: Convert ALL CAPS words to lowercase + replace_numerals: Convert numbers to words + fix_nonstandard_punctuation: Fix curly quotes, em/en dashes, etc. + + Returns: + Modified text + """ + # Apply nonstandard punctuation fixes FIRST (if enabled) + if fix_nonstandard_punctuation: + text = fix_punctuation(text) + + # Parse substitutions list + substitutions = parse_substitutions_list(substitutions_list_str) + + # Split text into segments (markers vs content) + segments = split_text_preserving_markers(text) + + # Process each segment + processed_segments = [] + for segment_type, segment_text in segments: + if segment_type == "marker": + # Preserve markers unchanged + processed_segments.append(segment_text) + else: + # Apply substitutions to content + processed_text = segment_text + + # Apply word substitutions + if substitutions: + processed_text = apply_word_replacements( + processed_text, substitutions, case_sensitive + ) + + # Apply ALL CAPS conversion + if replace_all_caps: + processed_text = convert_all_caps_to_lowercase(processed_text) + + # Apply numeral conversion + if replace_numerals: + processed_text = convert_numerals_to_words(processed_text) + + processed_segments.append(processed_text) + + return "".join(processed_segments) + + +def parse_substitutions_list(substitutions_str): + """ + Parse newline-separated "Word|NewWord" format. + + Args: + substitutions_str: String with substitutions, one per line + + Returns: + List of tuples: [(word, replacement), ...] + """ + substitutions = [] + for line in substitutions_str.strip().split("\n"): + line = line.strip() + if not line or "|" not in line: + continue + + parts = line.split("|", 1) + if len(parts) == 2: + word = parts[0].strip() + replacement = parts[1].strip() + if word: # Only add if word is not empty + substitutions.append((word, replacement)) + + return substitutions + + +def split_text_preserving_markers(text): + """ + Split text into segments alternating between markers and content. + + Args: + text: Input text with potential markers + + Returns: + List of tuples: [("marker"|"content", text), ...] + """ + # Combined pattern for all markers and timestamps + marker_pattern = re.compile( + r"(<]*>>|<]*>>|<]*>>|\d{1,2}:\d{2}:\d{2}(?:[.,]\d{1,3})?)" + ) + + segments = [] + last_end = 0 + + for match in marker_pattern.finditer(text): + # Content before marker + if match.start() > last_end: + segments.append(("content", text[last_end : match.start()])) + + # Marker itself + segments.append(("marker", match.group(0))) + last_end = match.end() + + # Remaining content after last marker + if last_end < len(text): + segments.append(("content", text[last_end:])) + + return segments + + +def apply_word_replacements(text, substitutions, case_sensitive=False): + """ + Apply word substitutions using whole-word matching. + + Args: + text: Input text + substitutions: List of (word, replacement) tuples + case_sensitive: If True, match case-sensitively + + Returns: + Text with substitutions applied + """ + for word, replacement in substitutions: + # Use word boundaries for exact matching + # Escape special regex characters + escaped_word = re.escape(word) + pattern = re.compile( + r"\b" + escaped_word + r"\b", + 0 if case_sensitive else re.IGNORECASE, + ) + text = pattern.sub(replacement, text) + + return text + + +def convert_all_caps_to_lowercase(text): + """ + Convert ALL CAPS words to lowercase. + + Args: + text: Input text + + Returns: + Text with ALL CAPS converted to lowercase + """ + + def replace_caps(match): + word = match.group(0) + # Convert to lowercase + return word.lower() + + # Match words that are ALL CAPS (2+ letters) + pattern = re.compile(r"\b[A-Z]{2,}\b") + return pattern.sub(replace_caps, text) + + +def convert_numerals_to_words(text): + """ + Convert numerals to words using num2words library. + + Args: + text: Input text + + Returns: + Text with numerals converted to words + """ + try: + from num2words import num2words + except ImportError: + # If num2words not available, return unchanged + return text + + def replace_number(match): + try: + number = int(match.group(0)) + # Convert to words in English + return num2words(number) + except Exception: + # If conversion fails, return original + return match.group(0) + + # Match integers (but not timestamps or other patterns) + # Negative lookbehind/ahead to avoid timestamps + pattern = re.compile(r"(?