mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 21:50:28 +02:00
Compare commits
10
Commits
v1.3.1
...
9fa81fbe1e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fa81fbe1e | ||
|
|
9fd9fad238 | ||
|
|
ca5c5ee62d | ||
|
|
e51be95bc1 | ||
|
|
2223f46c9e | ||
|
|
8322f7f416 | ||
|
|
2c15d2f78a | ||
|
|
cc9c2a22ba | ||
|
|
d1366b445d | ||
|
|
c224cdbb56 |
@@ -0,0 +1,15 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [jborza, jeremiahsb, mohangk]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
@@ -38,3 +38,4 @@ dist/
|
||||
.old/
|
||||
test_assets/
|
||||
dev_notes/
|
||||
.claude/
|
||||
|
||||
@@ -915,7 +915,11 @@ class EpubParser(BaseBookParser):
|
||||
|
||||
if slice_html.strip():
|
||||
slice_soup = BeautifulSoup(slice_html, "html.parser")
|
||||
for tag in slice_soup.find_all(["p", "div"]):
|
||||
|
||||
# Add line breaks after block-level elements to ensure pauses in speech
|
||||
for tag in slice_soup.find_all(
|
||||
["p", "div", "h1", "h2", "h3", "h4", "h5", "h6", "li", "blockquote"]
|
||||
):
|
||||
tag.append("\n\n")
|
||||
|
||||
for ol in slice_soup.find_all("ol"):
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
"""Backwards-compatible re-export of conversion module.
|
||||
|
||||
The PyQt-based implementation lives in abogen.pyqt.conversion.
|
||||
The web-based implementation is in abogen.webui.conversion_runner.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Re-export PyQt conversion classes for backwards compatibility
|
||||
from abogen.pyqt.conversion import ( # noqa: F401
|
||||
ConversionThread,
|
||||
VoicePreviewThread,
|
||||
PlayAudioThread,
|
||||
)
|
||||
|
||||
__all__ = ["ConversionThread", "VoicePreviewThread", "PlayAudioThread"]
|
||||
+356
-260
@@ -42,6 +42,10 @@ from abogen.subtitle_utils import (
|
||||
get_sample_voice_text,
|
||||
sanitize_name_for_os,
|
||||
_CHAPTER_MARKER_SEARCH_PATTERN,
|
||||
_VOICE_MARKER_PATTERN,
|
||||
_VOICE_MARKER_SEARCH_PATTERN,
|
||||
split_text_by_voice_markers,
|
||||
validate_voice_name,
|
||||
)
|
||||
|
||||
class CountdownDialog(QDialog):
|
||||
@@ -296,6 +300,31 @@ class ConversionThread(QThread):
|
||||
self.use_spacy_segmentation = True # Default, will be overridden from GUI
|
||||
# Set split pattern based on language and subtitle mode
|
||||
self.split_pattern = self._get_split_pattern(lang_code, subtitle_mode)
|
||||
self.voice_cache = {} # Cache for loaded voices
|
||||
|
||||
def load_voice_cached(self, voice_name, tts):
|
||||
"""Load voice with caching to avoid reloading same voice.
|
||||
|
||||
Args:
|
||||
voice_name: Voice name or formula string
|
||||
tts: TTS pipeline instance
|
||||
|
||||
Returns:
|
||||
Loaded voice tensor or voice name string
|
||||
"""
|
||||
# Check cache first
|
||||
if voice_name in self.voice_cache:
|
||||
return self.voice_cache[voice_name]
|
||||
|
||||
# Load voice
|
||||
if "*" in voice_name:
|
||||
loaded_voice = get_new_voice(tts, voice_name, self.use_gpu)
|
||||
else:
|
||||
loaded_voice = voice_name
|
||||
|
||||
# Cache it
|
||||
self.voice_cache[voice_name] = loaded_voice
|
||||
return loaded_voice
|
||||
|
||||
def _stream_audio_in_chunks(
|
||||
self, segments, process_func, progress_prefix="Processing"
|
||||
@@ -524,6 +553,26 @@ class ConversionThread(QThread):
|
||||
# Clean up text using utility function
|
||||
text = clean_text(text)
|
||||
|
||||
# Apply word substitutions if enabled
|
||||
if getattr(self, "word_substitutions_enabled", False):
|
||||
from abogen.word_substitution import apply_word_substitutions
|
||||
|
||||
self.log_updated.emit("Applying word substitutions...")
|
||||
|
||||
substitutions_list = getattr(self, "word_substitutions_list", "")
|
||||
case_sensitive = getattr(self, "case_sensitive_substitutions", False)
|
||||
replace_caps = getattr(self, "replace_all_caps", False)
|
||||
replace_nums = getattr(self, "replace_numerals", False)
|
||||
fix_punct = getattr(self, "fix_nonstandard_punctuation", False)
|
||||
|
||||
text = apply_word_substitutions(
|
||||
text,
|
||||
substitutions_list,
|
||||
case_sensitive,
|
||||
replace_caps,
|
||||
replace_nums,
|
||||
fix_punct,
|
||||
)
|
||||
|
||||
# --- Chapter splitting logic ---
|
||||
# Use pre-compiled pattern for better performance
|
||||
@@ -550,6 +599,42 @@ class ConversionThread(QThread):
|
||||
chapters = [("text", text)]
|
||||
total_chapters = len(chapters)
|
||||
|
||||
# --- Voice marker splitting logic ---
|
||||
# Split each chapter by voice markers, preserving voice state across chapters
|
||||
chapters_with_voices = []
|
||||
current_voice = self.voice # Start with default voice
|
||||
total_valid_markers = 0
|
||||
total_invalid_markers = 0
|
||||
|
||||
for chapter_name, chapter_text in chapters:
|
||||
# Use current_voice as the starting voice for this chapter
|
||||
voice_segments, last_voice, valid_count, invalid_count = split_text_by_voice_markers(chapter_text, current_voice)
|
||||
chapters_with_voices.append((chapter_name, voice_segments))
|
||||
|
||||
# Update current_voice so next chapter continues with this voice
|
||||
current_voice = last_voice
|
||||
|
||||
# Track total valid/invalid markers
|
||||
total_valid_markers += valid_count
|
||||
total_invalid_markers += invalid_count
|
||||
|
||||
# Log voice marker information with accurate counts
|
||||
total_markers = total_valid_markers + total_invalid_markers
|
||||
if total_markers > 0:
|
||||
if total_invalid_markers == 0:
|
||||
# All markers were valid
|
||||
self.log_updated.emit(
|
||||
(f"\nDetected {total_markers} voice marker(s) - all valid", "grey")
|
||||
)
|
||||
else:
|
||||
# Some markers were invalid
|
||||
self.log_updated.emit(
|
||||
(f"\nDetected {total_markers} voice marker(s) - {total_valid_markers} valid, {total_invalid_markers} invalid (using previous voice)", "orange")
|
||||
)
|
||||
|
||||
# Replace chapters with the new structure
|
||||
chapters = chapters_with_voices
|
||||
|
||||
# For text files with chapters, prompt user for options if not already set
|
||||
is_txt_file = not self.is_direct_text and (
|
||||
self.file_name.lower().endswith(".txt")
|
||||
@@ -842,7 +927,7 @@ class ConversionThread(QThread):
|
||||
]
|
||||
srt_index = 1 # SRT numbering fix for chapter-only mode
|
||||
# Instead of processing the whole text, process by chapter
|
||||
for chapter_idx, (chapter_name, chapter_text) in enumerate(chapters, 1):
|
||||
for chapter_idx, (chapter_name, voice_segments) in enumerate(chapters, 1):
|
||||
chapter_out_path = None
|
||||
chapter_out_file = None
|
||||
chapter_ffmpeg_proc = None
|
||||
@@ -862,11 +947,6 @@ class ConversionThread(QThread):
|
||||
if merge_chapters_at_end:
|
||||
chapter_time["start"] = current_time
|
||||
|
||||
# Check if the voice is a formula and load it if necessary
|
||||
if "*" in self.voice:
|
||||
loaded_voice = get_new_voice(tts, self.voice, self.use_gpu)
|
||||
else:
|
||||
loaded_voice = self.voice
|
||||
# Prepare per-chapter output file if needed
|
||||
if save_chapters_separately and total_chapters > 1:
|
||||
# First pass: keep alphanumeric, spaces, hyphens, and underscores
|
||||
@@ -986,286 +1066,302 @@ class ConversionThread(QThread):
|
||||
chapter_subtitle_path = None
|
||||
chapter_subtitle_file = None
|
||||
|
||||
# Determine if spaCy segmentation should be used for PRE-TTS segmentation
|
||||
# Only non-English languages use spaCy for pre-segmentation
|
||||
# English uses spaCy only for subtitle generation (post-TTS)
|
||||
# spaCy is disabled when subtitle mode is "Disabled" or "Line"
|
||||
# spaCy is also disabled when input is a subtitle file
|
||||
is_subtitle_input = (
|
||||
not self.is_direct_text
|
||||
and self.file_name
|
||||
and os.path.splitext(self.file_name)[1].lower()
|
||||
in [".srt", ".ass", ".vtt"]
|
||||
)
|
||||
use_spacy = (
|
||||
getattr(self, "use_spacy_segmentation", False)
|
||||
and self.subtitle_mode not in ["Disabled", "Line"]
|
||||
and not is_subtitle_input
|
||||
)
|
||||
spacy_sentences = None
|
||||
active_split_pattern = self.split_pattern
|
||||
spacing_pattern = r"\s*" if self.lang_code in ["z", "j"] else r"\s+"
|
||||
|
||||
# Pre-load spaCy model for English if it will be needed for subtitle generation
|
||||
if (
|
||||
use_spacy
|
||||
and self.lang_code in ["a", "b"]
|
||||
and self.subtitle_mode in ["Sentence", "Sentence + Comma"]
|
||||
):
|
||||
from abogen.spacy_utils import get_spacy_model
|
||||
|
||||
nlp = get_spacy_model(
|
||||
self.lang_code,
|
||||
log_callback=lambda msg: self.log_updated.emit(msg),
|
||||
)
|
||||
if nlp:
|
||||
# Process each voice segment within the chapter
|
||||
for segment_idx, (voice_name, segment_text) in enumerate(voice_segments):
|
||||
# Load voice for this segment (with caching)
|
||||
try:
|
||||
loaded_voice = self.load_voice_cached(voice_name, tts)
|
||||
if segment_idx > 0:
|
||||
voice_display = voice_name if len(voice_name) < 50 else voice_name[:47] + "..."
|
||||
self.log_updated.emit((f" → Voice: {voice_display}", "grey"))
|
||||
except Exception:
|
||||
self.log_updated.emit(
|
||||
(
|
||||
"\nUsing spaCy for sentence segmentation (only for subtitles)...",
|
||||
"grey",
|
||||
)
|
||||
(f"⚠ Voice loading error for '{voice_name}', continuing with previous", "orange")
|
||||
)
|
||||
if segment_idx == 0:
|
||||
loaded_voice = self.load_voice_cached(self.voice, tts)
|
||||
|
||||
if use_spacy and self.lang_code not in ["a", "b"]:
|
||||
# Non-English: use spaCy for pre-TTS segmentation
|
||||
self.log_updated.emit(
|
||||
("\nUsing spaCy for sentence segmentation (pre-TTS)...", "grey")
|
||||
# Determine if spaCy segmentation should be used for PRE-TTS segmentation
|
||||
# Only non-English languages use spaCy for pre-segmentation
|
||||
# English uses spaCy only for subtitle generation (post-TTS)
|
||||
# spaCy is disabled when subtitle mode is "Disabled" or "Line"
|
||||
# spaCy is also disabled when input is a subtitle file
|
||||
is_subtitle_input = (
|
||||
not self.is_direct_text
|
||||
and self.file_name
|
||||
and os.path.splitext(self.file_name)[1].lower()
|
||||
in [".srt", ".ass", ".vtt"]
|
||||
)
|
||||
from abogen.spacy_utils import segment_sentences
|
||||
|
||||
spacy_sentences = segment_sentences(
|
||||
chapter_text,
|
||||
self.lang_code,
|
||||
log_callback=lambda msg: self.log_updated.emit(msg),
|
||||
use_spacy = (
|
||||
getattr(self, "use_spacy_segmentation", False)
|
||||
and self.subtitle_mode not in ["Disabled", "Line"]
|
||||
and not is_subtitle_input
|
||||
)
|
||||
if spacy_sentences:
|
||||
self.log_updated.emit(
|
||||
(
|
||||
f"\nspaCy: Text segmented into {len(spacy_sentences)} sentences...",
|
||||
"grey",
|
||||
)
|
||||
)
|
||||
# For Sentence + Comma mode, still split on commas within spaCy sentences
|
||||
if self.subtitle_mode == "Sentence + Comma":
|
||||
active_split_pattern = r"(?<=[{}]){}|\n+".format(
|
||||
self.PUNCTUATION_COMMAS, spacing_pattern
|
||||
)
|
||||
else:
|
||||
active_split_pattern = (
|
||||
"\n" # Use newline splitting for Sentence mode
|
||||
)
|
||||
else:
|
||||
self.log_updated.emit(
|
||||
("\nspaCy: Fallback to default segmentation...", "grey")
|
||||
)
|
||||
spacy_sentences = None
|
||||
active_split_pattern = self.split_pattern
|
||||
spacing_pattern = r"\s*" if self.lang_code in ["z", "j"] else r"\s+"
|
||||
|
||||
# Process text - either as spaCy sentences or as single text
|
||||
text_segments = spacy_sentences if spacy_sentences else [chapter_text]
|
||||
|
||||
# Print active split pattern used by the TTS engine once for this batch
|
||||
try:
|
||||
print(f"Using split pattern: {active_split_pattern!r}")
|
||||
except Exception:
|
||||
# Print must never break processing
|
||||
print("Using split pattern: (unprintable)")
|
||||
|
||||
for text_segment in text_segments:
|
||||
for result in tts(
|
||||
text_segment,
|
||||
voice=loaded_voice,
|
||||
speed=self.speed,
|
||||
split_pattern=active_split_pattern,
|
||||
# Pre-load spaCy model for English if it will be needed for subtitle generation
|
||||
if (
|
||||
use_spacy
|
||||
and self.lang_code in ["a", "b"]
|
||||
and self.subtitle_mode in ["Sentence", "Sentence + Comma"]
|
||||
):
|
||||
# Print the result for debugging
|
||||
# print(f"Result: {result}")
|
||||
if self.cancel_requested:
|
||||
if chapter_out_file:
|
||||
chapter_out_file.close()
|
||||
if merged_out_file:
|
||||
merged_out_file.close()
|
||||
self.conversion_finished.emit("Cancelled", None)
|
||||
return
|
||||
current_segment += 1
|
||||
grapheme_len = len(result.graphemes)
|
||||
self.processed_char_count += grapheme_len
|
||||
# Log progress with both character counts and the graphemes content
|
||||
self.log_updated.emit(
|
||||
f"\n{self.processed_char_count:,}/{self.total_char_count:,}: {result.graphemes}"
|
||||
from abogen.spacy_utils import get_spacy_model
|
||||
|
||||
nlp = get_spacy_model(
|
||||
self.lang_code,
|
||||
log_callback=lambda msg: self.log_updated.emit(msg),
|
||||
)
|
||||
if nlp:
|
||||
self.log_updated.emit(
|
||||
(
|
||||
"\nUsing spaCy for sentence segmentation (only for subtitles)...",
|
||||
"grey",
|
||||
)
|
||||
)
|
||||
|
||||
chunk_dur = len(result.audio) / rate
|
||||
chunk_start = current_time
|
||||
# Write audio directly to merged file ONLY if merging
|
||||
if merge_chapters_at_end and merged_out_file:
|
||||
merged_out_file.write(result.audio)
|
||||
elif merge_chapters_at_end and ffmpeg_proc:
|
||||
if hasattr(result.audio, "numpy"):
|
||||
audio_bytes = (
|
||||
result.audio.numpy().astype("float32").tobytes()
|
||||
if use_spacy and self.lang_code not in ["a", "b"]:
|
||||
# Non-English: use spaCy for pre-TTS segmentation
|
||||
self.log_updated.emit(
|
||||
("\nUsing spaCy for sentence segmentation (pre-TTS)...", "grey")
|
||||
)
|
||||
from abogen.spacy_utils import segment_sentences
|
||||
|
||||
spacy_sentences = segment_sentences(
|
||||
segment_text,
|
||||
self.lang_code,
|
||||
log_callback=lambda msg: self.log_updated.emit(msg),
|
||||
)
|
||||
if spacy_sentences:
|
||||
self.log_updated.emit(
|
||||
(
|
||||
f"\nspaCy: Text segmented into {len(spacy_sentences)} sentences...",
|
||||
"grey",
|
||||
)
|
||||
)
|
||||
# For Sentence + Comma mode, still split on commas within spaCy sentences
|
||||
if self.subtitle_mode == "Sentence + Comma":
|
||||
active_split_pattern = r"(?<=[{}]){}|\n+".format(
|
||||
self.PUNCTUATION_COMMAS, spacing_pattern
|
||||
)
|
||||
else:
|
||||
audio_bytes = result.audio.astype("float32").tobytes()
|
||||
ffmpeg_proc.stdin.write(audio_bytes)
|
||||
if chapter_out_file:
|
||||
chapter_out_file.write(result.audio)
|
||||
elif chapter_ffmpeg_proc:
|
||||
if hasattr(result.audio, "numpy"):
|
||||
audio_bytes = (
|
||||
result.audio.numpy().astype("float32").tobytes()
|
||||
active_split_pattern = (
|
||||
"\n" # Use newline splitting for Sentence mode
|
||||
)
|
||||
else:
|
||||
audio_bytes = result.audio.astype("float32").tobytes()
|
||||
chapter_ffmpeg_proc.stdin.write(audio_bytes)
|
||||
# Subtitle logic
|
||||
if self.subtitle_mode != "Disabled":
|
||||
tokens_list = getattr(result, "tokens", [])
|
||||
else:
|
||||
self.log_updated.emit(
|
||||
("\nspaCy: Fallback to default segmentation...", "grey")
|
||||
)
|
||||
|
||||
# Fallback for languages without token support (non-English)
|
||||
# Create a single token representing the entire segment duration
|
||||
if not tokens_list and result.graphemes:
|
||||
# Process text - either as spaCy sentences or as single text
|
||||
text_segments = spacy_sentences if spacy_sentences else [segment_text]
|
||||
|
||||
class FakeToken:
|
||||
def __init__(self, text, start, end):
|
||||
self.text = text
|
||||
self.start_ts = start
|
||||
self.end_ts = end
|
||||
self.whitespace = ""
|
||||
# Print active split pattern used by the TTS engine once for this batch
|
||||
try:
|
||||
print(f"Using split pattern: {active_split_pattern!r}")
|
||||
except Exception:
|
||||
# Print must never break processing
|
||||
print("Using split pattern: (unprintable)")
|
||||
|
||||
tokens_list = [
|
||||
FakeToken(result.graphemes, 0, chunk_dur)
|
||||
]
|
||||
for text_segment in text_segments:
|
||||
for result in tts(
|
||||
text_segment,
|
||||
voice=loaded_voice,
|
||||
speed=self.speed,
|
||||
split_pattern=active_split_pattern,
|
||||
):
|
||||
# Print the result for debugging
|
||||
# print(f"Result: {result}")
|
||||
if self.cancel_requested:
|
||||
if chapter_out_file:
|
||||
chapter_out_file.close()
|
||||
if merged_out_file:
|
||||
merged_out_file.close()
|
||||
self.conversion_finished.emit("Cancelled", None)
|
||||
return
|
||||
current_segment += 1
|
||||
grapheme_len = len(result.graphemes)
|
||||
self.processed_char_count += grapheme_len
|
||||
# Log progress with both character counts and the graphemes content
|
||||
self.log_updated.emit(
|
||||
f"\n{self.processed_char_count:,}/{self.total_char_count:,}: {result.graphemes}"
|
||||
)
|
||||
|
||||
tokens_with_timestamps = []
|
||||
chapter_tokens_with_timestamps = []
|
||||
chunk_dur = len(result.audio) / rate
|
||||
chunk_start = current_time
|
||||
# Write audio directly to merged file ONLY if merging
|
||||
if merge_chapters_at_end and merged_out_file:
|
||||
merged_out_file.write(result.audio)
|
||||
elif merge_chapters_at_end and ffmpeg_proc:
|
||||
if hasattr(result.audio, "numpy"):
|
||||
audio_bytes = (
|
||||
result.audio.numpy().astype("float32").tobytes()
|
||||
)
|
||||
else:
|
||||
audio_bytes = result.audio.astype("float32").tobytes()
|
||||
ffmpeg_proc.stdin.write(audio_bytes)
|
||||
if chapter_out_file:
|
||||
chapter_out_file.write(result.audio)
|
||||
elif chapter_ffmpeg_proc:
|
||||
if hasattr(result.audio, "numpy"):
|
||||
audio_bytes = (
|
||||
result.audio.numpy().astype("float32").tobytes()
|
||||
)
|
||||
else:
|
||||
audio_bytes = result.audio.astype("float32").tobytes()
|
||||
chapter_ffmpeg_proc.stdin.write(audio_bytes)
|
||||
# Subtitle logic
|
||||
if self.subtitle_mode != "Disabled":
|
||||
tokens_list = getattr(result, "tokens", [])
|
||||
|
||||
# Process every token, regardless of text or timestamps
|
||||
for tok in tokens_list:
|
||||
tokens_with_timestamps.append(
|
||||
{
|
||||
"start": chunk_start + (tok.start_ts or 0),
|
||||
"end": chunk_start + (tok.end_ts or 0),
|
||||
"text": tok.text,
|
||||
"whitespace": tok.whitespace,
|
||||
}
|
||||
)
|
||||
if chapter_out_file or chapter_ffmpeg_proc:
|
||||
chapter_tokens_with_timestamps.append(
|
||||
# Fallback for languages without token support (non-English)
|
||||
# Create a single token representing the entire segment duration
|
||||
if not tokens_list and result.graphemes:
|
||||
|
||||
class FakeToken:
|
||||
def __init__(self, text, start, end):
|
||||
self.text = text
|
||||
self.start_ts = start
|
||||
self.end_ts = end
|
||||
self.whitespace = ""
|
||||
|
||||
tokens_list = [
|
||||
FakeToken(result.graphemes, 0, chunk_dur)
|
||||
]
|
||||
|
||||
tokens_with_timestamps = []
|
||||
chapter_tokens_with_timestamps = []
|
||||
|
||||
# Process every token, regardless of text or timestamps
|
||||
for tok in tokens_list:
|
||||
tokens_with_timestamps.append(
|
||||
{
|
||||
"start": chapter_current_time
|
||||
+ (tok.start_ts or 0),
|
||||
"end": chapter_current_time
|
||||
+ (tok.end_ts or 0),
|
||||
"start": chunk_start + (tok.start_ts or 0),
|
||||
"end": chunk_start + (tok.end_ts or 0),
|
||||
"text": tok.text,
|
||||
"whitespace": tok.whitespace,
|
||||
}
|
||||
)
|
||||
# Process tokens according to subtitle mode
|
||||
# Global subtitle processing ONLY if merging
|
||||
if chapter_out_file or chapter_ffmpeg_proc:
|
||||
chapter_tokens_with_timestamps.append(
|
||||
{
|
||||
"start": chapter_current_time
|
||||
+ (tok.start_ts or 0),
|
||||
"end": chapter_current_time
|
||||
+ (tok.end_ts or 0),
|
||||
"text": tok.text,
|
||||
"whitespace": tok.whitespace,
|
||||
}
|
||||
)
|
||||
# Process tokens according to subtitle mode
|
||||
# Global subtitle processing ONLY if merging
|
||||
if merge_chapters_at_end:
|
||||
# Incremental subtitle writing for merged output
|
||||
new_entries = []
|
||||
self._process_subtitle_tokens(
|
||||
tokens_with_timestamps,
|
||||
new_entries,
|
||||
self.max_subtitle_words,
|
||||
fallback_end_time=chunk_start + chunk_dur,
|
||||
)
|
||||
if merged_subtitle_file:
|
||||
subtitle_format = getattr(
|
||||
self, "subtitle_format", "srt"
|
||||
)
|
||||
if "ass" in subtitle_format:
|
||||
for start, end, text in new_entries:
|
||||
start_time = self._ass_time(start)
|
||||
end_time = self._ass_time(end)
|
||||
# Use karaoke effect for highlighting mode
|
||||
effect = (
|
||||
"karaoke"
|
||||
if self.subtitle_mode
|
||||
== "Sentence + Highlighting"
|
||||
else ""
|
||||
)
|
||||
merged_subtitle_file.write(
|
||||
f"Dialogue: 0,{start_time},{end_time},Default,,{merged_subtitle_margin},{merged_subtitle_margin},0,{effect},{merged_subtitle_alignment_tag}{text}\n"
|
||||
)
|
||||
else:
|
||||
for entry in new_entries:
|
||||
start, end, text = entry
|
||||
merged_subtitle_file.write(
|
||||
f"{merged_srt_index}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n"
|
||||
)
|
||||
merged_srt_index += 1
|
||||
# Per-chapter subtitle processing for both file and ffmpeg_proc
|
||||
if chapter_out_file or chapter_ffmpeg_proc:
|
||||
new_chapter_entries = []
|
||||
self._process_subtitle_tokens(
|
||||
chapter_tokens_with_timestamps,
|
||||
new_chapter_entries,
|
||||
self.max_subtitle_words,
|
||||
fallback_end_time=chapter_current_time + chunk_dur,
|
||||
)
|
||||
if chapter_subtitle_file:
|
||||
subtitle_format = getattr(
|
||||
self, "subtitle_format", "srt"
|
||||
)
|
||||
if "ass" in subtitle_format:
|
||||
for start, end, text in new_chapter_entries:
|
||||
start_time = self._ass_time(start)
|
||||
end_time = self._ass_time(end)
|
||||
# Use karaoke effect for highlighting mode
|
||||
effect = (
|
||||
"karaoke"
|
||||
if self.subtitle_mode
|
||||
== "Sentence + Highlighting"
|
||||
else ""
|
||||
)
|
||||
chapter_subtitle_file.write(
|
||||
f"Dialogue: 0,{start_time},{end_time},Default,,{chapter_subtitle_margin},{chapter_subtitle_margin},0,{effect},{chapter_subtitle_alignment_tag}{text}\n"
|
||||
)
|
||||
else:
|
||||
for entry in new_chapter_entries:
|
||||
start, end, text = entry
|
||||
chapter_subtitle_file.write(
|
||||
f"{chapter_srt_index}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n"
|
||||
)
|
||||
chapter_srt_index += 1
|
||||
if merge_chapters_at_end:
|
||||
# Incremental subtitle writing for merged output
|
||||
new_entries = []
|
||||
self._process_subtitle_tokens(
|
||||
tokens_with_timestamps,
|
||||
new_entries,
|
||||
self.max_subtitle_words,
|
||||
fallback_end_time=chunk_start + chunk_dur,
|
||||
)
|
||||
if merged_subtitle_file:
|
||||
subtitle_format = getattr(
|
||||
self, "subtitle_format", "srt"
|
||||
)
|
||||
if "ass" in subtitle_format:
|
||||
for start, end, text in new_entries:
|
||||
start_time = self._ass_time(start)
|
||||
end_time = self._ass_time(end)
|
||||
# Use karaoke effect for highlighting mode
|
||||
effect = (
|
||||
"karaoke"
|
||||
if self.subtitle_mode
|
||||
== "Sentence + Highlighting"
|
||||
else ""
|
||||
)
|
||||
merged_subtitle_file.write(
|
||||
f"Dialogue: 0,{start_time},{end_time},Default,,{merged_subtitle_margin},{merged_subtitle_margin},0,{effect},{merged_subtitle_alignment_tag}{text}\n"
|
||||
)
|
||||
else:
|
||||
for entry in new_entries:
|
||||
start, end, text = entry
|
||||
merged_subtitle_file.write(
|
||||
f"{merged_srt_index}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n"
|
||||
)
|
||||
merged_srt_index += 1
|
||||
# Per-chapter subtitle processing for both file and ffmpeg_proc
|
||||
if chapter_out_file or chapter_ffmpeg_proc:
|
||||
new_chapter_entries = []
|
||||
self._process_subtitle_tokens(
|
||||
chapter_tokens_with_timestamps,
|
||||
new_chapter_entries,
|
||||
self.max_subtitle_words,
|
||||
fallback_end_time=chapter_current_time + chunk_dur,
|
||||
)
|
||||
if chapter_subtitle_file:
|
||||
subtitle_format = getattr(
|
||||
self, "subtitle_format", "srt"
|
||||
)
|
||||
if "ass" in subtitle_format:
|
||||
for start, end, text in new_chapter_entries:
|
||||
start_time = self._ass_time(start)
|
||||
end_time = self._ass_time(end)
|
||||
# Use karaoke effect for highlighting mode
|
||||
effect = (
|
||||
"karaoke"
|
||||
if self.subtitle_mode
|
||||
== "Sentence + Highlighting"
|
||||
else ""
|
||||
)
|
||||
chapter_subtitle_file.write(
|
||||
f"Dialogue: 0,{start_time},{end_time},Default,,{chapter_subtitle_margin},{chapter_subtitle_margin},0,{effect},{chapter_subtitle_alignment_tag}{text}\n"
|
||||
)
|
||||
else:
|
||||
for entry in new_chapter_entries:
|
||||
start, end, text = entry
|
||||
chapter_subtitle_file.write(
|
||||
f"{chapter_srt_index}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n"
|
||||
)
|
||||
chapter_srt_index += 1
|
||||
if merge_chapters_at_end:
|
||||
current_time += chunk_dur
|
||||
if chapter_out_file or chapter_ffmpeg_proc:
|
||||
chapter_current_time += chunk_dur
|
||||
else:
|
||||
if chapter_out_file or chapter_ffmpeg_proc:
|
||||
chapter_current_time += chunk_dur
|
||||
# Calculate percentage based on characters processed
|
||||
percent = min(
|
||||
int(
|
||||
self.processed_char_count / self.total_char_count * 100
|
||||
),
|
||||
99,
|
||||
)
|
||||
|
||||
# Calculate ETR based on characters processed
|
||||
etr_str = "Processing..."
|
||||
chars_done = self.processed_char_count
|
||||
elapsed = time.time() - self.etr_start_time
|
||||
|
||||
# Calculate ETR if enough data is available
|
||||
if (
|
||||
chars_done > 0 and elapsed > 0.5
|
||||
): # Check elapsed > 0.5 to avoid instability
|
||||
avg_time_per_char = elapsed / chars_done
|
||||
remaining = (
|
||||
self.total_char_count - self.processed_char_count
|
||||
current_time += chunk_dur
|
||||
if chapter_out_file or chapter_ffmpeg_proc:
|
||||
chapter_current_time += chunk_dur
|
||||
else:
|
||||
if chapter_out_file or chapter_ffmpeg_proc:
|
||||
chapter_current_time += chunk_dur
|
||||
# Calculate percentage based on characters processed
|
||||
percent = min(
|
||||
int(
|
||||
self.processed_char_count / self.total_char_count * 100
|
||||
),
|
||||
99,
|
||||
)
|
||||
if remaining > 0:
|
||||
secs = avg_time_per_char * remaining
|
||||
h = int(secs // 3600)
|
||||
m = int((secs % 3600) // 60)
|
||||
s = int(secs % 60)
|
||||
etr_str = f"{h:02d}:{m:02d}:{s:02d}"
|
||||
|
||||
# Update progress more frequently (after each result)
|
||||
self.progress_updated.emit(percent, etr_str)
|
||||
# Calculate ETR based on characters processed
|
||||
etr_str = "Processing..."
|
||||
chars_done = self.processed_char_count
|
||||
elapsed = time.time() - self.etr_start_time
|
||||
|
||||
# Calculate ETR if enough data is available
|
||||
if (
|
||||
chars_done > 0 and elapsed > 0.5
|
||||
): # Check elapsed > 0.5 to avoid instability
|
||||
avg_time_per_char = elapsed / chars_done
|
||||
remaining = (
|
||||
self.total_char_count - self.processed_char_count
|
||||
)
|
||||
if remaining > 0:
|
||||
secs = avg_time_per_char * remaining
|
||||
h = int(secs // 3600)
|
||||
m = int((secs % 3600) // 60)
|
||||
s = int(secs % 60)
|
||||
etr_str = f"{h:02d}:{m:02d}:{s:02d}"
|
||||
|
||||
# Update progress more frequently (after each result)
|
||||
self.progress_updated.emit(percent, etr_str)
|
||||
|
||||
# Add silence between chapters for merged output (except after the last chapter)
|
||||
if merge_chapters_at_end and chapter_idx < total_chapters:
|
||||
|
||||
+241
-7
@@ -74,7 +74,7 @@ from abogen.subtitle_utils import (
|
||||
calculate_text_length,
|
||||
)
|
||||
|
||||
from abogen.conversion import ConversionThread, VoicePreviewThread, PlayAudioThread
|
||||
from abogen.pyqt.conversion import ConversionThread, VoicePreviewThread, PlayAudioThread, ChapterOptionsDialog, TimestampDetectionDialog
|
||||
from abogen.pyqt.book_handler import HandlerDialog
|
||||
from abogen.constants import (
|
||||
PROGRAM_NAME,
|
||||
@@ -665,6 +665,11 @@ class TextboxDialog(QDialog):
|
||||
self.insert_chapter_btn.clicked.connect(self.insert_chapter_marker)
|
||||
button_layout.addWidget(self.insert_chapter_btn)
|
||||
|
||||
self.insert_voice_btn = QPushButton("Insert Voice Marker", self)
|
||||
self.insert_voice_btn.setToolTip("Insert a voice change marker at the cursor position")
|
||||
self.insert_voice_btn.clicked.connect(self.insert_voice_marker)
|
||||
button_layout.addWidget(self.insert_voice_btn)
|
||||
|
||||
self.cancel_button = QPushButton("Cancel", self)
|
||||
self.cancel_button.clicked.connect(self.reject)
|
||||
|
||||
@@ -767,6 +772,23 @@ class TextboxDialog(QDialog):
|
||||
self.update_char_count()
|
||||
self.text_edit.setFocus()
|
||||
|
||||
def insert_voice_marker(self):
|
||||
"""Insert a voice marker template at cursor position."""
|
||||
cursor = self.text_edit.textCursor()
|
||||
# Use the currently selected voice as the default
|
||||
try:
|
||||
parent_window = self.parent()
|
||||
if parent_window and hasattr(parent_window, 'selected_voice'):
|
||||
default_voice = parent_window.selected_voice or "af_heart"
|
||||
else:
|
||||
default_voice = "af_heart"
|
||||
except Exception:
|
||||
default_voice = "af_heart"
|
||||
cursor.insertText(f"\n<<VOICE:{default_voice}>>\n")
|
||||
self.text_edit.setTextCursor(cursor)
|
||||
self.update_char_count()
|
||||
self.text_edit.setFocus()
|
||||
|
||||
|
||||
def migrate_subtitle_format(config):
|
||||
"""Convert old subtitle_format values to new internal keys."""
|
||||
@@ -783,6 +805,108 @@ def migrate_subtitle_format(config):
|
||||
save_config(config)
|
||||
|
||||
|
||||
class WordSubstitutionsDialog(QDialog):
|
||||
"""Dialog for configuring word substitutions and text preprocessing options."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
initial_list="",
|
||||
initial_case_sensitive=False,
|
||||
initial_caps=False,
|
||||
initial_numerals=False,
|
||||
initial_punctuation=False,
|
||||
):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Word Substitutions Settings")
|
||||
self.setWindowFlags(
|
||||
Qt.WindowType.Window
|
||||
| Qt.WindowType.WindowCloseButtonHint
|
||||
| Qt.WindowType.WindowMaximizeButtonHint
|
||||
)
|
||||
self.resize(600, 500)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Instructions
|
||||
instructions = QLabel(
|
||||
"Enter word substitutions (one per line) in format: Word|NewWord\n"
|
||||
" - If nothing after |, the word will be erased completely\n"
|
||||
" - Substitutions match whole words only (e.g., \"tree\" won't match \"trees\" but will match \"tree's\")\n"
|
||||
" - By default, matching is case-insensitive (e.g., \"gonna\" matches \"Gonna\", \"GONNA\", etc.)",
|
||||
self,
|
||||
)
|
||||
instructions.setStyleSheet(
|
||||
"padding: 10px; background-color: #f0f0f0; border-radius: 5px;"
|
||||
)
|
||||
instructions.setWordWrap(True)
|
||||
layout.addWidget(instructions)
|
||||
|
||||
# Text edit area
|
||||
self.text_edit = QTextEdit(self)
|
||||
self.text_edit.setAcceptRichText(False)
|
||||
self.text_edit.setPlaceholderText("Word|NewWord")
|
||||
self.text_edit.setPlainText(initial_list)
|
||||
layout.addWidget(self.text_edit)
|
||||
|
||||
# Checkboxes
|
||||
self.case_sensitive_checkbox = QCheckBox(
|
||||
"Case-sensitive word matching", self
|
||||
)
|
||||
self.case_sensitive_checkbox.setChecked(initial_case_sensitive)
|
||||
layout.addWidget(self.case_sensitive_checkbox)
|
||||
|
||||
self.caps_checkbox = QCheckBox("Replace ALL CAPS with lowercase", self)
|
||||
self.caps_checkbox.setChecked(initial_caps)
|
||||
layout.addWidget(self.caps_checkbox)
|
||||
|
||||
self.numerals_checkbox = QCheckBox(
|
||||
"Replace Numerals with Words (e.g., 309 \u2192 three hundred and nine)", self
|
||||
)
|
||||
self.numerals_checkbox.setChecked(initial_numerals)
|
||||
layout.addWidget(self.numerals_checkbox)
|
||||
|
||||
self.punctuation_checkbox = QCheckBox(
|
||||
"Fix Nonstandard Punctuation (curly quotes and other Unicode punctuation that may affect how words sound)",
|
||||
self,
|
||||
)
|
||||
self.punctuation_checkbox.setChecked(initial_punctuation)
|
||||
layout.addWidget(self.punctuation_checkbox)
|
||||
|
||||
# Buttons
|
||||
button_layout = QHBoxLayout()
|
||||
self.cancel_button = QPushButton("Cancel", self)
|
||||
self.cancel_button.clicked.connect(self.reject)
|
||||
self.ok_button = QPushButton("OK", self)
|
||||
self.ok_button.setDefault(True)
|
||||
self.ok_button.clicked.connect(self.accept)
|
||||
|
||||
button_layout.addStretch()
|
||||
button_layout.addWidget(self.cancel_button)
|
||||
button_layout.addWidget(self.ok_button)
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
def get_substitutions_list(self):
|
||||
"""Get the substitutions list as plain text."""
|
||||
return self.text_edit.toPlainText()
|
||||
|
||||
def get_case_sensitive(self):
|
||||
"""Get whether case-sensitive matching is enabled."""
|
||||
return self.case_sensitive_checkbox.isChecked()
|
||||
|
||||
def get_replace_all_caps(self):
|
||||
"""Get whether ALL CAPS replacement is enabled."""
|
||||
return self.caps_checkbox.isChecked()
|
||||
|
||||
def get_replace_numerals(self):
|
||||
"""Get whether numeral-to-word conversion is enabled."""
|
||||
return self.numerals_checkbox.isChecked()
|
||||
|
||||
def get_fix_nonstandard_punctuation(self):
|
||||
"""Get whether nonstandard punctuation fixing is enabled."""
|
||||
return self.punctuation_checkbox.isChecked()
|
||||
|
||||
|
||||
class abogen(QWidget):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
@@ -833,6 +957,19 @@ class abogen(QWidget):
|
||||
self.use_silent_gaps = self.config.get("use_silent_gaps", True)
|
||||
self.subtitle_speed_method = self.config.get("subtitle_speed_method", "tts")
|
||||
self.use_spacy_segmentation = self.config.get("use_spacy_segmentation", True)
|
||||
# Word substitution settings
|
||||
self.word_substitutions_enabled = self.config.get(
|
||||
"word_substitutions_enabled", False
|
||||
)
|
||||
self.word_substitutions_list = self.config.get("word_substitutions_list", "")
|
||||
self.case_sensitive_substitutions = self.config.get(
|
||||
"case_sensitive_substitutions", False
|
||||
)
|
||||
self.replace_all_caps = self.config.get("replace_all_caps", False)
|
||||
self.replace_numerals = self.config.get("replace_numerals", False)
|
||||
self.fix_nonstandard_punctuation = self.config.get(
|
||||
"fix_nonstandard_punctuation", False
|
||||
)
|
||||
self._pending_close_event = None
|
||||
self.gpu_ok = False # Initialize GPU availability status
|
||||
|
||||
@@ -1071,6 +1208,35 @@ class abogen(QWidget):
|
||||
subtitle_layout.addWidget(self.subtitle_combo)
|
||||
controls_layout.addLayout(subtitle_layout)
|
||||
|
||||
# Word Substitutions section
|
||||
word_sub_layout = QHBoxLayout()
|
||||
word_sub_layout.setSpacing(7)
|
||||
word_sub_label = QLabel("Word Substitutions:", self)
|
||||
word_sub_layout.addWidget(word_sub_label)
|
||||
|
||||
self.word_sub_combo = QComboBox(self)
|
||||
self.word_sub_combo.addItems(["Disabled", "Enabled"])
|
||||
self.word_sub_combo.setStyleSheet(
|
||||
"QComboBox { min-height: 20px; padding: 6px 12px; }"
|
||||
)
|
||||
self.word_sub_combo.setSizePolicy(
|
||||
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
|
||||
)
|
||||
self.word_sub_combo.setCurrentText(
|
||||
"Enabled" if self.word_substitutions_enabled else "Disabled"
|
||||
)
|
||||
self.word_sub_combo.currentTextChanged.connect(self.on_word_sub_changed)
|
||||
word_sub_layout.addWidget(self.word_sub_combo)
|
||||
|
||||
self.btn_word_sub_settings = QPushButton("Settings", self)
|
||||
self.btn_word_sub_settings.setFixedSize(80, 36)
|
||||
self.btn_word_sub_settings.setStyleSheet("QPushButton { padding: 6px 12px; }")
|
||||
self.btn_word_sub_settings.clicked.connect(self.show_word_sub_dialog)
|
||||
self.btn_word_sub_settings.setEnabled(self.word_substitutions_enabled)
|
||||
word_sub_layout.addWidget(self.btn_word_sub_settings)
|
||||
|
||||
controls_layout.addLayout(word_sub_layout)
|
||||
|
||||
# Output voice format
|
||||
format_layout = QHBoxLayout()
|
||||
format_layout.setSpacing(7)
|
||||
@@ -2015,15 +2181,37 @@ class abogen(QWidget):
|
||||
self.subtitle_speed_method = getattr(
|
||||
queued_item, "subtitle_speed_method", "tts"
|
||||
)
|
||||
# Word substitution settings
|
||||
self.word_substitutions_enabled = getattr(
|
||||
queued_item, "word_substitutions_enabled", False
|
||||
)
|
||||
self.word_substitutions_list = getattr(
|
||||
queued_item, "word_substitutions_list", ""
|
||||
)
|
||||
self.case_sensitive_substitutions = getattr(
|
||||
queued_item, "case_sensitive_substitutions", False
|
||||
)
|
||||
self.replace_all_caps = getattr(queued_item, "replace_all_caps", False)
|
||||
self.replace_numerals = getattr(queued_item, "replace_numerals", False)
|
||||
self.fix_nonstandard_punctuation = getattr(
|
||||
queued_item, "fix_nonstandard_punctuation", False
|
||||
)
|
||||
|
||||
# This ensures that if conversion.py (or utils) reads from config/disk
|
||||
# This ensures that if conversion.py (or utils) reads from config/disk
|
||||
# instead of using passed arguments, it sees the correct queue values.
|
||||
self.config["replace_single_newlines"] = self.replace_single_newlines
|
||||
self.config["subtitle_mode"] = self.subtitle_mode
|
||||
self.config["selected_format"] = self.selected_format
|
||||
self.config["use_silent_gaps"] = self.use_silent_gaps
|
||||
self.config["subtitle_speed_method"] = self.subtitle_speed_method
|
||||
|
||||
# Word substitution settings
|
||||
self.config["word_substitutions_enabled"] = self.word_substitutions_enabled
|
||||
self.config["word_substitutions_list"] = self.word_substitutions_list
|
||||
self.config["case_sensitive_substitutions"] = self.case_sensitive_substitutions
|
||||
self.config["replace_all_caps"] = self.replace_all_caps
|
||||
self.config["replace_numerals"] = self.replace_numerals
|
||||
self.config["fix_nonstandard_punctuation"] = self.fix_nonstandard_punctuation
|
||||
|
||||
# Sync Voice/Profile in config
|
||||
self.config["selected_voice"] = self.selected_voice
|
||||
if "selected_profile_name" in self.config:
|
||||
@@ -2179,6 +2367,21 @@ class abogen(QWidget):
|
||||
self.conversion_thread.subtitle_speed_method = self.subtitle_speed_method
|
||||
# Pass use_spacy_segmentation setting
|
||||
self.conversion_thread.use_spacy_segmentation = self.use_spacy_segmentation
|
||||
# Pass word substitution settings
|
||||
self.conversion_thread.word_substitutions_enabled = (
|
||||
self.word_substitutions_enabled
|
||||
)
|
||||
self.conversion_thread.word_substitutions_list = (
|
||||
self.word_substitutions_list
|
||||
)
|
||||
self.conversion_thread.case_sensitive_substitutions = (
|
||||
self.case_sensitive_substitutions
|
||||
)
|
||||
self.conversion_thread.replace_all_caps = self.replace_all_caps
|
||||
self.conversion_thread.replace_numerals = self.replace_numerals
|
||||
self.conversion_thread.fix_nonstandard_punctuation = (
|
||||
self.fix_nonstandard_punctuation
|
||||
)
|
||||
# Pass separate_chapters_format setting
|
||||
self.conversion_thread.separate_chapters_format = (
|
||||
self.separate_chapters_format
|
||||
@@ -2927,6 +3130,41 @@ class abogen(QWidget):
|
||||
self.config["use_gpu"] = self.use_gpu
|
||||
save_config(self.config)
|
||||
|
||||
def on_word_sub_changed(self, text):
|
||||
"""Handle word substitution dropdown change."""
|
||||
self.word_substitutions_enabled = text == "Enabled"
|
||||
self.btn_word_sub_settings.setEnabled(self.word_substitutions_enabled)
|
||||
|
||||
# Save to config
|
||||
self.config["word_substitutions_enabled"] = self.word_substitutions_enabled
|
||||
save_config(self.config)
|
||||
|
||||
def show_word_sub_dialog(self):
|
||||
"""Show word substitutions settings dialog."""
|
||||
dialog = WordSubstitutionsDialog(
|
||||
self,
|
||||
initial_list=self.word_substitutions_list,
|
||||
initial_case_sensitive=self.case_sensitive_substitutions,
|
||||
initial_caps=self.replace_all_caps,
|
||||
initial_numerals=self.replace_numerals,
|
||||
initial_punctuation=self.fix_nonstandard_punctuation,
|
||||
)
|
||||
|
||||
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||||
self.word_substitutions_list = dialog.get_substitutions_list()
|
||||
self.case_sensitive_substitutions = dialog.get_case_sensitive()
|
||||
self.replace_all_caps = dialog.get_replace_all_caps()
|
||||
self.replace_numerals = dialog.get_replace_numerals()
|
||||
self.fix_nonstandard_punctuation = dialog.get_fix_nonstandard_punctuation()
|
||||
|
||||
# Save all settings to config
|
||||
self.config["word_substitutions_list"] = self.word_substitutions_list
|
||||
self.config["case_sensitive_substitutions"] = self.case_sensitive_substitutions
|
||||
self.config["replace_all_caps"] = self.replace_all_caps
|
||||
self.config["replace_numerals"] = self.replace_numerals
|
||||
self.config["fix_nonstandard_punctuation"] = self.fix_nonstandard_punctuation
|
||||
save_config(self.config)
|
||||
|
||||
def cleanup_conversion_thread(self):
|
||||
# Stop conversion thread
|
||||
if (
|
||||
@@ -2991,8 +3229,6 @@ class abogen(QWidget):
|
||||
"""Show dialog to ask user about chapter processing options when chapters are detected in a .txt file"""
|
||||
# Check if this is a timestamp detection (-1) or chapter detection
|
||||
if chapter_count == -1:
|
||||
from abogen.conversion import TimestampDetectionDialog
|
||||
|
||||
dialog = TimestampDetectionDialog(parent=self)
|
||||
dialog.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
|
||||
@@ -3007,8 +3243,6 @@ class abogen(QWidget):
|
||||
return
|
||||
|
||||
# Normal chapter detection
|
||||
from abogen.conversion import ChapterOptionsDialog
|
||||
|
||||
dialog = ChapterOptionsDialog(chapter_count, parent=self)
|
||||
dialog.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
|
||||
|
||||
@@ -35,6 +35,12 @@ OVERRIDE_FIELDS = [
|
||||
"replace_single_newlines",
|
||||
"use_silent_gaps",
|
||||
"subtitle_speed_method",
|
||||
"word_substitutions_enabled",
|
||||
"word_substitutions_list",
|
||||
"case_sensitive_substitutions",
|
||||
"replace_all_caps",
|
||||
"replace_numerals",
|
||||
"fix_nonstandard_punctuation",
|
||||
]
|
||||
|
||||
|
||||
@@ -474,6 +480,21 @@ class QueueManager(QDialog):
|
||||
attrs["subtitle_speed_method"] = getattr(
|
||||
parent, "subtitle_speed_method", "tts"
|
||||
)
|
||||
# word substitutions
|
||||
attrs["word_substitutions_enabled"] = getattr(
|
||||
parent, "word_substitutions_enabled", False
|
||||
)
|
||||
attrs["word_substitutions_list"] = getattr(
|
||||
parent, "word_substitutions_list", ""
|
||||
)
|
||||
attrs["case_sensitive_substitutions"] = getattr(
|
||||
parent, "case_sensitive_substitutions", False
|
||||
)
|
||||
attrs["replace_all_caps"] = getattr(parent, "replace_all_caps", False)
|
||||
attrs["replace_numerals"] = getattr(parent, "replace_numerals", False)
|
||||
attrs["fix_nonstandard_punctuation"] = getattr(
|
||||
parent, "fix_nonstandard_punctuation", False
|
||||
)
|
||||
# book handler options
|
||||
attrs["save_chapters_separately"] = getattr(
|
||||
parent, "save_chapters_separately", None
|
||||
|
||||
@@ -19,3 +19,10 @@ class QueuedItem:
|
||||
save_base_path: str = None
|
||||
save_chapters_separately: bool = None
|
||||
merge_chapters_at_end: bool = None
|
||||
# Word Substitution fields
|
||||
word_substitutions_enabled: bool = False
|
||||
word_substitutions_list: str = ""
|
||||
case_sensitive_substitutions: bool = False
|
||||
replace_all_caps: bool = False
|
||||
replace_numerals: bool = False
|
||||
fix_nonstandard_punctuation: bool = False
|
||||
|
||||
+125
-2
@@ -15,6 +15,8 @@ _ASS_STYLING_PATTERN = re.compile(r"\{[^}]+\}")
|
||||
_ASS_NEWLINE_N_PATTERN = re.compile(r"\\N")
|
||||
_ASS_NEWLINE_LOWER_N_PATTERN = re.compile(r"\\n")
|
||||
_CHAPTER_MARKER_SEARCH_PATTERN = re.compile(r"<<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)
|
||||
_VTT_STYLE_PATTERN = re.compile(r"STYLE\s*\n.*?(?=\n\n|$)", re.DOTALL)
|
||||
_VTT_NOTE_PATTERN = re.compile(r"NOTE\s*\n.*?(?=\n\n|$)", re.DOTALL)
|
||||
@@ -31,17 +33,19 @@ _LINUX_ILLEGAL_CHARS_PATTERN = re.compile(r"[/\x00]")
|
||||
|
||||
|
||||
def clean_subtitle_text(text):
|
||||
"""Remove chapter markers and metadata tags from subtitle text."""
|
||||
"""Remove chapter markers, voice markers, and metadata tags from subtitle text."""
|
||||
# Use pre-compiled patterns for better performance
|
||||
text = _METADATA_TAG_PATTERN.sub("", text)
|
||||
text = _CHAPTER_MARKER_PATTERN.sub("", text)
|
||||
text = _VOICE_MARKER_PATTERN.sub("", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def calculate_text_length(text):
|
||||
# Use pre-compiled patterns for better performance
|
||||
# Ignore chapter markers and metadata patterns in a single pass
|
||||
# Ignore chapter markers, voice markers, and metadata patterns in a single pass
|
||||
text = _CHAPTER_MARKER_PATTERN.sub("", text)
|
||||
text = _VOICE_MARKER_PATTERN.sub("", text)
|
||||
text = _METADATA_TAG_PATTERN.sub("", text)
|
||||
# Ignore newlines and leading/trailing spaces
|
||||
text = text.replace("\n", "").strip()
|
||||
@@ -459,3 +463,122 @@ def sanitize_name_for_os(name, is_folder=True):
|
||||
sanitized = sanitized[:255].rstrip(". ")
|
||||
|
||||
return sanitized
|
||||
|
||||
|
||||
def validate_voice_name(voice_name):
|
||||
"""Validate voice name against VOICES_INTERNAL list (case-insensitive).
|
||||
Handles both single voices and formulas like 'af_heart*0.5 + am_echo*0.5'.
|
||||
|
||||
Args:
|
||||
voice_name: Voice name or formula string to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, invalid_voice_name):
|
||||
- is_valid: True if all voices in the name/formula are valid
|
||||
- invalid_voice_name: The first invalid voice found, or None if all valid
|
||||
"""
|
||||
from abogen.constants import VOICES_INTERNAL
|
||||
|
||||
# Create case-insensitive lookup set (done once per call)
|
||||
voice_lookup_lower = {v.lower() for v in VOICES_INTERNAL}
|
||||
voice_name = voice_name.strip()
|
||||
|
||||
# Check if it's a formula (contains *)
|
||||
if "*" in voice_name:
|
||||
# Extract voice names from formula
|
||||
voices = voice_name.split("+")
|
||||
for term in voices:
|
||||
if "*" in term:
|
||||
base_voice = term.split("*")[0].strip()
|
||||
# Case-insensitive comparison
|
||||
if base_voice.lower() not in voice_lookup_lower:
|
||||
return False, base_voice
|
||||
return True, None
|
||||
else:
|
||||
# Single voice - case-insensitive comparison
|
||||
if voice_name.lower() not in voice_lookup_lower:
|
||||
return False, voice_name
|
||||
return True, None
|
||||
|
||||
|
||||
def split_text_by_voice_markers(text, default_voice):
|
||||
"""Split text by voice markers, returning list of (voice, text) tuples.
|
||||
|
||||
IMPORTANT: Returns the last voice used so it can persist across chapters.
|
||||
Voice names are normalized to lowercase to match VOICES_INTERNAL.
|
||||
|
||||
Args:
|
||||
text: Text potentially containing <<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
|
||||
|
||||
@@ -1023,8 +1023,13 @@ class EpubExtractor:
|
||||
if not html:
|
||||
return ""
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
for tag in soup.find_all(["p", "div"]):
|
||||
|
||||
# Add line breaks after block-level elements to ensure pauses in speech
|
||||
for tag in soup.find_all(
|
||||
["p", "div", "h1", "h2", "h3", "h4", "h5", "h6", "li", "blockquote"]
|
||||
):
|
||||
tag.append("\n\n")
|
||||
|
||||
for ol in soup.find_all("ol"):
|
||||
start_attr = ol.get("start")
|
||||
try:
|
||||
|
||||
@@ -17,10 +17,43 @@ _preview_pipeline_lock = threading.Lock()
|
||||
def _select_device() -> str:
|
||||
import platform
|
||||
|
||||
try:
|
||||
import torch # type: ignore[import-not-found]
|
||||
except Exception:
|
||||
return "cpu"
|
||||
|
||||
system = platform.system()
|
||||
if system == "Darwin" and platform.processor() == "arm":
|
||||
return "mps"
|
||||
return "cuda"
|
||||
try:
|
||||
if torch.backends.mps.is_available():
|
||||
return "mps"
|
||||
except Exception:
|
||||
pass
|
||||
return "cpu"
|
||||
|
||||
try:
|
||||
if torch.cuda.is_available():
|
||||
return "cuda"
|
||||
except Exception:
|
||||
pass
|
||||
return "cpu"
|
||||
|
||||
|
||||
def _resolve_pipeline(language: str, use_gpu: bool) -> Tuple[Any, bool]:
|
||||
devices: List[str] = ["cpu"]
|
||||
if use_gpu:
|
||||
preferred = _select_device()
|
||||
if preferred != "cpu":
|
||||
devices.insert(0, preferred)
|
||||
|
||||
last_error: Optional[Exception] = None
|
||||
for device in devices:
|
||||
try:
|
||||
return get_preview_pipeline(language, device), device != "cpu"
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
|
||||
raise RuntimeError("Preview pipeline is unavailable") from last_error
|
||||
|
||||
|
||||
def _to_float32(audio_segment) -> np.ndarray:
|
||||
@@ -115,15 +148,7 @@ def generate_preview_audio(
|
||||
total_steps=supertonic_total_steps,
|
||||
)
|
||||
else:
|
||||
device = "cpu"
|
||||
if use_gpu:
|
||||
try:
|
||||
device = _select_device()
|
||||
except Exception:
|
||||
device = "cpu"
|
||||
use_gpu = False
|
||||
|
||||
pipeline = get_preview_pipeline(language, device)
|
||||
pipeline, pipeline_uses_gpu = _resolve_pipeline(language, use_gpu)
|
||||
if pipeline is None:
|
||||
raise RuntimeError("Preview pipeline is unavailable")
|
||||
|
||||
@@ -131,7 +156,7 @@ def generate_preview_audio(
|
||||
if voice_spec and "*" in voice_spec:
|
||||
from abogen.voice_formulas import get_new_voice
|
||||
|
||||
voice_choice = get_new_voice(pipeline, voice_spec, use_gpu)
|
||||
voice_choice = get_new_voice(pipeline, voice_spec, pipeline_uses_gpu)
|
||||
|
||||
segments = pipeline(
|
||||
normalized_text,
|
||||
|
||||
@@ -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