Compare commits

..
5 Commits
Author SHA1 Message Date
Deniz Şafak 08e2ee8b85 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
2026-09-07 17:29:27 +03:00
Deniz Şafak be74c69507 fix(subtitles): fix quotation mark spacing corruption and quoted dialogue splitting
- kokoro_text_normalization:
  - refactor _cleanup_spacing to contextually distinguish opening vs closing
    straight quotes (" and ') so leading quotes do not have spaces added after
    them and spaces before opening quotes are preserved
  - recognize non-English opening delimiters (¡, ¿, «, 「, etc.) and closing
    delimiters (», 」, etc.) in spacing normalization
  - in normalize_apostrophes, reconstruct text using original token offsets
    to preserve inter-token whitespace instead of blindly joining with spaces
- subtitle_generation:
  - add _is_sentence_boundary to recognize sentence punctuation followed by
    closing quotes/brackets (.", !", ?") in regex and karaoke modes
  - support proportional splitting for single FakeToken multi-sentence segments
    in spaCy mode (Supertonic / non-English Kokoro)
  - support newline splitting for Line mode in FakeToken fallbacks
  - safely convert language and subtitle_mode inputs
- tests:
  - add tests/test_subtitle_scenarios.py covering quotes, dialogues,
    paragraphs, contractions, all subtitle modes, and all TTS token styles
2026-08-29 12:45:18 +03:00
Deniz Şafak ffac4a4da9 fix(subtitles): enable "N words" and karaoke modes in PyQt
Three related fixes:

- make_subtitle_writer: accept word-count modes ("N words") in
  create_subtitle_writer. SubtitleMode("5 words") raised ValueError,
  which make_subtitle_writer swallowed and returned None, so PyQt never
  created or wrote the subtitle file when an "N words" mode was selected.
  Unknown modes now fall back to SubtitleMode.SENTENCE (writers only
  branch on SENTENCE_HIGHLIGHT). Regression test added.

- gui.py: refresh subtitle combo item availability after initial
  voice/profile selection. update_subtitle_options_availability() ran
  during initUI with selected_lang=None for a profile, taking the
  non-English branch and disabling Highlighting/N-words items; the fix-up
  never re-ran because setCurrentIndex on an already-current index emits
  no signal. Now called once more after the profile/voice language is
  resolved.

- AssWriter: stop discarding per-word karaoke timing. _add_karaoke_tags
  unconditionally replaced entry text with uniform {\\k100} tags,
  destroying the real per-word {\\kf} timings from
  _process_karaoke_highlighting. Only synthesize simplified tags when the
  text has no karaoke tags. Regression test added.
2026-08-20 23:27:49 +03:00
Deniz Şafak 5432de7ac5 fix(segmentation): process TTS segments per sentence, fix all subtitle modes
Sentence modes processed all text as a whole: Pipeline.__call__ merged every
engine segment back into one (whole text, no per-token timings), producing a
single giant subtitle and whole-text progress logs.

- tts_plugin/types: add TokenTiming, AudioSegment, SynthesizedAudio.segments
- tts_plugin/utils: Pipeline yields one Segment per engine segment (with
  tokens); merged fallback only when engine provides none
- kokoro engine: expose per-segment graphemes/audio + per-word token timings
- supertonic engine: expose per-segment graphemes/audio (no tokens)
- split_pattern: English Sentence/Sentence+Comma engine split is newline-only
  (boundaries applied at subtitle time via spaCy); non-English Sentence+Comma
  with spaCy ON uses spaCy pre-segmentation + newline engine split (no
  commas); spaCy-off fallback keeps comma pattern
- tts_segments: restore inter-segment whitespace on real per-word token
  boundaries only (never FakeToken fallbacks)
- _to_language_enum: accept Language enum input (str(enum) is "Language.ES",
  silently resolved to EN_US and disabled spaCy pre-TTS for every language
  in WebUI)
- pyqt/conversion, utils: replace print with logging
- add AGENTS.md documenting the segmentation/subtitle contract for future
  sessions
- tests: update English split-pattern expectations (1566 passing)
2026-08-20 23:00:15 +03:00
Deniz Şafak 823f5be029 fix(pyqt): make app close fast and crash-free
Clicking the window close button froze the app: closeEvent ran the full
process cleanup (engine disposal, CUDA flush, subprocess termination) and
unbounded thread joins synchronously on the GUI thread before the window
could start closing, and a 3.5s delay came from flush_cuda importing
torch even when it was never loaded.

- closeEvent no longer runs cleanup synchronously; the aboutToQuit hook,
  which was never connected (registered before QApplication existed), now
  runs it after the window is gone
- bound thread waits in cleanup_conversion_thread/cleanup_preview_threads
  with a terminate() fallback so closing never hangs
- flush_cuda skips torch work when torch was never imported
- restore the default Qt message handler before sys.exit; the custom
  Python handler was invoked during interpreter teardown and caused a
  SIGSEGV after shutdown cleanups finished
- log and time the close sequence (closeEvent steps + each shutdown hook)
2026-08-20 22:00:08 +03:00
20 changed files with 1114 additions and 207 deletions
+82
View File
@@ -0,0 +1,82 @@
# AGENTS.md — Segmentation & Subtitle System Contract
This document is the source of truth for how text is split for **voice
processing** (TTS engine segmentation) and **subtitle processing**, across
languages, TTS engines, and subtitle modes. It was written after a bug where
sentence modes "processed all text as a whole" (one merged engine segment →
one giant subtitle). **Do not change this behavior without updating this
table.**
## Voice processing — split pattern passed to the TTS engine
`get_split_pattern(language, mode)` in `abogen/domain/split_pattern.py` is the
default; the spaCy pre-TTS path overrides it. Both UIs must stay in sync:
`spacy_pre_tts_segmentation` (`abogen/domain/conversion_pipeline.py`, WebUI)
and the inline branch in `abogen/pyqt/conversion.py` (~line 860, PyQt).
| Subtitle mode | English (en-US/en-GB) | Non-English, spaCy ON | Non-English, spaCy OFF | CJK (ja/zh) |
|---|---|---|---|---|
| Disabled | `\n` | spaCy pre-split, engine `\n` | `\n+` | `(?<=[.!?؟。!?।])\s*\|\n+` |
| Line | `\n` | spaCy pre-split, engine `\n` | `\n` | `(?<=[.!?؟。!?।])\s*\|\n+` |
| Sentence | `\n` | spaCy pre-split, engine `\n` | `(?<=[.!?؟。!?।])\s+\|\n+` | `(?<=[.!?؟。!?।])\s*\|\n+` |
| Sentence + Comma | `\n` | spaCy pre-split, engine `\n` | `(?<=[.!?,؟。!?،،、।])\s+\|\n+` (commas kept) | `(?<=[.!?,؟。!?،،、।])\s*\|\n+` |
| Sentence + Highlighting | `\n+` | `\n+` | `\n+` | `\n+` |
| N words ("5 words") | `\n` (→ Disabled) | `\n+` | `\n+` | Disabled CJK pattern |
Rules baked into this table:
- **English voice splitting is ALWAYS newline-only** for Disabled, Line,
Sentence, and Sentence + Comma. English sentence/comma boundaries are
produced ONLY at subtitle time (spaCy post-TTS / regex fallback). Never add
punctuation to the English engine pattern.
- **Non-English + spaCy ON**: spaCy pre-segments the text (pre-TTS); the
engine pattern is `\n` for Sentence AND Sentence + Comma — **never commas**.
spaCy is skipped when the toggle is off, mode is Disabled/Line, or input is
a subtitle file.
- **Non-English + spaCy OFF** (toggle off, spaCy failure, subtitle input): the
default pattern is used — Sentence + Comma KEEPS its commas here. This is
the intentional fallback, not a bug.
- CJK: punctuation-based patterns for Disabled/Line (historical); spacing is
`\s*` (no spaces needed between CJK chars).
- Engine-level extra chunking (applies after the pattern): kokoro English
re-chunks at ~510 phonemes; kokoro non-English at ~400 chars; supertonic
caps each part at 300 chars.
## Subtitle processing — post-TTS, from tokens
| Mode | Behavior |
|---|---|
| Disabled | no subtitles |
| Line | one entry per TTS segment (line) |
| Sentence | sentence boundaries: English → spaCy; others → regex on `[.!?…]` |
| Sentence + Comma | sentence + comma boundaries at subtitle time (both languages) — commas never affect voice |
| Sentence + Highlighting | karaoke `{\kf…}` per word, grouped by sentence |
| N words | groups of N words by whitespace counting |
Token granularity (timing quality): kokoro English emits **per-word tokens**
with timestamps; kokoro non-English and supertonic emit **no tokens** → each
engine segment becomes one FakeToken, split by regex with proportional timing
when it contains multiple sentences.
## Hard invariants (breaking these reintroduces the original bug)
1. `Pipeline.__call__` (`abogen/tts_plugin/utils.py`) must yield ONE `Segment`
per engine segment (with tokens) — never merge segments back into the
whole text. `SynthesizedAudio.segments` carries the per-segment data;
engines expose it in `plugins/kokoro/engine.py` and
`plugins/supertonic/engine.py`.
2. `tts_segments` (`abogen/domain/conversion_pipeline.py`) restores trailing
whitespace on segment-boundary tokens ONLY for real per-word tokens, never
for FakeToken fallbacks.
3. `_to_language_enum` must return `lang_code` as-is when it is already a
`Language` enum (`str(Language.ES)` is `"Language.ES"`, which silently
resolved to EN_US and disabled spaCy pre-TTS for every language in WebUI).
4. English must never use spaCy for PRE-TTS segmentation — only for subtitles.
## Guarded by tests
- `tests/test_split_pattern.py` — English newline-only; non-English sentence
patterns; CJK behavior.
- `tests/test_domain_conversion_pipeline.py``tts_segments` / spaCy
segmentation helpers.
- Full suite: `python -m pytest tests/ -q` (expect 1566+ passing).
+6 -1
View File
@@ -11,6 +11,7 @@ Called by shutdown.py at process exit and by run_conversion() per-conversion.
from __future__ import annotations
import gc
import sys
from typing import Callable
_UI_CLEANUPS: list[Callable[[], None]] = []
@@ -19,8 +20,12 @@ _UI_CLEANUPS: list[Callable[[], None]] = []
def flush_cuda() -> None:
"""Run GC and release CUDA cache. Safe to call multiple times."""
gc.collect()
# Skip entirely if torch was never imported — importing it here just to
# check would add several seconds to shutdown with nothing to flush.
if "torch" not in sys.modules:
return
try:
import torch
torch = sys.modules["torch"]
if torch.cuda.is_available():
torch.cuda.empty_cache()
torch.cuda.ipc_collect()
+26 -9
View File
@@ -60,7 +60,7 @@ def spacy_pre_tts_segmentation(
text_segments is a list of sentences (always at least one element).
active_split_pattern is the regex to use for TTS backend splitting.
"""
from abogen.domain.split_pattern import PUNCTUATION_COMMAS, get_split_pattern
from abogen.domain.split_pattern import get_split_pattern
def _log(msg: str) -> None:
if log_callback:
@@ -99,20 +99,19 @@ def spacy_pre_tts_segmentation(
_log(f"spaCy: Text segmented into {len(spacy_sentences)} sentences...")
# Compute split_pattern override based on subtitle mode
spacing_pattern = r"\s*" if lang_enum in _CJK_LANGS else r"\s+"
if subtitle_mode_str == "Sentence + Comma":
active_split = r"(?<=[{}]){}|\n+".format(PUNCTUATION_COMMAS, spacing_pattern)
else:
# Sentence mode: spaCy already split, only split on newlines
active_split = "\n"
# spaCy already split at sentence boundaries; the engine only needs to
# split on newlines. Commas are never used in the engine split pattern
# for non-English (Sentence + Comma splits at commas only at subtitle
# time, like English).
active_split = "\n"
return spacy_sentences, active_split
def _to_language_enum(lang_code: Any) -> Language:
"""Convert lang_code to Language enum (ISO code or Language enum)."""
if isinstance(lang_code, Language):
return lang_code
try:
return Language.from_str(str(lang_code))
except ValueError:
@@ -174,6 +173,8 @@ def tts_segments(
segment_iter = backend(text, **kwargs)
chunk_start = current_time
prev_tokens: Optional[List[Dict[str, Any]]] = None
prev_was_fallback = True
for segment in segment_iter:
graphemes_raw = getattr(segment, "graphemes", "") or ""
@@ -186,8 +187,10 @@ def tts_segments(
duration = len(audio) / SAMPLE_RATE
tokens_list = getattr(segment, "tokens", [])
was_fallback = False
if not tokens_list and graphemes:
tokens_list = [FakeToken(graphemes, 0, duration)]
was_fallback = True
tokens = [
{
@@ -199,6 +202,18 @@ def tts_segments(
for tok in tokens_list
]
# When the engine splits text on a punctuation pattern, the
# whitespace between segments is consumed by the split. Restore a
# trailing space on the boundary token of the previous segment so
# subtitle processing sees the original spacing (only for real
# per-word tokens; FakeToken fallbacks split via their own logic).
if (
not prev_was_fallback
and prev_tokens
and not prev_tokens[-1].get("whitespace")
):
prev_tokens[-1]["whitespace"] = " "
yield SegmentResult(
graphemes=graphemes,
audio=audio,
@@ -207,6 +222,8 @@ def tts_segments(
tokens=tokens,
)
prev_tokens = tokens
prev_was_fallback = was_fallback
chunk_start += duration
+14 -5
View File
@@ -5,10 +5,10 @@ from __future__ import annotations
from abogen.domain.enums import Language, SubtitleMode
# Canonical punctuation sets covering all supported scripts:
# ASCII (. ! ?), Arabic ؟, CJK (。!?), Devanagari ।
PUNCTUATION_SENTENCE = r".!?؟。!?।"
# ASCII (. ! ?), ellipsis (…), Arabic ؟, CJK (。!?), Devanagari ।
PUNCTUATION_SENTENCE = r".!?؟。!?।"
# Commas: ASCII , CJK fullwidth CJK ideographic 、
PUNCTUATION_SENTENCE_COMMA = r".!?,?。!?،,、।"
PUNCTUATION_SENTENCE_COMMA = r".!?,?。!?،,、।"
PUNCTUATION_COMMAS = ",,、"
@@ -27,9 +27,18 @@ def get_split_pattern(language: Language, subtitle_mode: str) -> str:
except ValueError:
mode = SubtitleMode.DISABLED
# For English, always use newline splitting only
# English: spaCy is NOT used for pre-TTS segmentation (it is only used
# for post-TTS subtitle boundaries), so sentence boundaries for English
# are applied at subtitle time, not in the TTS engine. Disabled, Line,
# Sentence, and Sentence + Comma all keep newline-only engine splitting.
if language in (Language.EN_US, Language.EN_GB):
return "\n"
if mode in (
SubtitleMode.DISABLED,
SubtitleMode.LINE,
SubtitleMode.SENTENCE,
SubtitleMode.SENTENCE_COMMA,
):
return "\n"
# Determine spacing pattern based on language
spacing = r"\s*" if language.is_cjk else r"\s+"
+197 -70
View File
@@ -13,6 +13,34 @@ from typing import List, Optional, Tuple
from abogen.domain.enums import Language, SubtitleMode
from abogen.domain.split_pattern import PUNCTUATION_SENTENCE, PUNCTUATION_SENTENCE_COMMA
_CLOSING_DELIMS = "\"\"\"\"'\"”’»›)]}」』"
def _is_sentence_boundary(
token: dict,
current_sentence: List[dict],
separator: str,
) -> bool:
"""Check whether token ends a sentence, considering closing quotes and brackets."""
ws = token.get("whitespace", "") or ""
if not ws:
return False
# For Line mode, a newline in whitespace or text marks line boundary
if separator == r"\n":
return "\n" in ws or "\n" in str(token.get("text", ""))
text = str(token.get("text", ""))
if re.search(rf"{separator}[{re.escape(_CLOSING_DELIMS)}]*$", text):
return True
if len(current_sentence) >= 2 and text and all(c in _CLOSING_DELIMS for c in text):
prev_text = str(current_sentence[-2].get("text", ""))
if re.search(rf"{separator}$", prev_text):
return True
return False
def process_subtitle_tokens(
tokens_with_timestamps: List[dict],
@@ -42,37 +70,55 @@ def process_subtitle_tokens(
if not tokens_with_timestamps:
return
if not isinstance(language, Language):
try:
language = Language.from_str(str(language))
except ValueError:
language = Language.EN_US
if isinstance(subtitle_mode, SubtitleMode):
subtitle_mode_str = subtitle_mode.value
else:
subtitle_mode_str = str(subtitle_mode)
processed_tokens = tokens_with_timestamps
# 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 = (
use_spacy_segmentation
and subtitle_mode not in [SubtitleMode.DISABLED, SubtitleMode.LINE]
and subtitle_mode_str not in [SubtitleMode.DISABLED.value, SubtitleMode.LINE.value, "Disabled", "Line"]
and language in [Language.EN_US, Language.EN_GB]
and subtitle_mode in [SubtitleMode.SENTENCE, SubtitleMode.SENTENCE_COMMA]
and subtitle_mode_str in [SubtitleMode.SENTENCE.value, SubtitleMode.SENTENCE_COMMA.value, "Sentence", "Sentence + Comma"]
)
if subtitle_mode == SubtitleMode.SENTENCE_HIGHLIGHT:
if subtitle_mode_str in (SubtitleMode.SENTENCE_HIGHLIGHT.value, "Sentence + Highlighting"):
_process_karaoke_highlighting(
processed_tokens, subtitle_entries, max_subtitle_words, fallback_end_time
)
elif subtitle_mode in [SubtitleMode.SENTENCE, SubtitleMode.SENTENCE_COMMA, SubtitleMode.LINE]:
if use_spacy_for_english and subtitle_mode != SubtitleMode.LINE:
elif subtitle_mode_str in [
SubtitleMode.SENTENCE.value,
SubtitleMode.SENTENCE_COMMA.value,
SubtitleMode.LINE.value,
"Sentence",
"Sentence + Comma",
"Line",
]:
if use_spacy_for_english and subtitle_mode_str not in (SubtitleMode.LINE.value, "Line"):
_process_spacy_sentences(
processed_tokens, subtitle_entries, max_subtitle_words,
subtitle_mode, language, fallback_end_time
subtitle_mode_str, language, fallback_end_time
)
else:
_process_regex_sentences(
processed_tokens, subtitle_entries, max_subtitle_words,
subtitle_mode, fallback_end_time
subtitle_mode_str, fallback_end_time
)
else:
# Word count-based grouping (e.g., "5" for 5-word groups)
_process_word_count(
processed_tokens, subtitle_entries, max_subtitle_words,
subtitle_mode, fallback_end_time
subtitle_mode_str, fallback_end_time
)
@@ -91,10 +137,8 @@ def _process_karaoke_highlighting(
current_sentence.append(token)
word_count += 1
# Split sentences based on separator or word count
if (
re.search(separator, token["text"]) and token.get("whitespace") == " "
) or word_count >= max_subtitle_words:
is_boundary = _is_sentence_boundary(token, current_sentence, separator)
if is_boundary or word_count >= max_subtitle_words:
if current_sentence:
# Create karaoke subtitle entry for this sentence
start_time = current_sentence[0]["start"]
@@ -109,13 +153,18 @@ def _process_karaoke_highlighting(
if t.get("end") is not None and t.get("start") is not None
else 0.5
)
duration_cs = int(duration * 100)
try:
duration_cs = int(duration * 100)
except (ValueError, OverflowError, TypeError):
duration_cs = 50
# Add karaoke effect
karaoke_text += f"{{\\kf{duration_cs}}}{t['text']}{t.get('whitespace', '') or ''}"
karaoke_text += f"{{\\kf{duration_cs}}}{t.get('text', '')}{t.get('whitespace', '') or ''}"
subtitle_entries.append(
(start_time, end_time, karaoke_text.strip())
)
text_stripped = karaoke_text.strip()
if text_stripped:
subtitle_entries.append(
(start_time, end_time, text_stripped)
)
current_sentence = []
word_count = 0
@@ -128,9 +177,14 @@ def _process_karaoke_highlighting(
karaoke_text = ""
for t in current_sentence:
duration = t["end"] - t["start"] if t.get("end") and t.get("start") else 0.5
duration_cs = int(duration * 100)
karaoke_text += f"{{\\kf{duration_cs}}}{t['text']}{t.get('whitespace', '') or ''}"
subtitle_entries.append((start_time, end_time, karaoke_text.strip()))
try:
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 ''}"
text_stripped = karaoke_text.strip()
if text_stripped:
subtitle_entries.append((start_time, end_time, text_stripped))
# Fallback for last entry
_apply_fallback_end_time(subtitle_entries, fallback_end_time)
@@ -166,7 +220,7 @@ def _process_spacy_sentences(
# Build full text and track character positions to token indices
full_text = ""
for token in tokens:
text_part = token["text"] + (token.get("whitespace") or "")
text_part = str(token.get("text", "")) + (token.get("whitespace") or "")
full_text += text_part
# Get sentence boundaries from spaCy
@@ -174,7 +228,7 @@ def _process_spacy_sentences(
sentence_boundaries = [sent.end_char for sent in doc.sents]
# For "Sentence + Comma" mode, also split on commas
if subtitle_mode == SubtitleMode.SENTENCE_COMMA:
if subtitle_mode in (SubtitleMode.SENTENCE_COMMA.value, "Sentence + Comma"):
comma_positions = [
i + 1 for i, c in enumerate(full_text) if c == ","
]
@@ -182,6 +236,56 @@ def _process_spacy_sentences(
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
if len(tokens) == 1 and len(sentence_boundaries) > 1:
single = tokens[0]
start_time = single.get("start", 0.0) or 0.0
end_time = single.get("end")
duration = (end_time - start_time) if (end_time is not None and end_time > start_time) else 0.0
prev_pos = 0
cur_start = start_time
total_chars = max(len(full_text), 1)
for i, b_pos in enumerate(sentence_boundaries):
piece = full_text[prev_pos:b_pos].strip()
if not piece:
prev_pos = b_pos
continue
if i == len(sentence_boundaries) - 1:
cur_end = end_time if end_time is not None else (cur_start + 1.0)
else:
cur_end = cur_start + duration * len(piece) / total_chars
subtitle_entries.append((cur_start, cur_end, piece))
cur_start = cur_end
prev_pos = b_pos
if prev_pos < len(full_text):
remainder = full_text[prev_pos:].strip()
if 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)
return
# Group tokens by sentence boundaries
current_sentence = []
word_count = 0
@@ -191,7 +295,7 @@ def _process_spacy_sentences(
for token in tokens:
current_sentence.append(token)
word_count += 1
text_len = len(token["text"]) + len(token.get("whitespace") or "")
text_len = len(str(token.get("text", ""))) + len(token.get("whitespace") or "")
current_char_pos += text_len
# Check if we've hit a sentence boundary or max words
@@ -204,15 +308,19 @@ def _process_spacy_sentences(
start_time = current_sentence[0]["start"]
end_time = current_sentence[-1]["end"]
sentence_text = "".join(
t["text"] + (t.get("whitespace") or "")
str(t.get("text", "")) + (t.get("whitespace") or "")
for t in current_sentence
)
subtitle_entries.append(
(start_time, end_time, sentence_text.strip())
)
).strip()
if sentence_text:
subtitle_entries.append(
(start_time, end_time, sentence_text)
)
current_sentence = []
word_count = 0
if at_boundary:
while (
boundary_idx < len(sentence_boundaries)
and current_char_pos >= sentence_boundaries[boundary_idx]
):
boundary_idx += 1
# Add remaining tokens
@@ -220,12 +328,13 @@ def _process_spacy_sentences(
start_time = current_sentence[0]["start"]
end_time = current_sentence[-1]["end"]
sentence_text = "".join(
t["text"] + (t.get("whitespace") or "")
str(t.get("text", "")) + (t.get("whitespace") or "")
for t in current_sentence
)
subtitle_entries.append(
(start_time, end_time, sentence_text.strip())
)
).strip()
if sentence_text:
subtitle_entries.append(
(start_time, end_time, sentence_text)
)
# Fallback for last entry
_apply_fallback_end_time(subtitle_entries, fallback_end_time)
@@ -240,9 +349,9 @@ def _process_regex_sentences(
) -> None:
"""Process tokens using regex for sentence boundary detection."""
# Define separator pattern based on mode
if subtitle_mode == SubtitleMode.LINE:
if subtitle_mode in (SubtitleMode.LINE.value, "Line"):
separator = r"\n"
elif subtitle_mode == SubtitleMode.SENTENCE:
elif subtitle_mode in (SubtitleMode.SENTENCE.value, "Sentence"):
separator = rf"[{PUNCTUATION_SENTENCE}]"
else: # Sentence + Comma
separator = rf"[{PUNCTUATION_SENTENCE_COMMA}]"
@@ -255,22 +364,22 @@ def _process_regex_sentences(
word_count += 1
# Split sentences based on separator or word count
if (
re.search(separator, token["text"]) and token.get("whitespace") == " "
) or word_count >= max_subtitle_words:
is_boundary = _is_sentence_boundary(token, current_sentence, separator)
if is_boundary or word_count >= max_subtitle_words:
if current_sentence:
# Create subtitle entry for this sentence
start_time = current_sentence[0]["start"]
end_time = current_sentence[-1]["end"]
# Simplified text joining logic
sentence_text = ""
for t in current_sentence:
sentence_text += t["text"] + (t.get("whitespace") or "")
sentence_text = "".join(
str(t.get("text", "")) + (t.get("whitespace") or "")
for t in current_sentence
).strip()
subtitle_entries.append(
(start_time, end_time, sentence_text.strip())
)
if sentence_text:
subtitle_entries.append(
(start_time, end_time, sentence_text)
)
current_sentence = []
word_count = 0
@@ -279,23 +388,39 @@ def _process_regex_sentences(
start_time = current_sentence[0]["start"]
end_time = current_sentence[-1]["end"]
sentence_text = ""
for t in current_sentence:
sentence_text += t["text"] + (t.get("whitespace") or "")
sentence_text = sentence_text.strip()
sentence_text = "".join(
str(t.get("text", "")) + (t.get("whitespace") or "")
for t in current_sentence
).strip()
if len(current_sentence) == 1:
parts = re.split(rf"(?<={separator})\s+", sentence_text)
split_pat = (
r"\n+"
if separator == r"\n"
else rf"(?<={separator})\s+|(?<={separator}[{re.escape(_CLOSING_DELIMS)}])\s+"
)
parts = [p.strip() for p in re.split(split_pat, sentence_text) if p.strip()]
if len(parts) > 1:
d = end_time - start_time
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)
cur_s = start_time if start_time is not None else 0.0
for i, p in enumerate(parts):
e = end_time if i == len(parts) - 1 else start_time + d * len(p) / len(sentence_text)
subtitle_entries.append((start_time, e, p.strip()))
start_time = e
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))
cur_s = e
current_sentence = []
if current_sentence:
subtitle_entries.append((start_time, end_time, sentence_text))
if current_sentence and 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
_apply_fallback_end_time(subtitle_entries, fallback_end_time)
@@ -328,27 +453,29 @@ def _process_word_count(
# Split after counting N spaces
if space_count >= word_count:
text = "".join(
t["text"] + (t.get("whitespace") or "")
str(t.get("text", "")) + (t.get("whitespace") or "")
for t in current_group
)
subtitle_entries.append(
(
current_group[0]["start"],
current_group[-1]["end"],
text.strip(),
).strip()
if text:
subtitle_entries.append(
(
current_group[0]["start"],
current_group[-1]["end"],
text,
)
)
)
current_group = []
space_count = 0
# Add any remaining tokens
if current_group:
text = "".join(
t["text"] + (t.get("whitespace") or "") for t in current_group
)
subtitle_entries.append(
(current_group[0]["start"], current_group[-1]["end"], text.strip())
)
str(t.get("text", "")) + (t.get("whitespace") or "") for t in current_group
).strip()
if text:
subtitle_entries.append(
(current_group[0]["start"], current_group[-1]["end"], text)
)
# Fallback for last entry
_apply_fallback_end_time(subtitle_entries, fallback_end_time)
+18 -3
View File
@@ -220,8 +220,10 @@ class AssWriter(SubtitleWriter):
style = "Default"
if self.config.mode == SubtitleMode.SENTENCE_HIGHLIGHT:
# Add karaoke tags for highlighting
text = self._add_karaoke_tags(text)
# Entries from process_subtitle_tokens already carry per-word
# {\kf...} timing; only synthesize simplified tags when absent.
if "{\\k" not in text:
text = self._add_karaoke_tags(text)
style = "Highlight"
alignment_tag = r"{\an5}" if self._is_centered else ""
@@ -248,6 +250,19 @@ class AssWriter(SubtitleWriter):
return f"{hours}:{minutes:02d}:{secs:05.2f}"
def _coerce_mode(mode: str) -> SubtitleMode:
"""Parse a subtitle mode, tolerating word-count strings like "5 words".
Word-count modes are grouped upstream (subtitle_generation) and the writer
only branches on SubtitleMode.SENTENCE_HIGHLIGHT, so any non-highlight
fallback is behaviorally equivalent for the writers.
"""
try:
return SubtitleMode(mode)
except ValueError:
return SubtitleMode.SENTENCE
def create_subtitle_writer(
path: Path,
format: str,
@@ -257,7 +272,7 @@ def create_subtitle_writer(
) -> SubtitleWriter:
"""Factory function to create subtitle writer."""
fmt = SubtitleFormat(format.lower())
mode = SubtitleMode(mode)
mode = _coerce_mode(mode)
align = SubtitleAlignment(alignment.lower())
config = SubtitleConfig(
+63 -15
View File
@@ -672,6 +672,15 @@ def tokenize_with_spans(text: str) -> List[Tuple[str, int, int]]:
]
_OPENING_PUNCTUATION_CHARS = "«‹“‘([{¡¿「『"
_CLOSING_PUNCTUATION_CHARS = "»›”’)]}」』"
_STANDARD_PUNCTUATION_CHARS = ",.;:!?%"
_OPENING_PUNCT_CLASS = re.escape(_OPENING_PUNCTUATION_CHARS)
_CLOSING_PUNCT_CLASS = re.escape(_CLOSING_PUNCTUATION_CHARS)
_STANDARD_PUNCT_CLASS = re.escape(_STANDARD_PUNCTUATION_CHARS)
def _cleanup_spacing(text: str) -> str:
if not text:
return text
@@ -679,22 +688,39 @@ def _cleanup_spacing(text: str) -> str:
for marker in ("\ufeff", "\u200b", "\u200c", "\u200d", "\u2060"):
text = text.replace(marker, "")
# Collapse spaces before closing punctuation.
text = re.sub(r"\s+([,.;:!?%])", r"\1", text)
text = re.sub(r"\s+([\"”»›)\]\}])", r"\1", text)
# Collapse spaces before standard punctuation and unambiguous closing quotes/brackets.
text = re.sub(rf"\s+([{_STANDARD_PUNCT_CLASS}])", r"\1", text)
text = re.sub(rf"\s+([{_CLOSING_PUNCT_CLASS}])", r"\1", text)
# Remove spaces directly after opening punctuation/quotes.
text = re.sub(r"([«‹“‘\"'(\[\{])\s+", r"\1", text)
# Remove spaces directly after unambiguous opening punctuation/quotes.
text = re.sub(rf"([{_OPENING_PUNCT_CLASS}])\s+", r"\1", text)
# Handle ambiguous straight quotes (\", ')
# 1. Remove spaces directly after opening straight quotes:
# e.g. ' \" word' -> ' \"word', '^\" word' -> '\"word', '(\" word' -> '(\"word'
text = re.sub(rf"(^|[\s{_OPENING_PUNCT_CLASS}])([\"\'])\s+", r"\1\2", text)
# 2. Collapse spaces directly before closing straight quotes:
# e.g. 'word \" ' -> 'word\" ', 'word \".' -> 'word\".'
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.
text = re.sub(r"([,.;:!?%])(?![\s”'\"’»›)])", r"\1 ", text)
text = re.sub(r"([”\"])(?![\s.,;:!?\"”’»›)])", 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')
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
text = re.sub(rf"(\S\")([A-Za-z0-9{_OPENING_PUNCT_CLASS}])", r"\1 \2", text)
# Straight single quote closing (preceded by punctuation, not internal word apostrophe) followed by a word
text = re.sub(rf"([{_STANDARD_PUNCT_CLASS}{_CLOSING_PUNCT_CLASS}]\')([A-Za-z0-9{_OPENING_PUNCT_CLASS}])", r"\1 \2", text)
# Tighten hyphen/em dash spacing between word characters.
text = re.sub(r"(?<=\w)\s*([-–—])\s*(?=\w)", r"\1", text)
# Normalize multiple spaces.
text = re.sub(r"\s{2,}", " ", text)
# Normalize multiple spaces, preserving paragraph breaks (double
# 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()
@@ -1622,8 +1648,18 @@ def normalize_apostrophes(
results.append((tok, category, norm))
normalized_tokens.append(norm)
filtered = [token for token in normalized_tokens if token]
normalized_text = _cleanup_spacing(" ".join(filtered))
out_pieces: List[str] = []
last_end = 0
for (tok, start, end), norm in zip(token_entries, normalized_tokens):
if start > last_end:
out_pieces.append(text[last_end:start])
out_pieces.append(norm)
last_end = end
if last_end < len(text):
out_pieces.append(text[last_end:])
reconstructed = "".join(out_pieces)
normalized_text = _cleanup_spacing(reconstructed)
return normalized_text, results
@@ -1824,7 +1860,10 @@ def _normalize_grouped_numbers(text: str, cfg: ApostropheConfig) -> str:
for digit in trimmed_fraction:
if not digit.isdigit():
return token
digit_words.append(_DIGIT_WORDS[int(digit)])
try:
digit_words.append(_DIGIT_WORDS[int(digit)])
except (ValueError, IndexError):
return token
spoken = f"{integer_words} point {' '.join(digit_words)}"
return f"minus {spoken}" if is_negative else spoken
@@ -1846,18 +1885,27 @@ def _normalize_grouped_numbers(text: str, cfg: ApostropheConfig) -> str:
# Magnitude case: $2.5 million -> two point five million dollars
if "." in amount_str:
integer_part, fraction_part = amount_str.split(".", 1)
integer_val = int(integer_part)
try:
integer_val = int(integer_part)
except ValueError:
return match.group(0)
integer_words = _int_to_words(integer_val, language)
# Spell out fraction digits
digit_words = []
for digit in fraction_part:
if digit.isdigit():
digit_words.append(_DIGIT_WORDS[int(digit)])
try:
digit_words.append(_DIGIT_WORDS[int(digit)])
except (ValueError, IndexError):
return match.group(0)
amount_spoken = f"{integer_words} point {' '.join(digit_words)}"
else:
amount_spoken = _int_to_words(int(amount), language)
try:
amount_spoken = _int_to_words(int(amount), language)
except (ValueError, OverflowError):
return match.group(0)
currency_names = {
"$": "dollars",
+13 -16
View File
@@ -1,5 +1,6 @@
import os
import time
import logging
import hashlib # For generating unique cache filenames
from pathlib import Path
from platformdirs import user_desktop_dir
@@ -50,6 +51,8 @@ import abogen.hf_tracker as hf_tracker
import static_ffmpeg
import threading # for efficient waiting
logger = logging.getLogger(__name__)
# Configuration constants
@@ -64,7 +67,6 @@ from abogen.subtitle_utils import (
sanitize_name_for_os,
split_text_by_voice_markers
)
from abogen.domain.split_pattern import PUNCTUATION_COMMAS
class CountdownDialog(QDialog):
"""Base dialog with auto-accept countdown functionality"""
@@ -348,7 +350,7 @@ class ConversionThread(QThread):
return samples_processed
def run(self):
print(
logger.info(
f"\nVoice: {self.voice}\nLanguage: {self.lang_code}\nSpeed: {self.speed}\nGPU: {self.use_gpu}\nFile: {self.file_name}\nSubtitle mode: {self.subtitle_mode}\nOutput format: {self.output_format}\nSave option: {self.save_option}\n"
)
try:
@@ -873,7 +875,6 @@ class ConversionThread(QThread):
)
spacy_sentences = None
active_split_pattern = self.split_pattern
spacing_pattern = r"\s*" if self.lang_code in (Language.JA, Language.ZH) else r"\s+"
# Pre-load spaCy model for English if it will be needed for subtitle generation
if (
@@ -914,15 +915,11 @@ class ConversionThread(QThread):
"grey",
)
)
# For Sentence + Comma mode, still split on commas within spaCy sentences
if self.subtitle_mode == "Sentence + Comma":
active_split_pattern = r"(?<=[{}]){}|\n+".format(
PUNCTUATION_COMMAS, spacing_pattern
)
else:
active_split_pattern = (
"\n" # Use newline splitting for Sentence mode
)
# spaCy already split at sentence boundaries; the
# engine only splits on newlines. Commas are never
# used in the engine split pattern (Sentence +
# Comma splits at commas only at subtitle time).
active_split_pattern = "\n"
else:
self.log_updated.emit(
("\nspaCy: Fallback to default segmentation...", "grey")
@@ -933,10 +930,10 @@ class ConversionThread(QThread):
# Print active split pattern used by the TTS engine once for this batch
try:
print(f"Using split pattern: {active_split_pattern!r}")
logger.info(f"Using split pattern: {active_split_pattern!r}")
except Exception:
# Print must never break processing
print("Using split pattern: (unprintable)")
# Logging must never break processing
logger.warning("Using split pattern: (unprintable)")
for text_segment in text_segments:
def _qt_check_cancel() -> bool:
@@ -1445,7 +1442,7 @@ class VoicePreviewThread(QThread):
return os.path.join(self.cache_dir, filename)
def run(self):
print(
logger.info(
f"\nVoice: {self.voice}\nLanguage: {self.lang_code}\nSpeed: {self.speed}\nGPU: {self.use_gpu}\n"
)
+123 -71
View File
@@ -5,9 +5,12 @@ import tempfile
import platform
import base64
import re
import logging
from abogen.pyqt.queue_manager_gui import QueueManager
from abogen.pyqt.queued_item import QueuedItem
_log = logging.getLogger("abogen.gui")
import abogen.hf_tracker as hf_tracker
import hashlib # Added for cache path generation
from PyQt6.QtWidgets import (
@@ -134,6 +137,28 @@ class ThreadSafeLogSignal(QObject):
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):
def icon(self, fileInfo):
return super().icon(fileInfo)
@@ -1016,6 +1041,7 @@ class abogen(QWidget):
self.selected_lang = (
language_for_voice_id(entry[0]) if entry and entry[0] else Language.EN_US
)
self.update_subtitle_options_availability()
if self.save_option == "Choose output folder" and self.selected_output_folder:
self.save_path_label.setText(self.selected_output_folder)
self.save_path_row_widget.show()
@@ -3169,14 +3195,25 @@ class abogen(QWidget):
save_config(self.config)
def cleanup_conversion_thread(self):
# Stop conversion thread
# Stop conversion thread (bounded wait so closing never hangs)
if (
hasattr(self, "conversion_thread")
and self.conversion_thread is not None
and self.conversion_thread.isRunning()
):
_log.info("Close: stopping conversion thread")
start = time.perf_counter()
self.conversion_thread.cancel()
self.conversion_thread.wait()
if not self.conversion_thread.wait(2000):
_log.warning("Close: conversion thread did not stop in 2s, terminating")
self.conversion_thread.terminate()
self.conversion_thread.wait(1000)
_log.info(
"Close: conversion thread stopped in %.2fs",
time.perf_counter() - start,
)
else:
_log.info("Close: no running conversion thread")
def cleanup_preview_threads(self):
# Stop preview generation thread
@@ -3185,8 +3222,13 @@ class abogen(QWidget):
and self.preview_thread is not None
and self.preview_thread.isRunning()
):
_log.info("Close: terminating preview thread")
start = time.perf_counter()
self.preview_thread.terminate()
self.preview_thread.wait()
self.preview_thread.wait(1000)
_log.info(
"Close: preview thread stopped in %.2fs", time.perf_counter() - start
)
# Stop audio playback thread
if (
@@ -3194,8 +3236,13 @@ class abogen(QWidget):
and self.play_audio_thread is not None
and self.play_audio_thread.isRunning()
):
_log.info("Close: stopping audio playback thread")
start = time.perf_counter()
self.play_audio_thread.stop()
self.play_audio_thread.wait()
self.play_audio_thread.wait(1000)
_log.info(
"Close: audio thread stopped in %.2fs", time.perf_counter() - start
)
# Cleanup pygame mixer if initialized
try:
@@ -3206,6 +3253,7 @@ class abogen(QWidget):
pass
def closeEvent(self, event):
_log.info("Close: window close requested (converting=%s)", self.is_converting)
if self.is_converting:
box = QMessageBox(self)
box.setIcon(QMessageBox.Icon.Warning)
@@ -3218,16 +3266,14 @@ class abogen(QWidget):
)
box.setDefaultButton(QMessageBox.StandardButton.No)
if box.exec() == QMessageBox.StandardButton.Yes:
from abogen import shutdown
shutdown.request_shutdown()
_log.info("Close: user confirmed exit during conversion")
self.cleanup_conversion_thread()
self.cleanup_preview_threads()
event.accept()
else:
_log.info("Close: user cancelled exit")
event.ignore()
else:
from abogen import shutdown
shutdown.request_shutdown()
self.cleanup_conversion_thread()
self.cleanup_preview_threads()
event.accept()
@@ -4053,75 +4099,85 @@ Categories=AudioVideo;Audio;Utility;
self.check_for_updates_startup()
def check_for_updates_startup(self):
import urllib.request
def show_update_message(remote_version, local_version):
msg_box = QMessageBox(self)
msg_box.setIcon(QMessageBox.Icon.Information)
msg_box.setWindowTitle("Update Available")
msg_box.setText(
f"A new version of {PROGRAM_NAME} is available! ({local_version} > {remote_version})"
)
msg_box.setInformativeText(
f"If you installed via pip, update by running:\n"
f"pip install --upgrade {PROGRAM_NAME}\n\n"
f"If you're using the Windows portable version, run 'WINDOWS_INSTALL.bat' again.\n\n"
"Alternatively, visit the GitHub repository for more information. "
"Would you like to view the changelog?"
)
msg_box.setStandardButtons(
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
msg_box.setDefaultButton(QMessageBox.StandardButton.Yes)
if msg_box.exec() == QMessageBox.StandardButton.Yes:
try:
QDesktopServices.openUrl(QUrl(GITHUB_URL + "/releases/latest"))
except Exception:
pass
# Reset flag to track if we should show "no updates" message
# 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 _on_update_check_done(self, remote_raw, show_result):
remote_version = remote_raw.strip()
local_version = VERSION
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
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}).",
)
# Parse version numbers
remote_version = remote_raw
local_version = local_raw
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.setIcon(QMessageBox.Icon.Information)
msg_box.setWindowTitle("Update Available")
msg_box.setText(
f"A new version of {PROGRAM_NAME} is available! ({local_version} > {remote_version})"
)
msg_box.setInformativeText(
f"If you installed via pip, update by running:\n"
f"pip install --upgrade {PROGRAM_NAME}\n\n"
f"If you're using the Windows portable version, run 'WINDOWS_INSTALL.bat' again.\n\n"
"Alternatively, visit the GitHub repository for more information. "
"Would you like to view the changelog?"
)
msg_box.setStandardButtons(
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
msg_box.setDefaultButton(QMessageBox.StandardButton.Yes)
if msg_box.exec() == QMessageBox.StandardButton.Yes:
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
QDesktopServices.openUrl(QUrl(GITHUB_URL + "/releases/latest"))
except Exception:
pass
def clear_cache_files(self):
"""Clear cache files created by the program."""
@@ -4224,8 +4280,6 @@ Categories=AudioVideo;Audio;Utility;
def set_max_log_lines(self):
"""Open a dialog to set the maximum lines in the log window."""
from PyQt6.QtWidgets import QInputDialog
value, ok = QInputDialog.getInt(
self,
"Max Lines in Log Window",
@@ -4247,8 +4301,6 @@ Categories=AudioVideo;Audio;Utility;
def set_max_subtitle_words(self):
"""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"])
value, ok = QInputDialog.getInt(
+8 -1
View File
@@ -164,6 +164,9 @@ def main():
with timed_log("QApplication creation", logger=_log):
app = QApplication(sys.argv)
# Qt shutdown hook must be connected AFTER QApplication exists
shutdown.install_qt_hook()
# Set application icon using get_resource_path from utils
icon_path = get_resource_path("abogen.assets", "icon.ico")
if icon_path:
@@ -181,7 +184,11 @@ def main():
with timed_log("window show", logger=_log):
ex.show()
_log.info("App startup complete. Showing window.")
sys.exit(app.exec())
rc = app.exec()
# Restore the default Qt message handler BEFORE interpreter shutdown.
# A Python message handler invoked during Qt teardown segfaults (SIGSEGV).
qInstallMessageHandler(None)
sys.exit(rc)
if __name__ == "__main__":
+21 -3
View File
@@ -14,10 +14,14 @@ Per-conversion cleanup lives in run_conversion() finally block.
from __future__ import annotations
import atexit
import logging
import signal
import sys
import time
from typing import Callable
_log = logging.getLogger("abogen.shutdown")
_CLEANUP_FUNCS: list[Callable[[], None]] = []
_EXECUTED = False
@@ -32,11 +36,17 @@ def _run_cleanups() -> None:
if _EXECUTED:
return
_EXECUTED = True
_log.info("Shutdown: starting %d cleanup hook(s)", len(_CLEANUP_FUNCS))
for fn in _CLEANUP_FUNCS:
start = time.perf_counter()
try:
fn()
except Exception:
pass
_log.info(
"Shutdown: %s done in %.2fs", fn.__name__, time.perf_counter() - start
)
_log.info("Shutdown: all cleanups finished")
# ---- Process-level cleanup functions ----
@@ -117,13 +127,19 @@ def register_shutdown() -> None:
except Exception:
pass
# Qt hook — connect AFTER QApplication is created
install_qt_hook()
def install_qt_hook() -> None:
"""Connect Qt aboutToQuit to cleanup. Must run AFTER QApplication is created."""
try:
from PyQt6.QtWidgets import QApplication
app = QApplication.instance()
if app is not None:
if app is not None and not getattr(app, "_abogen_cleanup_connected", False):
app.aboutToQuit.connect(_run_cleanups)
app._abogen_cleanup_connected = True
_log.info("Shutdown: Qt aboutToQuit hook connected")
except Exception:
pass
@@ -132,13 +148,15 @@ register_shutdown._registered = False
def _on_signal(signum: int, _frame) -> None:
_log.info("Shutdown: signal %s received", signum)
_run_cleanups()
sys.exit(0)
def request_shutdown() -> None:
"""Programmatically trigger cleanup (e.g., from GUI closeEvent)."""
_log.info("Shutdown: cleanup requested")
_run_cleanups()
__all__ = ["register_shutdown", "request_shutdown", "register_cleanup"]
__all__ = ["register_shutdown", "install_qt_hook", "request_shutdown", "register_cleanup"]
+42
View File
@@ -79,6 +79,44 @@ class SynthesisRequest:
format: AudioFormat
@dataclass(frozen=True)
class TokenTiming:
"""Per-token timing within a synthesized segment.
Attributes:
text: Token text.
whitespace: Whitespace following the token ("" if none).
start: Start time in seconds (relative to segment start).
end: End time in seconds (relative to segment start).
"""
text: str
whitespace: str = ""
start: float = 0.0
end: float = 0.0
@dataclass(frozen=True)
class AudioSegment:
"""One contiguous synthesized segment (sentence-level chunk).
Engines that split the input text (via ``split_pattern``) expose each
chunk as its own AudioSegment so hosts can report per-sentence progress
and build subtitles from per-token timings.
Attributes:
graphemes: The text this segment was synthesized from.
audio: Raw float32 PCM audio bytes for this segment.
sample_rate: Sample rate of ``audio``.
tokens: Per-token timing details, when the engine provides them.
"""
graphemes: str
audio: bytes
sample_rate: int
tokens: tuple[TokenTiming, ...] = ()
@dataclass(frozen=True)
class SynthesizedAudio:
"""Immutable value object for synthesized audio result.
@@ -87,11 +125,15 @@ class SynthesizedAudio:
data: Raw audio bytes.
format: Audio format of the result.
duration: Duration of the audio.
segments: Per-segment details when the engine split the text into
sentence-level chunks (empty for engines that only produce a
single merged result).
"""
data: bytes
format: AudioFormat
duration: Duration
segments: tuple[AudioSegment, ...] = ()
@dataclass(frozen=True)
+25 -2
View File
@@ -169,15 +169,38 @@ class Pipeline:
)
result = session.synthesize(request)
audio_array = np.frombuffer(result.data, dtype=np.float32)
from dataclasses import dataclass
from dataclasses import dataclass, field
@dataclass
class Token:
text: str
whitespace: str = ""
start_ts: float = 0.0
end_ts: float = 0.0
@dataclass
class Segment:
graphemes: str
audio: np.ndarray
tokens: list[Any] = field(default_factory=list)
if result.segments:
for seg in result.segments:
audio_array = np.frombuffer(seg.audio, dtype=np.float32)
tokens = [
Token(
text=tok.text,
whitespace=tok.whitespace,
start_ts=tok.start,
end_ts=tok.end,
)
for tok in seg.tokens
]
yield Segment(graphemes=seg.graphemes, audio=audio_array, tokens=tokens)
return
audio_array = np.frombuffer(result.data, dtype=np.float32)
yield Segment(graphemes=text, audio=audio_array)
def load_single_voice(self, voice_name: str) -> Any:
+5 -7
View File
@@ -16,6 +16,8 @@ from functools import lru_cache
from dotenv import load_dotenv, find_dotenv
logger = logging.getLogger(__name__)
def _load_environment() -> None:
explicit_path = os.environ.get("ABOGEN_ENV_FILE")
@@ -441,10 +443,6 @@ default_encoding = sys.getfilesystemencoding()
def create_process(cmd, stdin=None, text=True, capture_output=False):
import logging
logger = logging.getLogger(__name__)
# Configure root logger to output to console if not already configured
root = logging.getLogger()
if not root.handlers:
@@ -493,8 +491,8 @@ def create_process(cmd, stdin=None, text=True, capture_output=False):
}
)
# Print the command being executed
print(f"Executing: {cmd if isinstance(cmd, str) else ' '.join(cmd)}")
# Log the command being executed
logger.info(f"Executing: {cmd if isinstance(cmd, str) else ' '.join(cmd)}")
proc = subprocess.Popen(cmd, **kwargs)
@@ -615,7 +613,7 @@ def prevent_sleep_start():
)
else:
# Non-systemd distro or systemd tools not installed: skip inhibition rather than crash
print(
logger.warning(
"systemd-inhibit not found: skipping sleep inhibition on this Linux system."
)
+28 -2
View File
@@ -22,9 +22,11 @@ from abogen.tts_plugin.errors import EngineError
from abogen.tts_plugin.manifest import VoiceManifest
from abogen.tts_plugin.types import (
AudioFormat,
AudioSegment,
Duration,
SynthesisRequest,
SynthesizedAudio,
TokenTiming,
)
logger = logging.getLogger(__name__)
@@ -117,7 +119,9 @@ class KokoroSession:
speed = request.parameters.values.get("speed", 1.0)
split_pattern = request.parameters.values.get("split_pattern", None)
sample_rate = _KOKORO_SAMPLE_RATE
audio_parts: list[np.ndarray] = []
segments: list[AudioSegment] = []
for segment in self._pipeline(
request.text,
voice=voice,
@@ -127,7 +131,28 @@ class KokoroSession:
audio = segment.audio
if hasattr(audio, "numpy"):
audio = audio.numpy()
audio_parts.append(np.asarray(audio, dtype="float32"))
audio = np.asarray(audio, dtype="float32")
if audio.size == 0:
continue
audio_parts.append(audio)
tokens = tuple(
TokenTiming(
text=str(tok.text),
whitespace=str(tok.whitespace or ""),
start=float(tok.start_ts or 0.0),
end=float(tok.end_ts or 0.0),
)
for tok in (getattr(segment, "tokens", None) or [])
)
segments.append(
AudioSegment(
graphemes=str(getattr(segment, "graphemes", "") or ""),
audio=audio.tobytes(),
sample_rate=sample_rate,
tokens=tokens,
)
)
if not audio_parts:
return SynthesizedAudio(
@@ -138,12 +163,13 @@ class KokoroSession:
combined = np.concatenate(audio_parts).astype("float32", copy=False)
audio_bytes = combined.tobytes()
duration_seconds = len(combined) / _KOKORO_SAMPLE_RATE
duration_seconds = len(combined) / sample_rate
return SynthesizedAudio(
data=audio_bytes,
format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=duration_seconds),
segments=tuple(segments),
)
except EngineError:
raise
+14 -1
View File
@@ -19,6 +19,7 @@ from abogen.tts_plugin.errors import EngineError
from abogen.tts_plugin.manifest import VoiceManifest
from abogen.tts_plugin.types import (
AudioFormat,
AudioSegment,
Duration,
SynthesisRequest,
SynthesizedAudio,
@@ -113,6 +114,7 @@ class SuperTonicSession:
total_steps = int(total_steps)
audio_parts: list[np.ndarray] = []
segments: list[AudioSegment] = []
for segment in self._pipeline(
request.text,
voice=voice,
@@ -120,7 +122,17 @@ class SuperTonicSession:
split_pattern=split_pattern,
total_steps=total_steps,
):
audio_parts.append(segment.audio)
audio = np.asarray(segment.audio, dtype="float32")
if audio.size == 0:
continue
audio_parts.append(audio)
segments.append(
AudioSegment(
graphemes=str(getattr(segment, "graphemes", "") or ""),
audio=audio.tobytes(),
sample_rate=self._pipeline.sample_rate,
)
)
if not audio_parts:
return SynthesizedAudio(
@@ -139,6 +151,7 @@ class SuperTonicSession:
data=audio_bytes,
format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=duration_seconds),
segments=tuple(segments),
)
except EngineError:
raise
+7
View File
@@ -90,3 +90,10 @@ def test_manual_override_normalization():
assert normalize_manual_override_token("The") == "the"
assert normalize_manual_override_token(" A ") == "a"
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
+1 -1
View File
@@ -9,7 +9,7 @@ from abogen.domain.enums import Language
from abogen.domain.split_pattern import get_split_pattern
# --- English always returns \n ---
# --- English: newline-only for Disabled/Line, punctuation-based for sentence modes ---
class TestEnglish:
def test_english_sentence(self):
+402
View File
@@ -0,0 +1,402 @@
"""Comprehensive tests for subtitle generation across different models, modes, and text scenarios.
Tests include:
- Quotation mark handling (straight quotes, curly quotes, guillemets, dialogs)
- No spurious spaces after opening quotes (e.g., test "word word" vs test " word word")
- Proper sentence boundary detection for quoted dialogues (e.g., "Hello." She said.)
- Paragraph handling and multi-line text
- All subtitle modes (Line, Sentence, Sentence + Comma, Sentence + Highlighting, N-words)
- Both TTS model token styles (Kokoro per-word tokens and Supertonic FakeTokens)
- Non-English and multilingual scenarios
"""
import pytest
from abogen.domain.enums import Language, SubtitleMode
from abogen.domain.normalization import prepare_text_for_tts
from abogen.domain.subtitle_generation import (
process_subtitle_tokens,
PUNCTUATION_SENTENCE,
PUNCTUATION_SENTENCE_COMMA,
)
class TestQuoteNormalizationAndSpacing:
"""Verify text normalization correctly preserves quotation mark spacing."""
def test_straight_quote_mid_sentence_no_extra_space(self):
"""Input 'test "word word"' should keep space before quote and no space after."""
text = 'test "word word"'
normalized = prepare_text_for_tts(text)
assert '" word' not in normalized
assert 'test "' in normalized or 'test "word' in normalized
def test_straight_quote_at_start_no_extra_space(self):
"""Input '"word word"' should not have a leading space after opening quote."""
text = '"word word"'
normalized = prepare_text_for_tts(text)
assert not normalized.startswith('" ')
assert normalized.startswith('"word')
def test_dialogue_quote_spacing(self):
"""He said, "Hello world." should preserve proper comma-space-quote-word sequence."""
text = 'He said, "Hello world."'
normalized = prepare_text_for_tts(text)
assert 'said, "' in normalized or 'said,"' not in normalized
assert '" Hello' not in normalized
assert '"Hello' in normalized
def test_quote_with_contraction(self):
"""Contraction inside quotes like "Don't go!" should expand cleanly without extra spaces."""
text = '"Don\'t go!"'
normalized = prepare_text_for_tts(text)
assert '" Do not' not in normalized
assert '"Do not' in normalized or '"Don\'t' in normalized
def test_curly_quotes_preserved(self):
"""Curly quotes like “Hello world.” should not have spurious spacing."""
text = '“Hello world.”'
normalized = prepare_text_for_tts(text)
assert '' not in normalized
assert '' not in normalized
def test_spanish_opening_punctuation(self):
"""Spanish inverted exclamation ¡Hola! should not have space after ¡."""
text = '¡Hola mundo!'
normalized = prepare_text_for_tts(text)
assert '¡ ' not in normalized
def test_french_guillemets_spacing(self):
"""French guillemets « Bonjour » should clean up spaces properly."""
text = '« Bonjour »'
normalized = prepare_text_for_tts(text)
assert '« ' not in normalized
assert ' »' not in normalized
class TestKokoroPerWordTokenSubtitles:
"""Tests using Kokoro-style per-word tokens with individual timestamps."""
def test_quoted_phrase_subtitles(self):
"""Tokens for 'test "word word"' produce subtitle without space after quote."""
tokens = [
{"start": 0.0, "end": 0.3, "text": "test", "whitespace": " "},
{"start": 0.3, "end": 0.35, "text": '"', "whitespace": ""},
{"start": 0.35, "end": 0.7, "text": "word", "whitespace": " "},
{"start": 0.7, "end": 1.0, "text": "word", "whitespace": ""},
{"start": 1.0, "end": 1.05, "text": '"', "whitespace": ""},
]
entries = []
process_subtitle_tokens(
tokens, entries, max_subtitle_words=50, subtitle_mode="Sentence",
language=Language.EN_US, use_spacy_segmentation=False
)
assert len(entries) == 1
assert entries[0][2] == 'test "word word"'
def test_dialogue_sentence_splitting_regex(self):
"""Dialogue ending with ." should split into separate sentence subtitles."""
tokens = [
{"start": 0.0, "end": 0.05, "text": '"', "whitespace": ""},
{"start": 0.05, "end": 0.5, "text": "Hello", "whitespace": " "},
{"start": 0.5, "end": 0.9, "text": "world.", "whitespace": ""},
{"start": 0.9, "end": 0.95, "text": '"', "whitespace": " "},
{"start": 0.95, "end": 1.4, "text": "She", "whitespace": " "},
{"start": 1.4, "end": 1.8, "text": "smiled.", "whitespace": ""},
]
entries = []
process_subtitle_tokens(
tokens, entries, max_subtitle_words=50, subtitle_mode="Sentence",
language=Language.EN_US, use_spacy_segmentation=False
)
assert len(entries) == 2
assert entries[0][2] == '"Hello world."'
assert entries[1][2] == "She smiled."
assert entries[0][0] == 0.0
assert entries[0][1] == 0.95
assert entries[1][0] == 0.95
assert entries[1][1] == 1.8
def test_question_exclamation_dialogue_splitting(self):
"""Dialogue with ?" and !" should split sentences cleanly."""
tokens = [
{"start": 0.0, "end": 0.05, "text": '"', "whitespace": ""},
{"start": 0.05, "end": 0.4, "text": "Why?", "whitespace": ""},
{"start": 0.4, "end": 0.45, "text": '"', "whitespace": " "},
{"start": 0.45, "end": 0.8, "text": "she", "whitespace": " "},
{"start": 0.8, "end": 1.2, "text": "asked.", "whitespace": " "},
{"start": 1.2, "end": 1.25, "text": '"', "whitespace": ""},
{"start": 1.25, "end": 1.7, "text": "Because!", "whitespace": ""},
{"start": 1.7, "end": 1.75, "text": '"', "whitespace": " "},
{"start": 1.75, "end": 2.0, "text": "he", "whitespace": " "},
{"start": 2.0, "end": 2.4, "text": "replied.", "whitespace": ""},
]
entries = []
process_subtitle_tokens(
tokens, entries, max_subtitle_words=50, subtitle_mode="Sentence",
language=Language.EN_US, use_spacy_segmentation=False
)
assert len(entries) == 4
assert entries[0][2] == '"Why?"'
assert entries[1][2] == "she asked."
assert entries[2][2] == '"Because!"'
assert entries[3][2] == "he replied."
def test_sentence_comma_mode_with_quotes(self):
"""Sentence + Comma mode splits at commas and sentence boundaries."""
tokens = [
{"start": 0.0, "end": 0.4, "text": "First,", "whitespace": " "},
{"start": 0.4, "end": 0.8, "text": "she", "whitespace": " "},
{"start": 0.8, "end": 1.2, "text": "said,", "whitespace": " "},
{"start": 1.2, "end": 1.25, "text": '"', "whitespace": ""},
{"start": 1.25, "end": 1.6, "text": "wait.", "whitespace": ""},
{"start": 1.6, "end": 1.65, "text": '"', "whitespace": ""},
]
entries = []
process_subtitle_tokens(
tokens, entries, max_subtitle_words=50, subtitle_mode="Sentence + Comma",
language=Language.EN_US, use_spacy_segmentation=False
)
assert len(entries) >= 2
assert "First," in entries[0][2]
def test_karaoke_highlighting_with_quotes(self):
"""Sentence + Highlighting generates valid karaoke tags with quotes."""
tokens = [
{"start": 0.0, "end": 0.05, "text": '"', "whitespace": ""},
{"start": 0.05, "end": 0.5, "text": "Hello", "whitespace": " "},
{"start": 0.5, "end": 0.9, "text": "world.", "whitespace": ""},
{"start": 0.9, "end": 0.95, "text": '"', "whitespace": " "},
{"start": 0.95, "end": 1.4, "text": "She", "whitespace": " "},
{"start": 1.4, "end": 1.8, "text": "said.", "whitespace": ""},
]
entries = []
process_subtitle_tokens(
tokens, entries, max_subtitle_words=50, subtitle_mode="Sentence + Highlighting",
language=Language.EN_US, use_spacy_segmentation=False
)
assert len(entries) == 2
assert '{\\kf' in entries[0][2]
assert '{\\kf' in entries[1][2]
assert '"' in entries[0][2]
def test_word_count_mode_with_quotes(self):
"""N-words mode (e.g. '3 words') groups tokens by space count."""
tokens = [
{"start": 0.0, "end": 0.3, "text": "One", "whitespace": " "},
{"start": 0.3, "end": 0.35, "text": '"', "whitespace": ""},
{"start": 0.35, "end": 0.7, "text": "two", "whitespace": " "},
{"start": 0.7, "end": 1.0, "text": "three", "whitespace": ""},
{"start": 1.0, "end": 1.05, "text": '"', "whitespace": " "},
{"start": 1.05, "end": 1.4, "text": "four", "whitespace": " "},
{"start": 1.4, "end": 1.8, "text": "five", "whitespace": " "},
{"start": 1.8, "end": 2.2, "text": "six.", "whitespace": ""},
]
entries = []
process_subtitle_tokens(
tokens, entries, max_subtitle_words=50, subtitle_mode="3",
language=Language.EN_US, use_spacy_segmentation=False
)
assert len(entries) == 2
assert entries[0][2] == 'One "two three"'
assert entries[1][2] == "four five six."
class TestSupertonicAndFakeTokenSubtitles:
"""Tests using Supertonic / non-English Kokoro FakeTokens (segment-level stubs)."""
def test_faketoken_multi_sentence_regex_split(self):
"""A single FakeToken containing multiple sentences should split proportionally."""
tokens = [
{
"start": 0.0,
"end": 6.0,
"text": 'First sentence. "Second quoted sentence." Third sentence.',
"whitespace": "",
}
]
entries = []
process_subtitle_tokens(
tokens, entries, max_subtitle_words=50, subtitle_mode="Sentence",
language=Language.ES, use_spacy_segmentation=False
)
assert len(entries) == 3
assert entries[0][2] == "First sentence."
assert entries[1][2] == '"Second quoted sentence."'
assert entries[2][2] == "Third sentence."
assert entries[0][0] == 0.0
assert entries[2][1] == 6.0
def test_faketoken_multi_sentence_spacy_split(self):
"""A single FakeToken in English with spaCy should split into separate sentences."""
tokens = [
{
"start": 0.0,
"end": 6.0,
"text": 'The sun rose high. "Are you ready?" she asked. "Always," he replied.',
"whitespace": "",
}
]
entries = []
process_subtitle_tokens(
tokens, entries, max_subtitle_words=50, subtitle_mode="Sentence",
language=Language.EN_US, use_spacy_segmentation=True
)
assert len(entries) >= 2
assert entries[0][0] == 0.0
assert entries[-1][1] == 6.0
for e in entries:
assert not e[2].startswith('" ')
def test_faketoken_single_sentence_with_quotes(self):
"""Single sentence FakeToken preserves quotes cleanly."""
tokens = [
{
"start": 1.0,
"end": 3.5,
"text": '"This is a single quoted thought."',
"whitespace": "",
}
]
entries = []
process_subtitle_tokens(
tokens, entries, max_subtitle_words=50, subtitle_mode="Sentence",
language=Language.FR, use_spacy_segmentation=False
)
assert len(entries) == 1
assert entries[0][2] == '"This is a single quoted thought."'
assert entries[0][0] == 1.0
assert entries[0][1] == 3.5
def test_line_mode_with_faketokens(self):
"""Line mode emits one subtitle per line / segment."""
tokens = [
{"start": 0.0, "end": 2.0, "text": 'Line 1 with "quotes"', "whitespace": "\n"},
{"start": 2.0, "end": 4.0, "text": 'Line 2 with "more quotes"', "whitespace": ""},
]
entries = []
process_subtitle_tokens(
tokens, entries, max_subtitle_words=50, subtitle_mode="Line",
language=Language.EN_US, use_spacy_segmentation=False
)
assert len(entries) == 2
assert entries[0][2] == 'Line 1 with "quotes"'
assert entries[1][2] == 'Line 2 with "more quotes"'
class TestComplexParagraphsAndEdgeCases:
"""Tests for paragraphs, multiple newlines, and unusual punctuation combinations."""
def test_paragraph_multi_line_token_flow(self):
"""Text spanning paragraphs with multiple sentences."""
tokens = [
{"start": 0.0, "end": 0.5, "text": "Paragraph", "whitespace": " "},
{"start": 0.5, "end": 1.0, "text": "one.", "whitespace": "\n\n"},
{"start": 1.0, "end": 1.5, "text": "Paragraph", "whitespace": " "},
{"start": 1.5, "end": 2.0, "text": "two.", "whitespace": ""},
]
entries = []
process_subtitle_tokens(
tokens, entries, max_subtitle_words=50, subtitle_mode="Sentence",
language=Language.EN_US, use_spacy_segmentation=False
)
assert len(entries) == 2
assert entries[0][2] == "Paragraph one."
assert entries[1][2] == "Paragraph two."
def test_nested_quotes_and_parentheses(self):
"""Sentence with nested quotes and parentheses: He said, "(Wait) 'now'!" """
tokens = [
{"start": 0.0, "end": 0.3, "text": "He", "whitespace": " "},
{"start": 0.3, "end": 0.6, "text": "said,", "whitespace": " "},
{"start": 0.6, "end": 0.65, "text": '"', "whitespace": ""},
{"start": 0.65, "end": 0.7, "text": "(", "whitespace": ""},
{"start": 0.7, "end": 1.0, "text": "Wait", "whitespace": ""},
{"start": 1.0, "end": 1.05, "text": ")", "whitespace": " "},
{"start": 1.05, "end": 1.1, "text": "'", "whitespace": ""},
{"start": 1.1, "end": 1.4, "text": "now", "whitespace": ""},
{"start": 1.4, "end": 1.45, "text": "'!", "whitespace": ""},
{"start": 1.45, "end": 1.5, "text": '"', "whitespace": ""},
]
entries = []
process_subtitle_tokens(
tokens, entries, max_subtitle_words=50, subtitle_mode="Sentence",
language=Language.EN_US, use_spacy_segmentation=False
)
assert len(entries) == 1
assert entries[0][2] == 'He said, "(Wait) \'now\'!"'
def test_trailing_quotes_and_ellipsis(self):
"""Sentence ending with ellipsis and quote: "I wonder..." """
tokens = [
{"start": 0.0, "end": 0.05, "text": '"', "whitespace": ""},
{"start": 0.05, "end": 0.3, "text": "I", "whitespace": " "},
{"start": 0.3, "end": 0.8, "text": "wonder...", "whitespace": ""},
{"start": 0.8, "end": 0.85, "text": '"', "whitespace": " "},
{"start": 0.85, "end": 1.2, "text": "he", "whitespace": " "},
{"start": 1.2, "end": 1.6, "text": "mused.", "whitespace": ""},
]
entries = []
process_subtitle_tokens(
tokens, entries, max_subtitle_words=50, subtitle_mode="Sentence",
language=Language.EN_US, use_spacy_segmentation=False
)
assert len(entries) == 2
assert entries[0][2] == '"I wonder..."'
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..."]
+19
View File
@@ -177,6 +177,19 @@ class TestAssWriter:
assert "Highlight" in content
assert r"{\k100}" in content
def test_highlight_mode_preserves_existing_karaoke_tags(self, tmp_path):
path = tmp_path / "test.ass"
config = SubtitleConfig(
format=SubtitleFormat.ASS,
mode=SubtitleMode.SENTENCE_HIGHLIGHT,
)
writer = AssWriter(path, config)
writer.write_entry(start=0.0, end=1.0, text=r"{\kf20}Hello {\kf20}world.")
writer.close()
content = path.read_text()
assert r"{\kf20}Hello {\kf20}world." in content
assert r"{\k100}" not in content
def test_centered_alignment(self, tmp_path):
path = tmp_path / "test.ass"
config = SubtitleConfig(
@@ -243,6 +256,12 @@ class TestCreateSubtitleWriter:
with pytest.raises(ValueError):
create_subtitle_writer(path, "xyz", "Line")
def test_word_count_mode(self, tmp_path):
path = tmp_path / "test.srt"
writer = create_subtitle_writer(path, "srt", "5 words", max_words=5)
assert isinstance(writer, SrtWriter)
writer.close()
# ===================================================================
# Context manager