mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
Add spaCy support for improved sentence segmentation, possible fix for #91
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ Here’s 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.
|
||||||
|
|||||||
+191
-70
@@ -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,11 +1380,68 @@ 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(
|
|
||||||
|
# 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:
|
||||||
|
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,
|
chapter_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:
|
||||||
|
active_split_pattern = "\n" # Use newline splitting for Sentence mode
|
||||||
|
else:
|
||||||
|
self.log_updated.emit(("\nspaCy: Fallback to default segmentation...", "grey"))
|
||||||
|
|
||||||
|
# Process text - either as spaCy sentences or as single text
|
||||||
|
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,
|
voice=loaded_voice,
|
||||||
speed=self.speed,
|
speed=self.speed,
|
||||||
split_pattern=self.split_pattern,
|
split_pattern=active_split_pattern,
|
||||||
):
|
):
|
||||||
# Print the result for debugging
|
# Print the result for debugging
|
||||||
# print(f"Result: {result}")
|
# print(f"Result: {result}")
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 = [
|
||||||
|
|||||||
Reference in New Issue
Block a user