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)
This commit is contained in:
Deniz Şafak
2026-08-20 23:00:15 +03:00
parent 823f5be029
commit 5432de7ac5
10 changed files with 247 additions and 40 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).
+25 -8
View File
@@ -60,7 +60,7 @@ def spacy_pre_tts_segmentation(
text_segments is a list of sentences (always at least one element). text_segments is a list of sentences (always at least one element).
active_split_pattern is the regex to use for TTS backend splitting. 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: def _log(msg: str) -> None:
if log_callback: if log_callback:
@@ -99,13 +99,10 @@ def spacy_pre_tts_segmentation(
_log(f"spaCy: Text segmented into {len(spacy_sentences)} sentences...") _log(f"spaCy: Text segmented into {len(spacy_sentences)} sentences...")
# Compute split_pattern override based on subtitle mode # spaCy already split at sentence boundaries; the engine only needs to
spacing_pattern = r"\s*" if lang_enum in _CJK_LANGS else r"\s+" # split on newlines. Commas are never used in the engine split pattern
# for non-English (Sentence + Comma splits at commas only at subtitle
if subtitle_mode_str == "Sentence + Comma": # time, like English).
active_split = r"(?<=[{}]){}|\n+".format(PUNCTUATION_COMMAS, spacing_pattern)
else:
# Sentence mode: spaCy already split, only split on newlines
active_split = "\n" active_split = "\n"
return spacy_sentences, active_split return spacy_sentences, active_split
@@ -113,6 +110,8 @@ def spacy_pre_tts_segmentation(
def _to_language_enum(lang_code: Any) -> Language: def _to_language_enum(lang_code: Any) -> Language:
"""Convert lang_code to Language enum (ISO code or Language enum).""" """Convert lang_code to Language enum (ISO code or Language enum)."""
if isinstance(lang_code, Language):
return lang_code
try: try:
return Language.from_str(str(lang_code)) return Language.from_str(str(lang_code))
except ValueError: except ValueError:
@@ -174,6 +173,8 @@ def tts_segments(
segment_iter = backend(text, **kwargs) segment_iter = backend(text, **kwargs)
chunk_start = current_time chunk_start = current_time
prev_tokens: Optional[List[Dict[str, Any]]] = None
prev_was_fallback = True
for segment in segment_iter: for segment in segment_iter:
graphemes_raw = getattr(segment, "graphemes", "") or "" graphemes_raw = getattr(segment, "graphemes", "") or ""
@@ -186,8 +187,10 @@ def tts_segments(
duration = len(audio) / SAMPLE_RATE duration = len(audio) / SAMPLE_RATE
tokens_list = getattr(segment, "tokens", []) tokens_list = getattr(segment, "tokens", [])
was_fallback = False
if not tokens_list and graphemes: if not tokens_list and graphemes:
tokens_list = [FakeToken(graphemes, 0, duration)] tokens_list = [FakeToken(graphemes, 0, duration)]
was_fallback = True
tokens = [ tokens = [
{ {
@@ -199,6 +202,18 @@ def tts_segments(
for tok in tokens_list 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( yield SegmentResult(
graphemes=graphemes, graphemes=graphemes,
audio=audio, audio=audio,
@@ -207,6 +222,8 @@ def tts_segments(
tokens=tokens, tokens=tokens,
) )
prev_tokens = tokens
prev_was_fallback = was_fallback
chunk_start += duration chunk_start += duration
+10 -1
View File
@@ -27,8 +27,17 @@ def get_split_pattern(language: Language, subtitle_mode: str) -> str:
except ValueError: except ValueError:
mode = SubtitleMode.DISABLED 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): if language in (Language.EN_US, Language.EN_GB):
if mode in (
SubtitleMode.DISABLED,
SubtitleMode.LINE,
SubtitleMode.SENTENCE,
SubtitleMode.SENTENCE_COMMA,
):
return "\n" return "\n"
# Determine spacing pattern based on language # Determine spacing pattern based on language
+13 -16
View File
@@ -1,5 +1,6 @@
import os import os
import time import time
import logging
import hashlib # For generating unique cache filenames import hashlib # For generating unique cache filenames
from pathlib import Path from pathlib import Path
from platformdirs import user_desktop_dir from platformdirs import user_desktop_dir
@@ -50,6 +51,8 @@ import abogen.hf_tracker as hf_tracker
import static_ffmpeg import static_ffmpeg
import threading # for efficient waiting import threading # for efficient waiting
logger = logging.getLogger(__name__)
# Configuration constants # Configuration constants
@@ -64,7 +67,6 @@ from abogen.subtitle_utils import (
sanitize_name_for_os, sanitize_name_for_os,
split_text_by_voice_markers split_text_by_voice_markers
) )
from abogen.domain.split_pattern import PUNCTUATION_COMMAS
class CountdownDialog(QDialog): class CountdownDialog(QDialog):
"""Base dialog with auto-accept countdown functionality""" """Base dialog with auto-accept countdown functionality"""
@@ -348,7 +350,7 @@ class ConversionThread(QThread):
return samples_processed return samples_processed
def run(self): 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" 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: try:
@@ -873,7 +875,6 @@ class ConversionThread(QThread):
) )
spacy_sentences = None spacy_sentences = None
active_split_pattern = self.split_pattern 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 # Pre-load spaCy model for English if it will be needed for subtitle generation
if ( if (
@@ -914,15 +915,11 @@ class ConversionThread(QThread):
"grey", "grey",
) )
) )
# For Sentence + Comma mode, still split on commas within spaCy sentences # spaCy already split at sentence boundaries; the
if self.subtitle_mode == "Sentence + Comma": # engine only splits on newlines. Commas are never
active_split_pattern = r"(?<=[{}]){}|\n+".format( # used in the engine split pattern (Sentence +
PUNCTUATION_COMMAS, spacing_pattern # Comma splits at commas only at subtitle time).
) active_split_pattern = "\n"
else:
active_split_pattern = (
"\n" # Use newline splitting for Sentence mode
)
else: else:
self.log_updated.emit( self.log_updated.emit(
("\nspaCy: Fallback to default segmentation...", "grey") ("\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 # Print active split pattern used by the TTS engine once for this batch
try: try:
print(f"Using split pattern: {active_split_pattern!r}") logger.info(f"Using split pattern: {active_split_pattern!r}")
except Exception: except Exception:
# Print must never break processing # Logging must never break processing
print("Using split pattern: (unprintable)") logger.warning("Using split pattern: (unprintable)")
for text_segment in text_segments: for text_segment in text_segments:
def _qt_check_cancel() -> bool: def _qt_check_cancel() -> bool:
@@ -1445,7 +1442,7 @@ class VoicePreviewThread(QThread):
return os.path.join(self.cache_dir, filename) return os.path.join(self.cache_dir, filename)
def run(self): def run(self):
print( logger.info(
f"\nVoice: {self.voice}\nLanguage: {self.lang_code}\nSpeed: {self.speed}\nGPU: {self.use_gpu}\n" f"\nVoice: {self.voice}\nLanguage: {self.lang_code}\nSpeed: {self.speed}\nGPU: {self.use_gpu}\n"
) )
+42
View File
@@ -79,6 +79,44 @@ class SynthesisRequest:
format: AudioFormat 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) @dataclass(frozen=True)
class SynthesizedAudio: class SynthesizedAudio:
"""Immutable value object for synthesized audio result. """Immutable value object for synthesized audio result.
@@ -87,11 +125,15 @@ class SynthesizedAudio:
data: Raw audio bytes. data: Raw audio bytes.
format: Audio format of the result. format: Audio format of the result.
duration: Duration of the audio. 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 data: bytes
format: AudioFormat format: AudioFormat
duration: Duration duration: Duration
segments: tuple[AudioSegment, ...] = ()
@dataclass(frozen=True) @dataclass(frozen=True)
+25 -2
View File
@@ -169,15 +169,38 @@ class Pipeline:
) )
result = session.synthesize(request) 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 @dataclass
class Segment: class Segment:
graphemes: str graphemes: str
audio: np.ndarray 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) yield Segment(graphemes=text, audio=audio_array)
def load_single_voice(self, voice_name: str) -> Any: 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 from dotenv import load_dotenv, find_dotenv
logger = logging.getLogger(__name__)
def _load_environment() -> None: def _load_environment() -> None:
explicit_path = os.environ.get("ABOGEN_ENV_FILE") 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): 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 # Configure root logger to output to console if not already configured
root = logging.getLogger() root = logging.getLogger()
if not root.handlers: if not root.handlers:
@@ -493,8 +491,8 @@ def create_process(cmd, stdin=None, text=True, capture_output=False):
} }
) )
# Print the command being executed # Log the command being executed
print(f"Executing: {cmd if isinstance(cmd, str) else ' '.join(cmd)}") logger.info(f"Executing: {cmd if isinstance(cmd, str) else ' '.join(cmd)}")
proc = subprocess.Popen(cmd, **kwargs) proc = subprocess.Popen(cmd, **kwargs)
@@ -615,7 +613,7 @@ def prevent_sleep_start():
) )
else: else:
# Non-systemd distro or systemd tools not installed: skip inhibition rather than crash # 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." "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.manifest import VoiceManifest
from abogen.tts_plugin.types import ( from abogen.tts_plugin.types import (
AudioFormat, AudioFormat,
AudioSegment,
Duration, Duration,
SynthesisRequest, SynthesisRequest,
SynthesizedAudio, SynthesizedAudio,
TokenTiming,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -117,7 +119,9 @@ class KokoroSession:
speed = request.parameters.values.get("speed", 1.0) speed = request.parameters.values.get("speed", 1.0)
split_pattern = request.parameters.values.get("split_pattern", None) split_pattern = request.parameters.values.get("split_pattern", None)
sample_rate = _KOKORO_SAMPLE_RATE
audio_parts: list[np.ndarray] = [] audio_parts: list[np.ndarray] = []
segments: list[AudioSegment] = []
for segment in self._pipeline( for segment in self._pipeline(
request.text, request.text,
voice=voice, voice=voice,
@@ -127,7 +131,28 @@ class KokoroSession:
audio = segment.audio audio = segment.audio
if hasattr(audio, "numpy"): if hasattr(audio, "numpy"):
audio = 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: if not audio_parts:
return SynthesizedAudio( return SynthesizedAudio(
@@ -138,12 +163,13 @@ class KokoroSession:
combined = np.concatenate(audio_parts).astype("float32", copy=False) combined = np.concatenate(audio_parts).astype("float32", copy=False)
audio_bytes = combined.tobytes() audio_bytes = combined.tobytes()
duration_seconds = len(combined) / _KOKORO_SAMPLE_RATE duration_seconds = len(combined) / sample_rate
return SynthesizedAudio( return SynthesizedAudio(
data=audio_bytes, data=audio_bytes,
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=duration_seconds), duration=Duration(seconds=duration_seconds),
segments=tuple(segments),
) )
except EngineError: except EngineError:
raise 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.manifest import VoiceManifest
from abogen.tts_plugin.types import ( from abogen.tts_plugin.types import (
AudioFormat, AudioFormat,
AudioSegment,
Duration, Duration,
SynthesisRequest, SynthesisRequest,
SynthesizedAudio, SynthesizedAudio,
@@ -113,6 +114,7 @@ class SuperTonicSession:
total_steps = int(total_steps) total_steps = int(total_steps)
audio_parts: list[np.ndarray] = [] audio_parts: list[np.ndarray] = []
segments: list[AudioSegment] = []
for segment in self._pipeline( for segment in self._pipeline(
request.text, request.text,
voice=voice, voice=voice,
@@ -120,7 +122,17 @@ class SuperTonicSession:
split_pattern=split_pattern, split_pattern=split_pattern,
total_steps=total_steps, 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: if not audio_parts:
return SynthesizedAudio( return SynthesizedAudio(
@@ -139,6 +151,7 @@ class SuperTonicSession:
data=audio_bytes, data=audio_bytes,
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=duration_seconds), duration=Duration(seconds=duration_seconds),
segments=tuple(segments),
) )
except EngineError: except EngineError:
raise raise
+1 -1
View File
@@ -9,7 +9,7 @@ from abogen.domain.enums import Language
from abogen.domain.split_pattern import get_split_pattern 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: class TestEnglish:
def test_english_sentence(self): def test_english_sentence(self):