Add spaCy support for improved sentence segmentation, possible fix for #91

This commit is contained in:
Deniz Şafak
2025-11-28 00:49:36 +03:00
parent 290c265d5e
commit 5ef2612df6
6 changed files with 537 additions and 244 deletions
+7
View File
@@ -1,7 +1,14 @@
# 1.2.4 (Pre-release) # 1.2.4 (Pre-release)
- **Subtitle generation is now available for all languages!** Abogen now supports subtitle generation for non-English languages using audio duration-based timing. Available modes include `Line`, `Sentence`, and `Sentence + Comma`. (Note: Word-level subtitle modes remain English-only due to Kokoro's timestamp token limitations.) - **Subtitle generation is now available for all languages!** Abogen now supports subtitle generation for non-English languages using audio duration-based timing. Available modes include `Line`, `Sentence`, and `Sentence + Comma`. (Note: Word-level subtitle modes remain English-only due to Kokoro's timestamp token limitations.)
- New option: **"Use spaCy for sentence segmentation"** You can now use [spaCy](https://spacy.io/) to automatically detect sentence boundaries and produce cleaner, more readable subtitles. Quick summary:
- **What it does:** Splits text into natural sentences so subtitle entries read better and align more naturally with speech.
- **Why this helps:** The previous punctuation-based splitting could break sentences incorrectly at common abbreviations (e.g. "Mr.", "Dr.", "Prof.") or initials, producing wrong subtitle breaks. spaCy avoids those false splits by using linguistic rules to detect real sentence boundaries.
- **For Non-English:** spaCy runs **before** audio generation to create better sentence chunks for TTS.
- **For English:** spaCy runs **during** subtitle generation to find accurate sentence breaks after TTS.
- **Note:** spaCy segmentation is only applied when subtitle mode is `Sentence` or `Sentence + Comma`. When turned off, it falls back to simple punctuation-based splitting.
- Added support for `.` separator in timestamps (e.g. `HH:MM:SS.ms`) for timestamp-based text files. - Added support for `.` separator in timestamps (e.g. `HH:MM:SS.ms`) for timestamp-based text files.
- Optimized regex compilation and eliminated busy-wait loops. - Optimized regex compilation and eliminated busy-wait loops.
- Possibly fixed `Silent truncation of long paragraphs` issue mentioned in [#91](https://github.com/denizsafak/abogen/issues/91) by [@xklzlxr](https://github.com/xklzlxr)
- Fixed unused regex patterns and variable naming conventions. - Fixed unused regex patterns and variable naming conventions.
- Improvements in code and documentation. - Improvements in code and documentation.
+2
View File
@@ -140,6 +140,7 @@ Heres Abogen in action: in this demo, it processes 3,000 characters of tex
| **Voice mixer** | Create custom voices by mixing different voice models with a profile system. See [Voice Mixer](#voice-mixer) for more details. | | **Voice mixer** | Create custom voices by mixing different voice models with a profile system. See [Voice Mixer](#voice-mixer) for more details. |
| **Voice preview** | Listen to the selected voice before processing. | | **Voice preview** | Listen to the selected voice before processing. |
| **Generate subtitles** | `Disabled`, `Line`, `Sentence`, `Sentence + Comma`, `Sentence + Highlighting`, `1 word`, `2 words`, `3 words`, etc. (Represents the number of words in each subtitle entry) | | **Generate subtitles** | `Disabled`, `Line`, `Sentence`, `Sentence + Comma`, `Sentence + Highlighting`, `1 word`, `2 words`, `3 words`, etc. (Represents the number of words in each subtitle entry) |
| **Use spaCy for sentence segmentation** | When this option is enabled, Abogen uses [spaCy](https://spacy.io/) to detect sentence boundaries more accurately. Previously, it only used punctuation marks (like periods, question marks, etc.) to split sentences, which could incorrectly cut off phrases like "Mr." or "Dr.". With spaCy, sentences are divided more accurately. For non-English text, spaCy runs **before** audio generation to create sentence chunks. For English text, spaCy runs **during** subtitle generation to improve timing and readability. spaCy is only used when subtitle mode is `Sentence` or `Sentence + Comma`. If you prefer the old punctuation splitting method, you can turn this option off. |
| **Output voice format** | `.WAV`, `.FLAC`, `.MP3`, `.OPUS (best compression)` and `M4B (with chapters)` | | **Output voice format** | `.WAV`, `.FLAC`, `.MP3`, `.OPUS (best compression)` and `M4B (with chapters)` |
| **Output subtitle format** | Configures the subtitle format as `SRT (standard)`, `ASS (wide)`, `ASS (narrow)`, `ASS (centered wide)`, or `ASS (centered narrow)`. | | **Output subtitle format** | Configures the subtitle format as `SRT (standard)`, `ASS (wide)`, `ASS (narrow)`, `ASS (centered wide)`, or `ASS (centered narrow)`. |
| **Replace single newlines with spaces** | Replaces single newlines with spaces in the text. This is useful for texts that have imaginary line breaks. | | **Replace single newlines with spaces** | Replaces single newlines with spaces in the text. This is useful for texts that have imaginary line breaks. |
@@ -476,6 +477,7 @@ Feel free to explore the code and make any changes you like.
## `Credits` ## `Credits`
- Abogen uses [Kokoro](https://github.com/hexgrad/kokoro) for its high-quality, natural-sounding text-to-speech synthesis. Huge thanks to the Kokoro team for making this possible. - Abogen uses [Kokoro](https://github.com/hexgrad/kokoro) for its high-quality, natural-sounding text-to-speech synthesis. Huge thanks to the Kokoro team for making this possible.
- Thanks to the [spaCy](https://spacy.io/) project for its sentence-segmentation tools, which help Abogen produce cleaner, more natural sentence segmentation.
- Thanks to [@wojiushixiaobai](https://github.com/wojiushixiaobai) for [Embedded Python](https://github.com/wojiushixiaobai/Python-Embed-Win64) packages. These modified packages include pip pre-installed, enabling Abogen to function as a standalone application without requiring users to separately install Python in Windows. - Thanks to [@wojiushixiaobai](https://github.com/wojiushixiaobai) for [Embedded Python](https://github.com/wojiushixiaobai/Python-Embed-Win64) packages. These modified packages include pip pre-installed, enabling Abogen to function as a standalone application without requiring users to separately install Python in Windows.
- Thanks to creators of [EbookLib](https://github.com/aerkalov/ebooklib), a Python library for reading and writing ePub files, which is used for extracting text from ePub files. - Thanks to creators of [EbookLib](https://github.com/aerkalov/ebooklib), a Python library for reading and writing ePub files, which is used for extracting text from ePub files.
- Special thanks to the [PyQt](https://www.riverbankcomputing.com/software/pyqt/) team for providing the cross-platform GUI toolkit that powers Abogen's interface. - Special thanks to the [PyQt](https://www.riverbankcomputing.com/software/pyqt/) team for providing the cross-platform GUI toolkit that powers Abogen's interface.
+361 -240
View File
@@ -610,24 +610,42 @@ class ConversionThread(QThread):
log_updated = pyqtSignal(object) # Updated signal for log updates log_updated = pyqtSignal(object) # Updated signal for log updates
chapters_detected = pyqtSignal(int) # Signal for chapter detection chapters_detected = pyqtSignal(int) # Signal for chapter detection
# Default split pattern for TTS processing # Punctuation constants for unified handling across languages
DEFAULT_SPLIT_PATTERN = r"\n+" PUNCTUATION_SENTENCE = ".!?।。!?"
PUNCTUATION_SENTENCE_COMMA = ".!?,।。!?、,"
PUNCTUATION_COMMAS = ",,、"
# Languages that should not use split pattern (better handled by Kokoro internally) def _get_split_pattern(self, lang_code, subtitle_mode):
# These languages have different text segmentation rules (no spaces, character-based, etc.) """
NO_SPLIT_LANGUAGES = {"z", "j"} # Chinese, Japanese Get the appropriate split pattern based on language and subtitle mode.
# Language-specific punctuation patterns for subtitle splitting Args:
LANGUAGE_PUNCTUATION = { lang_code: Language code (a, b, e, f, etc.)
"z": { subtitle_mode: Subtitle mode ("Sentence", "Sentence + Comma", "Line", etc.)
"sentence": r"[。!?]", # Chinese: period, exclamation, question
"comma": r"[。!?、,]", # Chinese: includes enumeration comma and comma Returns:
}, Split pattern string
"j": { """
"sentence": r"[。!?]", # Japanese: period, exclamation, question # For English, always use newline splitting only
"comma": r"[。!?、,]", # Japanese: includes enumeration comma and comma if lang_code in ["a", "b"]:
}, return "\n"
}
# Determine spacing pattern based on language
spacing_pattern = r"\s*" if lang_code in ["z", "j"] else r"\s+"
# For Chinese/Japanese, when subtitle mode is Disabled or Line, prefer
# punctuation-based splitting instead of plain newline splitting.
if subtitle_mode in ("Disabled", "Line") and lang_code in ["z", "j"]:
return r"(?<=[{}]){}|\n+".format(self.PUNCTUATION_SENTENCE, spacing_pattern)
if subtitle_mode == "Line":
return "\n"
elif subtitle_mode == "Sentence":
return r"(?<=[{}]){}|\n+".format(self.PUNCTUATION_SENTENCE, spacing_pattern)
elif subtitle_mode == "Sentence + Comma":
return r"(?<=[{}]){}|\n+".format(self.PUNCTUATION_SENTENCE_COMMA, spacing_pattern)
else:
return r"\n+" # Default to line breaks
def __init__( def __init__(
self, self,
@@ -677,31 +695,9 @@ class ConversionThread(QThread):
self.use_gpu = use_gpu # Store the GPU setting self.use_gpu = use_gpu # Store the GPU setting
self.max_subtitle_words = 50 # Default value, will be overridden from GUI self.max_subtitle_words = 50 # Default value, will be overridden from GUI
self.silence_duration = 2.0 # Default value, will be overridden from GUI self.silence_duration = 2.0 # Default value, will be overridden from GUI
# Set split pattern based on language - some languages handle splitting better internally self.use_spacy_segmentation = True # Default, will be overridden from GUI
self.split_pattern = ( # Set split pattern based on language and subtitle mode
None if lang_code in self.NO_SPLIT_LANGUAGES else self.DEFAULT_SPLIT_PATTERN self.split_pattern = self._get_split_pattern(lang_code, subtitle_mode)
)
# Override split pattern for non-English languages when sentence-based subtitles are requested
# This ensures we get sentence-level audio chunks since we don't have word-level timestamps
if (
self.subtitle_mode in ["Sentence", "Sentence + Comma"]
and lang_code not in ["a", "b"]
):
if lang_code in self.NO_SPLIT_LANGUAGES:
# Split by CJK punctuation (keeping it)
if self.subtitle_mode == "Sentence + Comma":
# Use comma pattern if available
self.split_pattern = r"(?<=[。!?、,])"
else:
self.split_pattern = r"(?<=[。!?])"
else:
# Split by sentence endings (keeping punctuation) or newlines
if self.subtitle_mode == "Sentence + Comma":
# Include commas in split pattern
self.split_pattern = r"(?<=[.!?,])\s+|\n+"
else:
self.split_pattern = r"(?<=[.!?])\s+|\n+"
def _stream_audio_in_chunks( def _stream_audio_in_chunks(
self, segments, process_func, progress_prefix="Processing" self, segments, process_func, progress_prefix="Processing"
@@ -721,7 +717,7 @@ class ConversionThread(QThread):
total_samples = sum(len(segment) for segment in segments) total_samples = sum(len(segment) for segment in segments)
samples_processed = 0 samples_processed = 0
self.log_updated.emit(f"\n{progress_prefix} segments...") self.log_updated.emit((f"\n{progress_prefix} segments...", "grey"))
# Stream each segment individually # Stream each segment individually
for i, segment in enumerate(segments): for i, segment in enumerate(segments):
@@ -749,7 +745,7 @@ class ConversionThread(QThread):
# Clear segment bytes from memory # Clear segment bytes from memory
del segment_bytes del segment_bytes
except Exception as e: except Exception as e:
self.log_updated.emit(f"Error processing segment {i}: {str(e)}") self.log_updated.emit((f"Error processing segment {i}: {str(e)}", "red"))
raise raise
return samples_processed return samples_processed
@@ -807,6 +803,7 @@ class ConversionThread(QThread):
self.log_updated.emit( self.log_updated.emit(
f"- Subtitle format: {next((label for value, label in SUBTITLE_FORMATS if value == getattr(self, 'subtitle_format', 'srt')), getattr(self, 'subtitle_format', 'srt'))}" f"- Subtitle format: {next((label for value, label in SUBTITLE_FORMATS if value == getattr(self, 'subtitle_format', 'srt')), getattr(self, 'subtitle_format', 'srt'))}"
) )
self.log_updated.emit(f"- Use spaCy for sentence segmentation: {'Yes' if getattr(self, 'use_spacy_segmentation', False) else 'No'}")
self.log_updated.emit(f"- Save option: {self.save_option}") self.log_updated.emit(f"- Save option: {self.save_option}")
if self.replace_single_newlines: if self.replace_single_newlines:
self.log_updated.emit(f"- Replace single newlines: Yes") self.log_updated.emit(f"- Replace single newlines: Yes")
@@ -860,7 +857,7 @@ class ConversionThread(QThread):
f"- Output folder: {self.output_folder or os.getcwd()}" f"- Output folder: {self.output_folder or os.getcwd()}"
) )
self.log_updated.emit("\nInitializing TTS pipeline...") self.log_updated.emit(("\nInitializing TTS pipeline...", "grey"))
# Set device based on use_gpu setting and platform # Set device based on use_gpu setting and platform
if self.use_gpu: if self.use_gpu:
@@ -887,7 +884,7 @@ class ConversionThread(QThread):
) )
elif file_ext == ".txt" and detect_timestamps_in_text(self.file_name): elif file_ext == ".txt" and detect_timestamps_in_text(self.file_name):
is_timestamp_text = True is_timestamp_text = True
self.log_updated.emit("\nDetected timestamps in text file") self.log_updated.emit(("\nDetected timestamps in text file", "grey"))
# Signal to ask user (-1 indicates timestamp detection) # Signal to ask user (-1 indicates timestamp detection)
self.chapters_detected.emit(-1) self.chapters_detected.emit(-1)
# Wait for user response using event with timeout for responsive cancellation # Wait for user response using event with timeout for responsive cancellation
@@ -983,7 +980,7 @@ class ConversionThread(QThread):
(f"\nDetected chapters ({total_chapters}):\n" + chapter_list) (f"\nDetected chapters ({total_chapters}):\n" + chapter_list)
) )
else: else:
self.log_updated.emit((f"\nProcessing {chapters[0][0]}...")) self.log_updated.emit((f"\nProcessing {chapters[0][0]}...", "grey"))
# If save_chapters_separately is enabled, find a unique suffix ONCE and use for both folder and merged file # If save_chapters_separately is enabled, find a unique suffix ONCE and use for both folder and merged file
save_chapters_separately = getattr(self, "save_chapters_separately", False) save_chapters_separately = getattr(self, "save_chapters_separately", False)
@@ -1383,194 +1380,251 @@ class ConversionThread(QThread):
else: else:
chapter_subtitle_path = None chapter_subtitle_path = None
chapter_subtitle_file = None chapter_subtitle_file = None
for result in tts(
chapter_text, # Determine if spaCy segmentation should be used for PRE-TTS segmentation
voice=loaded_voice, # Only non-English languages use spaCy for pre-segmentation
speed=self.speed, # English uses spaCy only for subtitle generation (post-TTS)
split_pattern=self.split_pattern, # spaCy is disabled when subtitle mode is "Disabled" or "Line"
): # spaCy is also disabled when input is a subtitle file
# Print the result for debugging is_subtitle_input = (
# print(f"Result: {result}") not self.is_direct_text
if self.cancel_requested: and self.file_name
if chapter_out_file: and os.path.splitext(self.file_name)[1].lower() in [".srt", ".ass", ".vtt"]
chapter_out_file.close() )
if merged_out_file: use_spacy = (
merged_out_file.close() getattr(self, "use_spacy_segmentation", False)
self.conversion_finished.emit("Cancelled", None) and self.subtitle_mode not in ["Disabled", "Line"]
return and not is_subtitle_input
current_segment += 1 )
grapheme_len = len(result.graphemes) spacy_sentences = None
self.processed_char_count += grapheme_len active_split_pattern = self.split_pattern
# Log progress with both character counts and the graphemes content spacing_pattern = r"\s*" if self.lang_code in ["z", "j"] else r"\s+"
self.log_updated.emit(
f"\n{self.processed_char_count:,}/{self.total_char_count:,}: {result.graphemes}" # 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:
self.log_updated.emit(("\nUsing spaCy for sentence segmentation (only for subtitles)...", "grey"))
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(
chapter_text,
self.lang_code,
log_callback=lambda msg: self.log_updated.emit(msg)
) )
if spacy_sentences:
chunk_dur = len(result.audio) / rate self.log_updated.emit((f"\nspaCy: Text segmented into {len(spacy_sentences)} sentences...", "grey"))
chunk_start = current_time # For Sentence + Comma mode, still split on commas within spaCy sentences
# Write audio directly to merged file ONLY if merging if self.subtitle_mode == "Sentence + Comma":
if merge_chapters_at_end and merged_out_file: active_split_pattern = r"(?<=[{}]){}|\n+".format(self.PUNCTUATION_COMMAS, spacing_pattern)
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: else:
audio_bytes = result.audio.astype("float32").tobytes() active_split_pattern = "\n" # Use newline splitting for Sentence mode
ffmpeg_proc.stdin.write(audio_bytes) else:
if chapter_out_file: self.log_updated.emit(("\nspaCy: Fallback to default segmentation...", "grey"))
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", [])
# 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 [chapter_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 = [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 = [] 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(self.processed_char_count / self.total_char_count * 100), 99
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 # Calculate ETR based on characters processed
etr_str = "Processing..." etr_str = "Processing..."
chars_done = self.processed_char_count chars_done = self.processed_char_count
elapsed = time.time() - self.etr_start_time elapsed = time.time() - self.etr_start_time
# Calculate ETR if enough data is available # Calculate ETR if enough data is available
if ( if (
chars_done > 0 and elapsed > 0.5 chars_done > 0 and elapsed > 0.5
): # Check elapsed > 0.5 to avoid instability ): # Check elapsed > 0.5 to avoid instability
avg_time_per_char = elapsed / chars_done avg_time_per_char = elapsed / chars_done
remaining = self.total_char_count - self.processed_char_count remaining = self.total_char_count - self.processed_char_count
if remaining > 0: if remaining > 0:
secs = avg_time_per_char * remaining secs = avg_time_per_char * remaining
h = int(secs // 3600) h = int(secs // 3600)
m = int((secs % 3600) // 60) m = int((secs % 3600) // 60)
s = int(secs % 60) s = int(secs % 60)
etr_str = f"{h:02d}:{m:02d}:{s:02d}" etr_str = f"{h:02d}:{m:02d}:{s:02d}"
# Update progress more frequently (after each result) # Update progress more frequently (after each result)
self.progress_updated.emit(percent, etr_str) 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:
@@ -1764,7 +1818,7 @@ class ConversionThread(QThread):
) )
return return
self.log_updated.emit(f"\nFound {len(subtitles)} subtitle entries") self.log_updated.emit((f"\nFound {len(subtitles)} subtitle entries", "grey"))
# Setup output paths # Setup output paths
base_name = os.path.splitext(os.path.basename(base_path))[0] base_name = os.path.splitext(os.path.basename(base_path))[0]
@@ -2350,16 +2404,19 @@ class ConversionThread(QThread):
processed_tokens = tokens_with_timestamps # Use tokens directly processed_tokens = tokens_with_timestamps # Use tokens directly
# For English with spaCy enabled and sentence-based modes, use spaCy for sentence boundaries
# spaCy is disabled when subtitle mode is "Disabled" or "Line"
use_spacy_for_english = (
getattr(self, "use_spacy_segmentation", False)
and self.subtitle_mode not in ["Disabled", "Line"]
and self.lang_code in ["a", "b"]
and self.subtitle_mode in ["Sentence", "Sentence + Comma"]
)
# Use processed_tokens instead of tokens_with_timestamps for the rest of the method # Use processed_tokens instead of tokens_with_timestamps for the rest of the method
if self.subtitle_mode == "Sentence + Highlighting": if self.subtitle_mode == "Sentence + Highlighting":
# Sentence-based processing with karaoke highlighting # Sentence-based processing with karaoke highlighting
# Use language-specific punctuation for CJK languages (without comma) # Use punctuation without comma
lang_punct = self.LANGUAGE_PUNCTUATION.get(self.lang_code, {}) separator = r"[{}]".format(self.PUNCTUATION_SENTENCE)
separator = (
lang_punct.get("sentence", r"[.!?]")
if isinstance(lang_punct, dict)
else r"[.!?]"
)
current_sentence = [] current_sentence = []
word_count = 0 word_count = 0
@@ -2416,25 +2473,89 @@ class ConversionThread(QThread):
subtitle_entries[-1] = (start, fallback_end_time, text) subtitle_entries[-1] = (start, fallback_end_time, text)
elif self.subtitle_mode in ["Sentence", "Sentence + Comma", "Line"]: elif self.subtitle_mode in ["Sentence", "Sentence + Comma", "Line"]:
# Check if we should use spaCy for English sentence boundaries
if use_spacy_for_english and self.subtitle_mode != "Line":
# Use spaCy for English sentence boundary detection (model already loaded)
from abogen.spacy_utils import get_spacy_model
nlp = get_spacy_model(self.lang_code) # No log_callback since model is already loaded
if nlp:
# Build full text and track character positions to token indices
full_text = ""
char_to_token = [] # Maps character index to token index
for idx, token in enumerate(processed_tokens):
start_char = len(full_text)
text_part = token["text"] + (token.get("whitespace", "") or "")
full_text += text_part
char_to_token.extend([idx] * len(text_part))
# Get sentence boundaries from spaCy
doc = nlp(full_text)
sentence_boundaries = [sent.end_char for sent in doc.sents]
# For "Sentence + Comma" mode, also split on commas
if self.subtitle_mode == "Sentence + Comma":
comma_positions = [i + 1 for i, c in enumerate(full_text) if c == ',']
sentence_boundaries = sorted(set(sentence_boundaries + comma_positions))
# Group tokens by sentence boundaries
current_sentence = []
word_count = 0
current_char_pos = 0
boundary_idx = 0
for idx, token in enumerate(processed_tokens):
current_sentence.append(token)
word_count += 1
text_len = len(token["text"]) + len(token.get("whitespace", "") or "")
current_char_pos += text_len
# Check if we've hit a sentence boundary or max words
at_boundary = (
boundary_idx < len(sentence_boundaries)
and current_char_pos >= sentence_boundaries[boundary_idx]
)
if at_boundary or word_count >= max_subtitle_words:
if current_sentence:
start_time = current_sentence[0]["start"]
end_time = current_sentence[-1]["end"]
sentence_text = "".join(
t["text"] + (t.get("whitespace", "") or "")
for t in current_sentence
)
subtitle_entries.append((start_time, end_time, sentence_text.strip()))
current_sentence = []
word_count = 0
if at_boundary:
boundary_idx += 1
# Add remaining tokens
if current_sentence:
start_time = current_sentence[0]["start"]
end_time = current_sentence[-1]["end"]
sentence_text = "".join(
t["text"] + (t.get("whitespace", "") or "")
for t in current_sentence
)
subtitle_entries.append((start_time, end_time, sentence_text.strip()))
# Fallback for last entry
if subtitle_entries and fallback_end_time is not None:
last_entry = subtitle_entries[-1]
start, end, text = last_entry
if end is None or end <= start or end <= 0:
subtitle_entries[-1] = (start, fallback_end_time, text)
return # Exit early, spaCy processing complete
# Default regex-based processing (non-English or spaCy unavailable)
# Define separator pattern based on mode # Define separator pattern based on mode
if self.subtitle_mode == "Line": if self.subtitle_mode == "Line":
separator = r"\n" separator = r"\n"
elif self.subtitle_mode == "Sentence": elif self.subtitle_mode == "Sentence":
# Use language-specific punctuation for CJK languages (without comma) # Use punctuation without comma
lang_punct = self.LANGUAGE_PUNCTUATION.get(self.lang_code, {}) separator = r"[{}]".format(self.PUNCTUATION_SENTENCE)
separator = (
lang_punct.get("sentence", r"[.!?]")
if isinstance(lang_punct, dict)
else r"[.!?]"
)
else: # Sentence + Comma else: # Sentence + Comma
# Use language-specific punctuation for CJK languages (with comma) # Use punctuation with comma
lang_punct = self.LANGUAGE_PUNCTUATION.get(self.lang_code, {}) separator = r"[{}]".format(self.PUNCTUATION_SENTENCE_COMMA)
separator = (
lang_punct.get("comma", r"[.!?,]")
if isinstance(lang_punct, dict)
else r"[.!?,]"
)
current_sentence = [] current_sentence = []
word_count = 0 word_count = 0
+21 -2
View File
@@ -828,6 +828,7 @@ class abogen(QWidget):
self.replace_single_newlines = self.config.get("replace_single_newlines", True) self.replace_single_newlines = self.config.get("replace_single_newlines", True)
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._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
@@ -2081,7 +2082,7 @@ class abogen(QWidget):
# pipeline_loaded_callback remains unchanged # pipeline_loaded_callback remains unchanged
def pipeline_loaded_callback(np_module, kpipeline_class, error): def pipeline_loaded_callback(np_module, kpipeline_class, error):
if error: if error:
self.update_log((f"Error loading numpy or KPipeline: {error}", False)) self.update_log((f"Error loading numpy or KPipeline: {error}", "red"))
prevent_sleep_end() prevent_sleep_end()
return return
@@ -2128,6 +2129,8 @@ class abogen(QWidget):
self.conversion_thread.use_silent_gaps = self.use_silent_gaps self.conversion_thread.use_silent_gaps = self.use_silent_gaps
# Pass subtitle_speed_method setting # Pass subtitle_speed_method setting
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
self.conversion_thread.use_spacy_segmentation = self.use_spacy_segmentation
# 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
@@ -2264,7 +2267,7 @@ class abogen(QWidget):
self.is_converting = False self.is_converting = False
elapsed = int(time.time() - self.start_time) elapsed = int(time.time() - self.start_time)
h, m, s = elapsed // 3600, (elapsed % 3600) // 60, elapsed % 60 h, m, s = elapsed // 3600, (elapsed % 3600) // 60, elapsed % 60
self.update_log(f"\nTime elapsed: {h:02d}:{m:02d}:{s:02d}") self.update_log((f"\nTime elapsed: {h:02d}:{m:02d}:{s:02d}", "grey"))
# Default to showing the button # Default to showing the button
show_open_file_button = True show_open_file_button = True
@@ -3206,6 +3209,18 @@ class abogen(QWidget):
# Add separator # Add separator
menu.addSeparator() menu.addSeparator()
# Add spaCy sentence segmentation option
spacy_action = QAction("Use spaCy for sentence segmentation", self)
spacy_action.setCheckable(True)
spacy_action.setChecked(self.use_spacy_segmentation)
spacy_action.triggered.connect(
lambda checked: self.toggle_spacy_segmentation(checked)
)
menu.addAction(spacy_action)
# Add separator
menu.addSeparator()
# Add "Disable Kokoro's internet access" option # Add "Disable Kokoro's internet access" option
disable_kokoro_action = QAction("Disable Kokoro's internet access", self) disable_kokoro_action = QAction("Disable Kokoro's internet access", self)
disable_kokoro_action.setCheckable(True) disable_kokoro_action.setCheckable(True)
@@ -3270,6 +3285,10 @@ class abogen(QWidget):
self.config["subtitle_speed_method"] = method self.config["subtitle_speed_method"] = method
save_config(self.config) save_config(self.config)
def toggle_spacy_segmentation(self, enabled):
self.use_spacy_segmentation = enabled
self.config["use_spacy_segmentation"] = enabled
save_config(self.config)
def restart_app(self): def restart_app(self):
import sys import sys
+143
View File
@@ -0,0 +1,143 @@
"""
Lazy-loaded spaCy utilities for sentence segmentation.
"""
# Cached spaCy module and models (lazy loaded)
_spacy = None
_nlp_cache = {}
# Language code to spaCy model mapping
SPACY_MODELS = {
"a": "en_core_web_sm", # American English
"b": "en_core_web_sm", # British English
"e": "es_core_news_sm", # Spanish
"f": "fr_core_news_sm", # French
"i": "it_core_news_sm", # Italian
"p": "pt_core_news_sm", # Brazilian Portuguese
"z": "zh_core_web_sm", # Mandarin Chinese
"j": "ja_core_news_sm", # Japanese
"h": "xx_sent_ud_sm", # Hindi (multi-language model)
}
def _load_spacy():
"""Lazy load spaCy module."""
global _spacy
if _spacy is None:
try:
import spacy
_spacy = spacy
except ImportError:
return None
return _spacy
def get_spacy_model(lang_code, log_callback=None):
"""
Get or load a spaCy model for the given language code.
Downloads the model automatically if not available.
Args:
lang_code: Language code (a, b, e, f, etc.)
log_callback: Optional function to log messages
Returns:
Loaded spaCy model or None if unavailable
"""
def log(msg, is_error=False):
# Prefer GUI log callback when provided to avoid spamming stdout.
if log_callback:
color = "red" if is_error else "grey"
try:
log_callback((msg, color))
except Exception:
# Fallback to printing if callback misbehaves
print(msg)
else:
print(msg)
# Check if model is cached
if lang_code in _nlp_cache:
return _nlp_cache[lang_code]
# Check if language is supported
model_name = SPACY_MODELS.get(lang_code)
if not model_name:
log(f"\nspaCy: No model mapping for language '{lang_code}'...")
return None
# Lazy load spaCy
spacy = _load_spacy()
if spacy is None:
log("\nspaCy: Module not installed, falling back to default segmentation...")
return None
# Try to load the model
try:
log(f"\nLoading spaCy model '{model_name}'...")
nlp = spacy.load(model_name, disable=["ner", "parser", "tagger", "lemmatizer", "attribute_ruler"])
# Enable sentence segmentation only
if "sentencizer" not in nlp.pipe_names:
nlp.add_pipe("sentencizer")
_nlp_cache[lang_code] = nlp
return nlp
except OSError:
# Model not found, attempt download
log(f"\nspaCy: Downloading model '{model_name}'...")
try:
from spacy.cli import download
download(model_name)
# Retry loading
nlp = spacy.load(model_name, disable=["ner", "parser", "tagger", "lemmatizer", "attribute_ruler"])
if "sentencizer" not in nlp.pipe_names:
nlp.add_pipe("sentencizer")
_nlp_cache[lang_code] = nlp
log(f"spaCy model '{model_name}' downloaded and loaded")
return nlp
except Exception as e:
log(f"\nspaCy: Failed to download model '{model_name}': {e}...", is_error=True)
return None
except Exception as e:
log(f"\nspaCy: Error loading model '{model_name}': {e}...", is_error=True)
return None
def segment_sentences(text, lang_code, log_callback=None):
"""
Segment text into sentences using spaCy.
Args:
text: Text to segment
lang_code: Language code
log_callback: Optional function to log messages
Returns:
List of sentence strings, or None if spaCy unavailable
"""
nlp = get_spacy_model(lang_code, log_callback)
if nlp is None:
return None
# Ensure spaCy can handle large texts by adjusting max_length if necessary
try:
text_len = len(text or "")
if text_len and hasattr(nlp, "max_length") and text_len > nlp.max_length:
# increase a bit beyond the text length to be safe
nlp.max_length = text_len + 1000
except Exception:
pass
# Process text and extract sentences
doc = nlp(text)
return [sent.text.strip() for sent in doc.sents if sent.text.strip()]
def is_spacy_available():
"""Check if spaCy can be imported."""
return _load_spacy() is not None
def clear_cache():
"""Clear the model cache to free memory."""
global _nlp_cache
_nlp_cache.clear()
+2 -1
View File
@@ -25,7 +25,8 @@ dependencies = [
"charset_normalizer>=3.4.1", "charset_normalizer>=3.4.1",
"chardet>=5.2.0", "chardet>=5.2.0",
"static_ffmpeg>=2.13", "static_ffmpeg>=2.13",
"Markdown>=3.9" "Markdown>=3.9",
"spacy>=3.8.7"
] ]
classifiers = [ classifiers = [