Merge pull request #146 from olandir/131wVoiceTags

Voice Tags and Word Substitution Added to Main Script
This commit is contained in:
Deniz Şafak
2026-03-07 01:48:59 +03:00
committed by GitHub
7 changed files with 1004 additions and 264 deletions
+1
View File
@@ -38,3 +38,4 @@ dist/
.old/ .old/
test_assets/ test_assets/
dev_notes/ dev_notes/
.claude/
+104 -8
View File
@@ -42,6 +42,10 @@ from abogen.subtitle_utils import (
get_sample_voice_text, get_sample_voice_text,
sanitize_name_for_os, sanitize_name_for_os,
_CHAPTER_MARKER_SEARCH_PATTERN, _CHAPTER_MARKER_SEARCH_PATTERN,
_VOICE_MARKER_PATTERN,
_VOICE_MARKER_SEARCH_PATTERN,
split_text_by_voice_markers,
validate_voice_name,
) )
class CountdownDialog(QDialog): class CountdownDialog(QDialog):
@@ -296,6 +300,31 @@ class ConversionThread(QThread):
self.use_spacy_segmentation = True # Default, will be overridden from GUI self.use_spacy_segmentation = True # Default, will be overridden from GUI
# Set split pattern based on language and subtitle mode # Set split pattern based on language and subtitle mode
self.split_pattern = self._get_split_pattern(lang_code, 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( def _stream_audio_in_chunks(
self, segments, process_func, progress_prefix="Processing" self, segments, process_func, progress_prefix="Processing"
@@ -524,6 +553,26 @@ class ConversionThread(QThread):
# Clean up text using utility function # Clean up text using utility function
text = clean_text(text) 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 --- # --- Chapter splitting logic ---
# Use pre-compiled pattern for better performance # Use pre-compiled pattern for better performance
@@ -550,6 +599,42 @@ class ConversionThread(QThread):
chapters = [("text", text)] chapters = [("text", text)]
total_chapters = len(chapters) 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 # For text files with chapters, prompt user for options if not already set
is_txt_file = not self.is_direct_text and ( is_txt_file = not self.is_direct_text and (
self.file_name.lower().endswith(".txt") self.file_name.lower().endswith(".txt")
@@ -842,7 +927,7 @@ class ConversionThread(QThread):
] ]
srt_index = 1 # SRT numbering fix for chapter-only mode srt_index = 1 # SRT numbering fix for chapter-only mode
# Instead of processing the whole text, process by chapter # 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_path = None
chapter_out_file = None chapter_out_file = None
chapter_ffmpeg_proc = None chapter_ffmpeg_proc = None
@@ -862,11 +947,6 @@ class ConversionThread(QThread):
if merge_chapters_at_end: if merge_chapters_at_end:
chapter_time["start"] = current_time 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 # Prepare per-chapter output file if needed
if save_chapters_separately and total_chapters > 1: if save_chapters_separately and total_chapters > 1:
# First pass: keep alphanumeric, spaces, hyphens, and underscores # First pass: keep alphanumeric, spaces, hyphens, and underscores
@@ -986,6 +1066,22 @@ class ConversionThread(QThread):
chapter_subtitle_path = None chapter_subtitle_path = None
chapter_subtitle_file = None chapter_subtitle_file = None
# 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(
(f"⚠ Voice loading error for '{voice_name}', continuing with previous", "orange")
)
if segment_idx == 0:
loaded_voice = self.load_voice_cached(self.voice, tts)
# Determine if spaCy segmentation should be used for PRE-TTS segmentation # Determine if spaCy segmentation should be used for PRE-TTS segmentation
# Only non-English languages use spaCy for pre-segmentation # Only non-English languages use spaCy for pre-segmentation
# English uses spaCy only for subtitle generation (post-TTS) # English uses spaCy only for subtitle generation (post-TTS)
@@ -1034,7 +1130,7 @@ class ConversionThread(QThread):
from abogen.spacy_utils import segment_sentences from abogen.spacy_utils import segment_sentences
spacy_sentences = segment_sentences( spacy_sentences = segment_sentences(
chapter_text, segment_text,
self.lang_code, self.lang_code,
log_callback=lambda msg: self.log_updated.emit(msg), log_callback=lambda msg: self.log_updated.emit(msg),
) )
@@ -1060,7 +1156,7 @@ class ConversionThread(QThread):
) )
# Process text - either as spaCy sentences or as single text # Process text - either as spaCy sentences or as single text
text_segments = spacy_sentences if spacy_sentences else [chapter_text] text_segments = spacy_sentences if spacy_sentences else [segment_text]
# Print active split pattern used by the TTS engine once for this batch # Print active split pattern used by the TTS engine once for this batch
try: try:
+238
View File
@@ -665,6 +665,11 @@ class TextboxDialog(QDialog):
self.insert_chapter_btn.clicked.connect(self.insert_chapter_marker) self.insert_chapter_btn.clicked.connect(self.insert_chapter_marker)
button_layout.addWidget(self.insert_chapter_btn) 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 = QPushButton("Cancel", self)
self.cancel_button.clicked.connect(self.reject) self.cancel_button.clicked.connect(self.reject)
@@ -767,6 +772,23 @@ class TextboxDialog(QDialog):
self.update_char_count() self.update_char_count()
self.text_edit.setFocus() 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<<VOICE:{default_voice}>>\n")
self.text_edit.setTextCursor(cursor)
self.update_char_count()
self.text_edit.setFocus()
def migrate_subtitle_format(config): def migrate_subtitle_format(config):
"""Convert old subtitle_format values to new internal keys.""" """Convert old subtitle_format values to new internal keys."""
@@ -783,6 +805,108 @@ def migrate_subtitle_format(config):
save_config(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): class abogen(QWidget):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
@@ -833,6 +957,19 @@ class abogen(QWidget):
self.use_silent_gaps = self.config.get("use_silent_gaps", True) self.use_silent_gaps = self.config.get("use_silent_gaps", True)
self.subtitle_speed_method = self.config.get("subtitle_speed_method", "tts") self.subtitle_speed_method = self.config.get("subtitle_speed_method", "tts")
self.use_spacy_segmentation = self.config.get("use_spacy_segmentation", True) 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._pending_close_event = None
self.gpu_ok = False # Initialize GPU availability status self.gpu_ok = False # Initialize GPU availability status
@@ -1071,6 +1208,35 @@ class abogen(QWidget):
subtitle_layout.addWidget(self.subtitle_combo) subtitle_layout.addWidget(self.subtitle_combo)
controls_layout.addLayout(subtitle_layout) 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 # Output voice format
format_layout = QHBoxLayout() format_layout = QHBoxLayout()
format_layout.setSpacing(7) format_layout.setSpacing(7)
@@ -2015,6 +2181,21 @@ class abogen(QWidget):
self.subtitle_speed_method = getattr( self.subtitle_speed_method = getattr(
queued_item, "subtitle_speed_method", "tts" 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. # instead of using passed arguments, it sees the correct queue values.
@@ -2023,6 +2204,13 @@ class abogen(QWidget):
self.config["selected_format"] = self.selected_format self.config["selected_format"] = self.selected_format
self.config["use_silent_gaps"] = self.use_silent_gaps self.config["use_silent_gaps"] = self.use_silent_gaps
self.config["subtitle_speed_method"] = self.subtitle_speed_method 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 # Sync Voice/Profile in config
self.config["selected_voice"] = self.selected_voice self.config["selected_voice"] = self.selected_voice
@@ -2179,6 +2367,21 @@ class abogen(QWidget):
self.conversion_thread.subtitle_speed_method = self.subtitle_speed_method self.conversion_thread.subtitle_speed_method = self.subtitle_speed_method
# Pass use_spacy_segmentation setting # Pass use_spacy_segmentation setting
self.conversion_thread.use_spacy_segmentation = self.use_spacy_segmentation 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 # Pass separate_chapters_format setting
self.conversion_thread.separate_chapters_format = ( self.conversion_thread.separate_chapters_format = (
self.separate_chapters_format self.separate_chapters_format
@@ -2927,6 +3130,41 @@ class abogen(QWidget):
self.config["use_gpu"] = self.use_gpu self.config["use_gpu"] = self.use_gpu
save_config(self.config) 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): def cleanup_conversion_thread(self):
# Stop conversion thread # Stop conversion thread
if ( if (
+21
View File
@@ -35,6 +35,12 @@ OVERRIDE_FIELDS = [
"replace_single_newlines", "replace_single_newlines",
"use_silent_gaps", "use_silent_gaps",
"subtitle_speed_method", "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( attrs["subtitle_speed_method"] = getattr(
parent, "subtitle_speed_method", "tts" 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 # book handler options
attrs["save_chapters_separately"] = getattr( attrs["save_chapters_separately"] = getattr(
parent, "save_chapters_separately", None parent, "save_chapters_separately", None
+7
View File
@@ -19,3 +19,10 @@ class QueuedItem:
save_base_path: str = None save_base_path: str = None
save_chapters_separately: bool = None save_chapters_separately: bool = None
merge_chapters_at_end: 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
+125 -2
View File
@@ -15,6 +15,8 @@ _ASS_STYLING_PATTERN = re.compile(r"\{[^}]+\}")
_ASS_NEWLINE_N_PATTERN = re.compile(r"\\N") _ASS_NEWLINE_N_PATTERN = re.compile(r"\\N")
_ASS_NEWLINE_LOWER_N_PATTERN = re.compile(r"\\n") _ASS_NEWLINE_LOWER_N_PATTERN = re.compile(r"\\n")
_CHAPTER_MARKER_SEARCH_PATTERN = re.compile(r"<<CHAPTER_MARKER:(.*?)>>") _CHAPTER_MARKER_SEARCH_PATTERN = re.compile(r"<<CHAPTER_MARKER:(.*?)>>")
_VOICE_MARKER_PATTERN = re.compile(r"<<VOICE:[^>]*>>")
_VOICE_MARKER_SEARCH_PATTERN = re.compile(r"<<VOICE:(.*?)>>")
_WEBVTT_HEADER_PATTERN = re.compile(r"^WEBVTT.*?\n", re.MULTILINE) _WEBVTT_HEADER_PATTERN = re.compile(r"^WEBVTT.*?\n", re.MULTILINE)
_VTT_STYLE_PATTERN = re.compile(r"STYLE\s*\n.*?(?=\n\n|$)", re.DOTALL) _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) _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): 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 # Use pre-compiled patterns for better performance
text = _METADATA_TAG_PATTERN.sub("", text) text = _METADATA_TAG_PATTERN.sub("", text)
text = _CHAPTER_MARKER_PATTERN.sub("", text) text = _CHAPTER_MARKER_PATTERN.sub("", text)
text = _VOICE_MARKER_PATTERN.sub("", text)
return text.strip() return text.strip()
def calculate_text_length(text): def calculate_text_length(text):
# Use pre-compiled patterns for better performance # 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 = _CHAPTER_MARKER_PATTERN.sub("", text)
text = _VOICE_MARKER_PATTERN.sub("", text)
text = _METADATA_TAG_PATTERN.sub("", text) text = _METADATA_TAG_PATTERN.sub("", text)
# Ignore newlines and leading/trailing spaces # Ignore newlines and leading/trailing spaces
text = text.replace("\n", "").strip() text = text.replace("\n", "").strip()
@@ -459,3 +463,122 @@ def sanitize_name_for_os(name, is_folder=True):
sanitized = sanitized[:255].rstrip(". ") sanitized = sanitized[:255].rstrip(". ")
return sanitized 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 <<VOICE:name>> 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
+254
View File
@@ -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"(<<CHAPTER_MARKER:[^>]*>>|<<VOICE:[^>]*>>|<<METADATA_[^:]+:[^>]*>>|\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"(?<!\d:)\b\d+\b(?!:\d)")
return pattern.sub(replace_number, text)
def fix_punctuation(text):
"""
Convert nonstandard punctuation to standard equivalents.
This helps TTS engines pronounce words correctly by converting:
- Curly quotes to straight quotes
- Ellipsis to three periods
Args:
text: Input text
Returns:
Text with nonstandard punctuation fixed
"""
# Define replacements
replacements = {
# Curly double quotes
"\u201c": '"', # Left double quotation mark
"\u201d": '"', # Right double quotation mark
"\u201e": '"', # Double low-9 quotation mark
# Curly single quotes
"\u2018": "'", # Left single quotation mark
"\u2019": "'", # Right single quotation mark
"\u201a": "'", # Single low-9 quotation mark
"\u201b": "'", # Single high-reversed-9 quotation mark
# Other punctuation
"\u2026": "...", # Ellipsis
}
# Apply all replacements
for old_char, new_char in replacements.items():
text = text.replace(old_char, new_char)
return text