Added subtitle generation support for non-English languages, improvements in code and documentation

This commit is contained in:
Deniz Şafak
2025-11-23 16:03:41 +03:00
parent 795bd8f1aa
commit f5d95547c5
5 changed files with 134 additions and 35 deletions
+2
View File
@@ -1,5 +1,7 @@
# 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 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.)
- Optimized regex compilation and eliminated busy-wait loops. - Optimized regex compilation and eliminated busy-wait loops.
- Improvements in code and documentation.
# 1.2.3 # 1.2.3
- Same as 1.2.2, re-released to fix an issue with subtitle timing when using timestamp-based text files. - Same as 1.2.2, re-released to fix an issue with subtitle timing when using timestamp-based text files.
+2 -2
View File
@@ -488,7 +488,7 @@ This project is available under the MIT License - see the [LICENSE](https://gith
## `Star History` ## `Star History`
[![Star History Chart](https://api.star-history.com/svg?repos=denizsafak/abogen&type=Date)](https://www.star-history.com/#denizsafak/abogen&Date) [![Star History Chart](https://api.star-history.com/svg?repos=denizsafak/abogen&type=Date)](https://www.star-history.com/#denizsafak/abogen&Date)
> [!IMPORTANT] > [!NOTE]
> Subtitle generation currently works only for English. This is because Kokoro provides timestamp tokens only for English text. If you want subtitles in other languages, please request this feature in the [Kokoro project](https://github.com/hexgrad/kokoro). For more technical details, see [this line](https://github.com/hexgrad/kokoro/blob/6d87f4ae7abc2d14dbc4b3ef2e5f19852e861ac2/kokoro/pipeline.py#L383) in the Kokoro's code. > Abogen supports subtitle generation for all languages. However, word-level subtitle modes (e.g., "1 word", "2 words", "3 words", etc.) are only available for English because [Kokoro provides timestamp tokens only for English text](https://github.com/hexgrad/kokoro/blob/6d87f4ae7abc2d14dbc4b3ef2e5f19852e861ac2/kokoro/pipeline.py#L383). For non-English languages, Abogen uses a duration-based fallback that supports sentence-level and comma-based subtitle modes ("Line", "Sentence", "Sentence + Comma"). If you need word-level subtitles for other languages, please request that feature in the [Kokoro project](https://github.com/hexgrad/kokoro).
> Tags: audiobook, kokoro, text-to-speech, TTS, audiobook generator, audiobooks, text to speech, audiobook maker, audiobook creator, audiobook generator, voice-synthesis, text to audio, text to audio converter, text to speech converter, text to speech generator, text to speech software, text to speech app, epub to audio, pdf to audio, markdown to audio, subtitle to audio, srt to audio, ass to audio, vtt to audio, webvtt to audio, content-creation, media-generation > Tags: audiobook, kokoro, text-to-speech, TTS, audiobook generator, audiobooks, text to speech, audiobook maker, audiobook creator, audiobook generator, voice-synthesis, text to audio, text to audio converter, text to speech converter, text to speech generator, text to speech software, text to speech app, epub to audio, pdf to audio, markdown to audio, subtitle to audio, srt to audio, ass to audio, vtt to audio, webvtt to audio, content-creation, media-generation
+1 -4
View File
@@ -61,10 +61,7 @@ SUPPORTED_INPUT_FORMATS = [
# Please refer to: https://github.com/hexgrad/kokoro/blob/6d87f4ae7abc2d14dbc4b3ef2e5f19852e861ac2/kokoro/pipeline.py # Please refer to: https://github.com/hexgrad/kokoro/blob/6d87f4ae7abc2d14dbc4b3ef2e5f19852e861ac2/kokoro/pipeline.py
# 383 English processing (unchanged) # 383 English processing (unchanged)
# 384 if self.lang_code in 'ab': # 384 if self.lang_code in 'ab':
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION = [ SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION = list(LANGUAGE_DESCRIPTIONS.keys())
"a",
"b",
]
# Voice and sample text constants # Voice and sample text constants
VOICES_INTERNAL = [ VOICES_INTERNAL = [
+35
View File
@@ -690,6 +690,27 @@ class ConversionThread(QThread):
None if lang_code in self.NO_SPLIT_LANGUAGES else self.DEFAULT_SPLIT_PATTERN None if lang_code in self.NO_SPLIT_LANGUAGES else self.DEFAULT_SPLIT_PATTERN
) )
# 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"
): ):
@@ -1419,6 +1440,20 @@ class ConversionThread(QThread):
# Subtitle logic # Subtitle logic
if self.subtitle_mode != "Disabled": if self.subtitle_mode != "Disabled":
tokens_list = getattr(result, "tokens", []) tokens_list = getattr(result, "tokens", [])
# 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 = [] tokens_with_timestamps = []
chapter_tokens_with_timestamps = [] chapter_tokens_with_timestamps = []
+94 -29
View File
@@ -390,7 +390,7 @@ class InputBox(QLabel):
window = self.window() window = self.window()
if hasattr(window, "subtitle_combo"): if hasattr(window, "subtitle_combo"):
# Only enable if language supports it # Only enable if language supports it
current_lang = getattr(window, "lang_code", "a") current_lang = getattr(window, "selected_lang", "a")
window.subtitle_combo.setEnabled( window.subtitle_combo.setEnabled(
current_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION current_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION
) )
@@ -824,7 +824,7 @@ class abogen(QWidget):
self.use_gpu = self.config.get( self.use_gpu = self.config.get(
"use_gpu", True # Load GPU setting with default True "use_gpu", True # Load GPU setting with default True
) )
self.replace_single_newlines = self.config.get("replace_single_newlines", False) 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._pending_close_event = None self._pending_close_event = None
@@ -1062,9 +1062,6 @@ class abogen(QWidget):
) )
self.subtitle_combo.setCurrentText(self.subtitle_mode) self.subtitle_combo.setCurrentText(self.subtitle_mode)
self.subtitle_combo.currentTextChanged.connect(self.on_subtitle_mode_changed) self.subtitle_combo.currentTextChanged.connect(self.on_subtitle_mode_changed)
# Enable/disable subtitle options based on selected language (profile or voice)
enable = self.selected_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION
self.subtitle_combo.setEnabled(enable)
subtitle_layout.addWidget(self.subtitle_combo) subtitle_layout.addWidget(self.subtitle_combo)
controls_layout.addLayout(subtitle_layout) controls_layout.addLayout(subtitle_layout)
@@ -1146,10 +1143,10 @@ class abogen(QWidget):
except Exception: except Exception:
# Fail-safe: don't crash UI if model manipulation isn't supported on some platforms # Fail-safe: don't crash UI if model manipulation isn't supported on some platforms
pass pass
# Enable/disable subtitle format based on selected language
self.subtitle_format_combo.setEnabled( # Enable/disable subtitle options based on selected language (profile or voice)
self.selected_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION self.update_subtitle_options_availability()
)
controls_layout.addLayout(subtitle_format_layout) controls_layout.addLayout(subtitle_format_layout)
# Replace single newlines dropdown (acts like checkbox) # Replace single newlines dropdown (acts like checkbox)
@@ -1590,19 +1587,69 @@ class abogen(QWidget):
self.config["speed"] = s self.config["speed"] = s
save_config(self.config) save_config(self.config)
def update_subtitle_options_availability(self):
"""
Update the enabled state of subtitle options based on the selected language.
For non-English languages, only sentence-based and line-based modes are supported.
"""
# Check if current file is a subtitle file
is_subtitle_input = False
if self.selected_file and self.selected_file.lower().endswith(
(".srt", ".ass", ".vtt")
):
is_subtitle_input = True
if self.selected_lang not in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION:
self.subtitle_combo.setEnabled(False)
self.subtitle_format_combo.setEnabled(False)
return
# Only enable subtitle_combo if it's NOT a subtitle input
self.subtitle_combo.setEnabled(not is_subtitle_input)
self.subtitle_format_combo.setEnabled(True)
is_english = self.selected_lang in ["a", "b"]
# Items to keep enabled for non-English
allowed_modes = ["Disabled", "Line", "Sentence", "Sentence + Comma"]
model = self.subtitle_combo.model()
for i in range(self.subtitle_combo.count()):
text = self.subtitle_combo.itemText(i)
item = model.item(i)
if not item:
continue
if is_english:
item.setEnabled(True)
else:
if text in allowed_modes:
item.setEnabled(True)
else:
item.setEnabled(False)
# If current selection is disabled, switch to a valid one
current_text = self.subtitle_combo.currentText()
current_idx = self.subtitle_combo.currentIndex()
current_item = model.item(current_idx)
if current_item and not current_item.isEnabled():
# Switch to "Sentence" if available, else "Disabled"
sentence_idx = self.subtitle_combo.findText("Sentence")
if sentence_idx >= 0:
self.subtitle_combo.setCurrentIndex(sentence_idx)
else:
self.subtitle_combo.setCurrentIndex(0) # Disabled
self.subtitle_mode = self.subtitle_combo.currentText()
def on_voice_changed(self, index): def on_voice_changed(self, index):
voice = self.voice_combo.itemData(index) voice = self.voice_combo.itemData(index)
self.selected_voice, self.selected_lang = voice, voice[0] self.selected_voice, self.selected_lang = voice, voice[0]
self.config["selected_voice"] = voice self.config["selected_voice"] = voice
save_config(self.config) save_config(self.config)
# Enable/disable subtitle options based on language # Enable/disable subtitle options based on language
if self.selected_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION: self.update_subtitle_options_availability()
self.subtitle_combo.setEnabled(True)
self.subtitle_format_combo.setEnabled(True)
self.subtitle_mode = self.subtitle_combo.currentText()
else:
self.subtitle_combo.setEnabled(False)
self.subtitle_format_combo.setEnabled(False)
def on_voice_combo_changed(self, index): def on_voice_combo_changed(self, index):
data = self.voice_combo.itemData(index) data = self.voice_combo.itemData(index)
@@ -1624,12 +1671,7 @@ class abogen(QWidget):
self.config.pop("selected_voice", None) self.config.pop("selected_voice", None)
save_config(self.config) save_config(self.config)
# enable subtitles based on profile language # enable subtitles based on profile language
self.subtitle_combo.setEnabled( self.update_subtitle_options_availability()
self.selected_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION
)
self.subtitle_format_combo.setEnabled(
self.selected_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION
)
else: else:
self.mixed_voice_state = None self.mixed_voice_state = None
self.selected_profile_name = None self.selected_profile_name = None
@@ -1638,13 +1680,7 @@ class abogen(QWidget):
if "selected_profile_name" in self.config: if "selected_profile_name" in self.config:
del self.config["selected_profile_name"] del self.config["selected_profile_name"]
save_config(self.config) save_config(self.config)
if self.selected_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION: self.update_subtitle_options_availability()
self.subtitle_combo.setEnabled(True)
self.subtitle_format_combo.setEnabled(True)
self.subtitle_mode = self.subtitle_combo.currentText()
else:
self.subtitle_combo.setEnabled(False)
self.subtitle_format_combo.setEnabled(False)
def update_subtitle_combo_for_profile(self, profile_name): def update_subtitle_combo_for_profile(self, profile_name):
from abogen.voice_profiles import load_profiles from abogen.voice_profiles import load_profiles
@@ -2790,6 +2826,33 @@ class abogen(QWidget):
self.conversion_thread.cancel() self.conversion_thread.cancel()
self.conversion_thread.wait() self.conversion_thread.wait()
def cleanup_preview_threads(self):
# Stop preview generation thread
if (
hasattr(self, "preview_thread")
and self.preview_thread is not None
and self.preview_thread.isRunning()
):
self.preview_thread.terminate()
self.preview_thread.wait()
# Stop audio playback thread
if (
hasattr(self, "play_audio_thread")
and self.play_audio_thread is not None
and self.play_audio_thread.isRunning()
):
self.play_audio_thread.stop()
self.play_audio_thread.wait()
# Cleanup pygame mixer if initialized
try:
import pygame
if pygame.mixer.get_init():
pygame.mixer.quit()
except ImportError:
pass
def closeEvent(self, event): def closeEvent(self, event):
if self.is_converting: if self.is_converting:
box = QMessageBox(self) box = QMessageBox(self)
@@ -2804,11 +2867,13 @@ class abogen(QWidget):
box.setDefaultButton(QMessageBox.StandardButton.No) box.setDefaultButton(QMessageBox.StandardButton.No)
if box.exec() == QMessageBox.StandardButton.Yes: if box.exec() == QMessageBox.StandardButton.Yes:
self.cleanup_conversion_thread() self.cleanup_conversion_thread()
self.cleanup_preview_threads()
event.accept() event.accept()
else: else:
event.ignore() event.ignore()
else: else:
self.cleanup_conversion_thread() self.cleanup_conversion_thread()
self.cleanup_preview_threads()
event.accept() event.accept()
def show_chapter_options_dialog(self, chapter_count): def show_chapter_options_dialog(self, chapter_count):