mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 05:40:26 +02:00
Port voice marker and word substitution features to upstream refactored structure
The upstream project moved PyQt code to abogen/pyqt/ subdirectory, making the original feature commits non-mergeable. This commit re-applies both features to the new file locations. Voice Marker feature (<<VOICE:voice_name>> syntax): - subtitle_utils.py: Added _VOICE_MARKER_PATTERN, _VOICE_MARKER_SEARCH_PATTERN, validate_voice_name(), split_text_by_voice_markers() (with valid/invalid counts) - pyqt/conversion.py: Added load_voice_cached(), voice marker pre-processing before chapter loop, inner voice segment loop wrapping spaCy+TTS block, updated imports - pyqt/gui.py: Added Insert Voice Marker button and insert_voice_marker() to TextboxDialog Word Substitution feature (text preprocessing before TTS): - word_substitution.py: New module (word replacements, ALL CAPS, numerals, punctuation) - pyqt/conversion.py: apply_word_substitutions() call after clean_text() - pyqt/gui.py: WordSubstitutionsDialog, word_sub_combo, Settings button, on_word_sub_changed(), show_word_sub_dialog(), config persistence, queue restore - pyqt/queued_item.py: 6 new word substitution fields - pyqt/queue_manager_gui.py: 6 fields added to OVERRIDE_FIELDS and get_current_attributes() Note: num2words>=0.5.13 was already added to pyproject.toml by upstream. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
8322f7f416
commit
2223f46c9e
+356
-260
@@ -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,286 +1066,302 @@ class ConversionThread(QThread):
|
|||||||
chapter_subtitle_path = None
|
chapter_subtitle_path = None
|
||||||
chapter_subtitle_file = 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
|
# Process each voice segment within the chapter
|
||||||
if (
|
for segment_idx, (voice_name, segment_text) in enumerate(voice_segments):
|
||||||
use_spacy
|
# Load voice for this segment (with caching)
|
||||||
and self.lang_code in ["a", "b"]
|
try:
|
||||||
and self.subtitle_mode in ["Sentence", "Sentence + Comma"]
|
loaded_voice = self.load_voice_cached(voice_name, tts)
|
||||||
):
|
if segment_idx > 0:
|
||||||
from abogen.spacy_utils import get_spacy_model
|
voice_display = voice_name if len(voice_name) < 50 else voice_name[:47] + "..."
|
||||||
|
self.log_updated.emit((f" → Voice: {voice_display}", "grey"))
|
||||||
nlp = get_spacy_model(
|
except Exception:
|
||||||
self.lang_code,
|
|
||||||
log_callback=lambda msg: self.log_updated.emit(msg),
|
|
||||||
)
|
|
||||||
if nlp:
|
|
||||||
self.log_updated.emit(
|
self.log_updated.emit(
|
||||||
(
|
(f"⚠ Voice loading error for '{voice_name}', continuing with previous", "orange")
|
||||||
"\nUsing spaCy for sentence segmentation (only for subtitles)...",
|
|
||||||
"grey",
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
if segment_idx == 0:
|
||||||
|
loaded_voice = self.load_voice_cached(self.voice, tts)
|
||||||
|
|
||||||
if use_spacy and self.lang_code not in ["a", "b"]:
|
# Determine if spaCy segmentation should be used for PRE-TTS segmentation
|
||||||
# Non-English: use spaCy for pre-TTS segmentation
|
# Only non-English languages use spaCy for pre-segmentation
|
||||||
self.log_updated.emit(
|
# English uses spaCy only for subtitle generation (post-TTS)
|
||||||
("\nUsing spaCy for sentence segmentation (pre-TTS)...", "grey")
|
# 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
|
use_spacy = (
|
||||||
|
getattr(self, "use_spacy_segmentation", False)
|
||||||
spacy_sentences = segment_sentences(
|
and self.subtitle_mode not in ["Disabled", "Line"]
|
||||||
chapter_text,
|
and not is_subtitle_input
|
||||||
self.lang_code,
|
|
||||||
log_callback=lambda msg: self.log_updated.emit(msg),
|
|
||||||
)
|
)
|
||||||
if spacy_sentences:
|
spacy_sentences = None
|
||||||
self.log_updated.emit(
|
active_split_pattern = self.split_pattern
|
||||||
(
|
spacing_pattern = r"\s*" if self.lang_code in ["z", "j"] else r"\s+"
|
||||||
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")
|
|
||||||
)
|
|
||||||
|
|
||||||
# Process text - either as spaCy sentences or as single text
|
# Pre-load spaCy model for English if it will be needed for subtitle generation
|
||||||
text_segments = spacy_sentences if spacy_sentences else [chapter_text]
|
if (
|
||||||
|
use_spacy
|
||||||
# Print active split pattern used by the TTS engine once for this batch
|
and self.lang_code in ["a", "b"]
|
||||||
try:
|
and self.subtitle_mode in ["Sentence", "Sentence + Comma"]
|
||||||
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,
|
|
||||||
):
|
):
|
||||||
# Print the result for debugging
|
from abogen.spacy_utils import get_spacy_model
|
||||||
# print(f"Result: {result}")
|
|
||||||
if self.cancel_requested:
|
nlp = get_spacy_model(
|
||||||
if chapter_out_file:
|
self.lang_code,
|
||||||
chapter_out_file.close()
|
log_callback=lambda msg: self.log_updated.emit(msg),
|
||||||
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}"
|
|
||||||
)
|
)
|
||||||
|
if nlp:
|
||||||
|
self.log_updated.emit(
|
||||||
|
(
|
||||||
|
"\nUsing spaCy for sentence segmentation (only for subtitles)...",
|
||||||
|
"grey",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
chunk_dur = len(result.audio) / rate
|
if use_spacy and self.lang_code not in ["a", "b"]:
|
||||||
chunk_start = current_time
|
# Non-English: use spaCy for pre-TTS segmentation
|
||||||
# Write audio directly to merged file ONLY if merging
|
self.log_updated.emit(
|
||||||
if merge_chapters_at_end and merged_out_file:
|
("\nUsing spaCy for sentence segmentation (pre-TTS)...", "grey")
|
||||||
merged_out_file.write(result.audio)
|
)
|
||||||
elif merge_chapters_at_end and ffmpeg_proc:
|
from abogen.spacy_utils import segment_sentences
|
||||||
if hasattr(result.audio, "numpy"):
|
|
||||||
audio_bytes = (
|
spacy_sentences = segment_sentences(
|
||||||
result.audio.numpy().astype("float32").tobytes()
|
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:
|
else:
|
||||||
audio_bytes = result.audio.astype("float32").tobytes()
|
active_split_pattern = (
|
||||||
ffmpeg_proc.stdin.write(audio_bytes)
|
"\n" # Use newline splitting for Sentence mode
|
||||||
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:
|
else:
|
||||||
audio_bytes = result.audio.astype("float32").tobytes()
|
self.log_updated.emit(
|
||||||
chapter_ffmpeg_proc.stdin.write(audio_bytes)
|
("\nspaCy: Fallback to default segmentation...", "grey")
|
||||||
# Subtitle logic
|
)
|
||||||
if self.subtitle_mode != "Disabled":
|
|
||||||
tokens_list = getattr(result, "tokens", [])
|
|
||||||
|
|
||||||
# Fallback for languages without token support (non-English)
|
# Process text - either as spaCy sentences or as single text
|
||||||
# Create a single token representing the entire segment duration
|
text_segments = spacy_sentences if spacy_sentences else [segment_text]
|
||||||
if not tokens_list and result.graphemes:
|
|
||||||
|
|
||||||
class FakeToken:
|
# Print active split pattern used by the TTS engine once for this batch
|
||||||
def __init__(self, text, start, end):
|
try:
|
||||||
self.text = text
|
print(f"Using split pattern: {active_split_pattern!r}")
|
||||||
self.start_ts = start
|
except Exception:
|
||||||
self.end_ts = end
|
# Print must never break processing
|
||||||
self.whitespace = ""
|
print("Using split pattern: (unprintable)")
|
||||||
|
|
||||||
tokens_list = [
|
for text_segment in text_segments:
|
||||||
FakeToken(result.graphemes, 0, chunk_dur)
|
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 = []
|
chunk_dur = len(result.audio) / rate
|
||||||
chapter_tokens_with_timestamps = []
|
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
|
# Fallback for languages without token support (non-English)
|
||||||
for tok in tokens_list:
|
# Create a single token representing the entire segment duration
|
||||||
tokens_with_timestamps.append(
|
if not tokens_list and result.graphemes:
|
||||||
{
|
|
||||||
"start": chunk_start + (tok.start_ts or 0),
|
class FakeToken:
|
||||||
"end": chunk_start + (tok.end_ts or 0),
|
def __init__(self, text, start, end):
|
||||||
"text": tok.text,
|
self.text = text
|
||||||
"whitespace": tok.whitespace,
|
self.start_ts = start
|
||||||
}
|
self.end_ts = end
|
||||||
)
|
self.whitespace = ""
|
||||||
if chapter_out_file or chapter_ffmpeg_proc:
|
|
||||||
chapter_tokens_with_timestamps.append(
|
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
|
"start": chunk_start + (tok.start_ts or 0),
|
||||||
+ (tok.start_ts or 0),
|
"end": chunk_start + (tok.end_ts or 0),
|
||||||
"end": chapter_current_time
|
|
||||||
+ (tok.end_ts or 0),
|
|
||||||
"text": tok.text,
|
"text": tok.text,
|
||||||
"whitespace": tok.whitespace,
|
"whitespace": tok.whitespace,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
# Process tokens according to subtitle mode
|
if chapter_out_file or chapter_ffmpeg_proc:
|
||||||
# Global subtitle processing ONLY if merging
|
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:
|
if merge_chapters_at_end:
|
||||||
# Incremental subtitle writing for merged output
|
current_time += chunk_dur
|
||||||
new_entries = []
|
if chapter_out_file or chapter_ffmpeg_proc:
|
||||||
self._process_subtitle_tokens(
|
chapter_current_time += chunk_dur
|
||||||
tokens_with_timestamps,
|
else:
|
||||||
new_entries,
|
if chapter_out_file or chapter_ffmpeg_proc:
|
||||||
self.max_subtitle_words,
|
chapter_current_time += chunk_dur
|
||||||
fallback_end_time=chunk_start + chunk_dur,
|
# Calculate percentage based on characters processed
|
||||||
)
|
percent = min(
|
||||||
if merged_subtitle_file:
|
int(
|
||||||
subtitle_format = getattr(
|
self.processed_char_count / self.total_char_count * 100
|
||||||
self, "subtitle_format", "srt"
|
),
|
||||||
)
|
99,
|
||||||
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
|
|
||||||
)
|
)
|
||||||
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)
|
# Calculate ETR based on characters processed
|
||||||
self.progress_updated.emit(percent, etr_str)
|
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)
|
# Add silence between chapters for merged output (except after the last chapter)
|
||||||
if merge_chapters_at_end and chapter_idx < total_chapters:
|
if merge_chapters_at_end and chapter_idx < total_chapters:
|
||||||
|
|||||||
+240
-2
@@ -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,15 +2181,37 @@ 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.
|
||||||
self.config["replace_single_newlines"] = self.replace_single_newlines
|
self.config["replace_single_newlines"] = self.replace_single_newlines
|
||||||
self.config["subtitle_mode"] = self.subtitle_mode
|
self.config["subtitle_mode"] = self.subtitle_mode
|
||||||
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
|
||||||
if "selected_profile_name" in self.config:
|
if "selected_profile_name" in self.config:
|
||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user