fix(normalization): improve punctuation handling and spacing in text normalization

fix(subtitles): enhance ellipsis and paragraph break handling in subtitle processing
feat(gui): implement background update check for new versions
test(tests): add tests for ellipsis handling and paragraph breaks preservation
This commit is contained in:
Deniz Şafak
2026-09-07 17:29:27 +03:00
parent be74c69507
commit 08e2ee8b85
6 changed files with 219 additions and 79 deletions
+3 -3
View File
@@ -5,10 +5,10 @@ from __future__ import annotations
from abogen.domain.enums import Language, SubtitleMode from abogen.domain.enums import Language, SubtitleMode
# Canonical punctuation sets covering all supported scripts: # Canonical punctuation sets covering all supported scripts:
# ASCII (. ! ?), Arabic ؟, CJK (。!?), Devanagari । # ASCII (. ! ?), ellipsis (…), Arabic ؟, CJK (。!?), Devanagari ।
PUNCTUATION_SENTENCE = r".!?؟。!?।" PUNCTUATION_SENTENCE = r".!?؟。!?।"
# Commas: ASCII , CJK fullwidth CJK ideographic 、 # Commas: ASCII , CJK fullwidth CJK ideographic 、
PUNCTUATION_SENTENCE_COMMA = r".!?,?。!?،,、।" PUNCTUATION_SENTENCE_COMMA = r".!?,?。!?،,、।"
PUNCTUATION_COMMAS = ",,、" PUNCTUATION_COMMAS = ",,、"
+37 -4
View File
@@ -153,7 +153,10 @@ def _process_karaoke_highlighting(
if t.get("end") is not None and t.get("start") is not None if t.get("end") is not None and t.get("start") is not None
else 0.5 else 0.5
) )
try:
duration_cs = int(duration * 100) duration_cs = int(duration * 100)
except (ValueError, OverflowError, TypeError):
duration_cs = 50
# Add karaoke effect # Add karaoke effect
karaoke_text += f"{{\\kf{duration_cs}}}{t.get('text', '')}{t.get('whitespace', '') or ''}" karaoke_text += f"{{\\kf{duration_cs}}}{t.get('text', '')}{t.get('whitespace', '') or ''}"
@@ -174,7 +177,10 @@ def _process_karaoke_highlighting(
karaoke_text = "" karaoke_text = ""
for t in current_sentence: for t in current_sentence:
duration = t["end"] - t["start"] if t.get("end") and t.get("start") else 0.5 duration = t["end"] - t["start"] if t.get("end") and t.get("start") else 0.5
try:
duration_cs = int(duration * 100) duration_cs = int(duration * 100)
except (ValueError, OverflowError, TypeError):
duration_cs = 50
karaoke_text += f"{{\\kf{duration_cs}}}{t.get('text', '')}{t.get('whitespace', '') or ''}" karaoke_text += f"{{\\kf{duration_cs}}}{t.get('text', '')}{t.get('whitespace', '') or ''}"
text_stripped = karaoke_text.strip() text_stripped = karaoke_text.strip()
if text_stripped: if text_stripped:
@@ -230,6 +236,19 @@ def _process_spacy_sentences(
set(sentence_boundaries + comma_positions) set(sentence_boundaries + comma_positions)
) )
# spaCy does not treat ellipsis ("...", "..", "…") as a sentence
# boundary ("Lorem ipsum... Lorem..." stays one sentence), so ellipsis
# runs followed by whitespace/end would merge into a single subtitle
# entry. Add explicit boundaries after them. Single dots ("Mr.") stay
# spaCy's responsibility so abbreviations don't regress.
for m in re.finditer(r"\.{2,}(?=[\s\"'”’»›)\]}]|$)|…(?=[\s\"'”’»›)\]}]|$)", full_text):
sentence_boundaries.append(m.end())
# Double newlines are paragraph breaks: always split, even when spaCy
# sees no sentence boundary.
for m in re.finditer(r"\n{2,}", full_text):
sentence_boundaries.append(m.end())
sentence_boundaries = sorted(set(sentence_boundaries))
# Multi-sentence single FakeToken handling # Multi-sentence single FakeToken handling
if len(tokens) == 1 and len(sentence_boundaries) > 1: if len(tokens) == 1 and len(sentence_boundaries) > 1:
single = tokens[0] single = tokens[0]
@@ -257,7 +276,12 @@ def _process_spacy_sentences(
if prev_pos < len(full_text): if prev_pos < len(full_text):
remainder = full_text[prev_pos:].strip() remainder = full_text[prev_pos:].strip()
if remainder: if remainder:
subtitle_entries.append((cur_start, end_time, remainder)) remainder_end = end_time
if remainder_end is None:
remainder_end = fallback_end_time
if remainder_end is None:
remainder_end = cur_start
subtitle_entries.append((cur_start, remainder_end, remainder))
_apply_fallback_end_time(subtitle_entries, fallback_end_time) _apply_fallback_end_time(subtitle_entries, fallback_end_time)
return return
@@ -379,15 +403,24 @@ def _process_regex_sentences(
if len(parts) > 1: if len(parts) > 1:
d = (end_time - start_time) if (end_time is not None and start_time is not None and end_time > start_time) else 0.0 d = (end_time - start_time) if (end_time is not None and start_time is not None and end_time > start_time) else 0.0
total_len = max(len(sentence_text), 1) total_len = max(len(sentence_text), 1)
cur_s = start_time cur_s = start_time if start_time is not None else 0.0
for i, p in enumerate(parts): for i, p in enumerate(parts):
e = end_time if i == len(parts) - 1 else cur_s + d * len(p) / total_len if i == len(parts) - 1 and end_time is not None:
e = end_time
else:
e = cur_s + d * len(p) / total_len
subtitle_entries.append((cur_s, e, p)) subtitle_entries.append((cur_s, e, p))
cur_s = e cur_s = e
current_sentence = [] current_sentence = []
if current_sentence and sentence_text: if current_sentence and sentence_text:
subtitle_entries.append((start_time, end_time, sentence_text)) safe_start = start_time if start_time is not None else 0.0
safe_end = end_time
if safe_end is None:
safe_end = fallback_end_time
if safe_end is None:
safe_end = safe_start
subtitle_entries.append((safe_start, safe_end, sentence_text))
# Fallback for last entry # Fallback for last entry
_apply_fallback_end_time(subtitle_entries, fallback_end_time) _apply_fallback_end_time(subtitle_entries, fallback_end_time)
+19 -3
View File
@@ -704,7 +704,9 @@ def _cleanup_spacing(text: str) -> str:
text = re.sub(rf"\s+([\"\'])([\s{_STANDARD_PUNCT_CLASS}{_CLOSING_PUNCT_CLASS}]|$)", r"\1\2", text) text = re.sub(rf"\s+([\"\'])([\s{_STANDARD_PUNCT_CLASS}{_CLOSING_PUNCT_CLASS}]|$)", r"\1\2", text)
# Ensure spaces exist after sentence punctuation when followed by a word/quote. # Ensure spaces exist after sentence punctuation when followed by a word/quote.
text = re.sub(rf"([{_STANDARD_PUNCT_CLASS}])(?![\s{_CLOSING_PUNCT_CLASS}\"\'”’»›)])", r"\1 ", text) # Runs of punctuation ("...", "?!?", "!!") must stay together: no space
# inside the run, only after it ("a...b" -> "a... b").
text = re.sub(rf"([{_STANDARD_PUNCT_CLASS}])(?![\s{_STANDARD_PUNCT_CLASS}{_CLOSING_PUNCT_CLASS}\"\'”’»›)])", r"\1 ", text)
# Ensure space after unambiguous closing quote when followed by a word (e.g. '”Next' -> '” Next') # Ensure space after unambiguous closing quote when followed by a word (e.g. '”Next' -> '” Next')
text = re.sub(rf"([{_CLOSING_PUNCT_CLASS}])(?![\s{_STANDARD_PUNCT_CLASS}{_CLOSING_PUNCT_CLASS}\"\'”’»›)])", r"\1 ", text) text = re.sub(rf"([{_CLOSING_PUNCT_CLASS}])(?![\s{_STANDARD_PUNCT_CLASS}{_CLOSING_PUNCT_CLASS}\"\'”’»›)])", r"\1 ", text)
# Straight double quote closing (preceded by non-whitespace) followed directly by a word/number/opening # Straight double quote closing (preceded by non-whitespace) followed directly by a word/number/opening
@@ -715,8 +717,10 @@ def _cleanup_spacing(text: str) -> str:
# Tighten hyphen/em dash spacing between word characters. # Tighten hyphen/em dash spacing between word characters.
text = re.sub(r"(?<=\w)\s*([-–—])\s*(?=\w)", r"\1", text) text = re.sub(r"(?<=\w)\s*([-–—])\s*(?=\w)", r"\1", text)
# Normalize multiple spaces. # Normalize multiple spaces, preserving paragraph breaks (double
text = re.sub(r"\s{2,}", " ", text) # newlines must survive so the TTS engine can split on them).
text = re.sub(r"[^\S\n]{2,}", " ", text)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip() return text.strip()
@@ -1856,7 +1860,10 @@ def _normalize_grouped_numbers(text: str, cfg: ApostropheConfig) -> str:
for digit in trimmed_fraction: for digit in trimmed_fraction:
if not digit.isdigit(): if not digit.isdigit():
return token return token
try:
digit_words.append(_DIGIT_WORDS[int(digit)]) digit_words.append(_DIGIT_WORDS[int(digit)])
except (ValueError, IndexError):
return token
spoken = f"{integer_words} point {' '.join(digit_words)}" spoken = f"{integer_words} point {' '.join(digit_words)}"
return f"minus {spoken}" if is_negative else spoken return f"minus {spoken}" if is_negative else spoken
@@ -1878,18 +1885,27 @@ def _normalize_grouped_numbers(text: str, cfg: ApostropheConfig) -> str:
# Magnitude case: $2.5 million -> two point five million dollars # Magnitude case: $2.5 million -> two point five million dollars
if "." in amount_str: if "." in amount_str:
integer_part, fraction_part = amount_str.split(".", 1) integer_part, fraction_part = amount_str.split(".", 1)
try:
integer_val = int(integer_part) integer_val = int(integer_part)
except ValueError:
return match.group(0)
integer_words = _int_to_words(integer_val, language) integer_words = _int_to_words(integer_val, language)
# Spell out fraction digits # Spell out fraction digits
digit_words = [] digit_words = []
for digit in fraction_part: for digit in fraction_part:
if digit.isdigit(): if digit.isdigit():
try:
digit_words.append(_DIGIT_WORDS[int(digit)]) digit_words.append(_DIGIT_WORDS[int(digit)])
except (ValueError, IndexError):
return match.group(0)
amount_spoken = f"{integer_words} point {' '.join(digit_words)}" amount_spoken = f"{integer_words} point {' '.join(digit_words)}"
else: else:
try:
amount_spoken = _int_to_words(int(amount), language) amount_spoken = _int_to_words(int(amount), language)
except (ValueError, OverflowError):
return match.group(0)
currency_names = { currency_names = {
"$": "dollars", "$": "dollars",
+78 -50
View File
@@ -137,6 +137,28 @@ class ThreadSafeLogSignal(QObject):
self.log_signal.emit(message) self.log_signal.emit(message)
_UPDATE_CHECK_URL = "https://raw.githubusercontent.com/denizsafak/abogen/refs/heads/main/abogen/VERSION"
_UPDATE_CHECK_TIMEOUT = 8 # seconds; bounds offline/DNS hangs so the GUI never blocks
class _UpdateCheckThread(QThread):
"""Fetch the remote VERSION file off the GUI thread."""
succeeded = pyqtSignal(str)
failed = pyqtSignal(str)
def run(self):
import urllib.request
try:
with urllib.request.urlopen(
_UPDATE_CHECK_URL, timeout=_UPDATE_CHECK_TIMEOUT
) as response:
self.succeeded.emit(response.read().decode().strip())
except Exception as exc: # offline, DNS hang, HTTP error, ...
self.failed.emit(str(exc))
class IconProvider(QFileIconProvider): class IconProvider(QFileIconProvider):
def icon(self, fileInfo): def icon(self, fileInfo):
return super().icon(fileInfo) return super().icon(fileInfo)
@@ -4077,9 +4099,63 @@ Categories=AudioVideo;Audio;Utility;
self.check_for_updates_startup() self.check_for_updates_startup()
def check_for_updates_startup(self): def check_for_updates_startup(self):
import urllib.request # Network I/O runs in a worker thread: urlopen without a timeout on
# the GUI thread froze the whole app when offline (DNS/connect can
# hang for minutes). Results return via signals on the GUI thread.
thread = getattr(self, "_update_check_thread", None)
if thread is not None:
try:
if thread.isRunning():
return
except RuntimeError:
pass # previous thread already finished/deleted
show_result = (
hasattr(self, "_show_update_check_result")
and self._show_update_check_result
)
self._show_update_check_result = False
self._update_check_thread = _UpdateCheckThread(self)
self._update_check_thread.succeeded.connect(
lambda remote_raw: self._on_update_check_done(remote_raw, show_result)
)
self._update_check_thread.failed.connect(
lambda err: self._on_update_check_failed(err, show_result)
)
self._update_check_thread.finished.connect(
self._update_check_thread.deleteLater
)
self._update_check_thread.start()
def show_update_message(remote_version, local_version): def _on_update_check_done(self, remote_raw, show_result):
remote_version = remote_raw.strip()
local_version = VERSION
try:
remote_num = int("".join(remote_version.split(".")))
local_num = int("".join(local_version.split(".")))
except ValueError:
return
if remote_num > local_num:
# Use QTimer to ensure UI is ready, then show update message.
QTimer.singleShot(
1000,
lambda: self._show_update_message(remote_version, local_version),
)
elif show_result:
QMessageBox.information(
self,
"Up to Date",
f"You are running the latest version of {PROGRAM_NAME} ({local_version}).",
)
def _on_update_check_failed(self, err, show_result):
if show_result:
QMessageBox.warning(
self,
"Update Check Failed",
f"Could not check for updates:\n{err}",
)
def _show_update_message(self, remote_version, local_version):
msg_box = QMessageBox(self) msg_box = QMessageBox(self)
msg_box.setIcon(QMessageBox.Icon.Information) msg_box.setIcon(QMessageBox.Icon.Information)
msg_box.setWindowTitle("Update Available") msg_box.setWindowTitle("Update Available")
@@ -4103,50 +4179,6 @@ Categories=AudioVideo;Audio;Utility;
except Exception: except Exception:
pass pass
# Reset flag to track if we should show "no updates" message
show_result = (
hasattr(self, "_show_update_check_result")
and self._show_update_check_result
)
self._show_update_check_result = False
try:
update_url = "https://raw.githubusercontent.com/denizsafak/abogen/refs/heads/main/abogen/VERSION"
with urllib.request.urlopen(update_url) as response:
remote_raw = response.read().decode().strip()
local_raw = VERSION
# Parse version numbers
remote_version = remote_raw
local_version = local_raw
try:
remote_num = int("".join(remote_version.split(".")))
local_num = int("".join(local_version.split(".")))
except ValueError as ve:
return
if remote_num > local_num:
# Use QTimer to ensure UI is ready, then show update message.
QTimer.singleShot(
1000, lambda: show_update_message(remote_version, local_version)
)
elif show_result:
# Show "no updates" message if manually checking
QMessageBox.information(
self,
"Up to Date",
f"You are running the latest version of {PROGRAM_NAME} ({local_version}).",
)
except Exception as e:
if show_result:
QMessageBox.warning(
self,
"Update Check Failed",
f"Could not check for updates:\n{str(e)}",
)
pass
def clear_cache_files(self): def clear_cache_files(self):
"""Clear cache files created by the program.""" """Clear cache files created by the program."""
import glob import glob
@@ -4248,8 +4280,6 @@ Categories=AudioVideo;Audio;Utility;
def set_max_log_lines(self): def set_max_log_lines(self):
"""Open a dialog to set the maximum lines in the log window.""" """Open a dialog to set the maximum lines in the log window."""
from PyQt6.QtWidgets import QInputDialog
value, ok = QInputDialog.getInt( value, ok = QInputDialog.getInt(
self, self,
"Max Lines in Log Window", "Max Lines in Log Window",
@@ -4271,8 +4301,6 @@ Categories=AudioVideo;Audio;Utility;
def set_max_subtitle_words(self): def set_max_subtitle_words(self):
"""Open a dialog to set the maximum words per subtitle""" """Open a dialog to set the maximum words per subtitle"""
from PyQt6.QtWidgets import QInputDialog
current_value = self.config.get("max_subtitle_words", _DEFAULTS["max_subtitle_words"]) current_value = self.config.get("max_subtitle_words", _DEFAULTS["max_subtitle_words"])
value, ok = QInputDialog.getInt( value, ok = QInputDialog.getInt(
+7
View File
@@ -90,3 +90,10 @@ def test_manual_override_normalization():
assert normalize_manual_override_token("The") == "the" assert normalize_manual_override_token("The") == "the"
assert normalize_manual_override_token(" A ") == "a" assert normalize_manual_override_token(" A ") == "a"
assert normalize_manual_override_token("word") == "word" assert normalize_manual_override_token("word") == "word"
def test_paragraph_breaks_and_ellipsis_preserved():
normalized = normalize("Test. Lorem ipsum...\n\nLorem...\n\nLorem ...")
assert "\n\n" in normalized
assert ". . ." not in normalized
assert "Lorem..." in normalized
+56
View File
@@ -344,3 +344,59 @@ class TestComplexParagraphsAndEdgeCases:
assert len(entries) == 2 assert len(entries) == 2
assert entries[0][2] == '"I wonder..."' assert entries[0][2] == '"I wonder..."'
assert entries[1][2] == "he mused." assert entries[1][2] == "he mused."
class TestEllipsisAndParagraphBreaks:
"""Regression: '...' sentences merged into one entry; '\\n\\n' flattened.
spaCy does not treat ellipsis as a sentence boundary, so
'Lorem ipsum... Lorem...' stayed a single subtitle entry. And
_cleanup_spacing collapsed paragraph breaks before TTS.
"""
@staticmethod
def _tok(text_ws, dur=0.5):
toks, t = [], 0.0
for text, ws in text_ws:
toks.append({"start": t, "end": t + dur, "text": text, "whitespace": ws})
t += dur
return toks, t
def test_spacy_splits_ellipsis_sentences(self):
# Kokoro-style tokens: '...' arrives as 3 dot tokens.
toks, end = self._tok([
("Test", ""), (".", " "), ("Lorem", " "), ("ipsum", ""),
(".", ""), (".", ""), (".", " "),
("Lorem", ""), (".", ""), (".", ""), (".", ""),
])
entries = []
process_subtitle_tokens(
toks, entries, max_subtitle_words=50, subtitle_mode="Sentence",
language=Language.EN_US, use_spacy_segmentation=True,
fallback_end_time=end,
)
assert [e[2] for e in entries] == ["Test.", "Lorem ipsum...", "Lorem..."]
def test_spacy_keeps_abbreviations_intact(self):
toks, end = self._tok([
("Mr.", " "), ("Smith", " "), ("went", " "), ("home", ""),
(".", " "), ("He", " "), ("slept", ""), (".", ""),
])
entries = []
process_subtitle_tokens(
toks, entries, max_subtitle_words=50, subtitle_mode="Sentence",
language=Language.EN_US, use_spacy_segmentation=True,
fallback_end_time=end,
)
assert [e[2] for e in entries] == ["Mr. Smith went home.", "He slept."]
def test_spacy_splits_faketoken_ellipsis(self):
entries = []
process_subtitle_tokens(
[{"start": 0.0, "end": 3.0,
"text": "Lorem ipsum... Lorem... Lorem...", "whitespace": ""}],
entries, max_subtitle_words=50, subtitle_mode="Sentence",
language=Language.EN_US, use_spacy_segmentation=True,
fallback_end_time=3.0,
)
assert [e[2] for e in entries] == ["Lorem ipsum...", "Lorem...", "Lorem..."]