aboutsummaryrefslogtreecommitdiff
path: root/app/converter/chunking.py
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-09-10 00:16:00 -0400
committerhistoria <historiavg@proton.me>2026-09-10 00:16:00 -0400
commite2da233cd1baa859a5721542fc8b80d9f3f880e7 (patch)
tree4e86797cbd7dce0434feef65bc0ad2e97bfbe4c0 /app/converter/chunking.py
parent31459b281b6a5368c692b3c42c91e522995ebd57 (diff)
downloadtts-audiobook-generator-e2da233cd1baa859a5721542fc8b80d9f3f880e7.tar.gz
fix: smart chunking, extraction, and process-safety issuesHEADmain
Diffstat (limited to 'app/converter/chunking.py')
-rw-r--r--app/converter/chunking.py97
1 files changed, 85 insertions, 12 deletions
diff --git a/app/converter/chunking.py b/app/converter/chunking.py
index 0ffaa21..bf104bc 100644
--- a/app/converter/chunking.py
+++ b/app/converter/chunking.py
@@ -21,7 +21,8 @@ _FLOOR_RATIO = 0.7
# boundary; if such a token runs longer than this many characters per
# allowed word it is sliced at the hard character cap (a last-resort
# bound for punctuation-free input such as some CJK text or damage from
-# text extraction).
+# text extraction). The slice also honors the word limit itself, since
+# every non-spaced-script character counts as one word.
_MAX_CHARS_PER_WORD = 8
_ABBREVIATIONS = frozenset(
@@ -203,8 +204,8 @@ def _split_oversized(text: str, max_words: int) -> List[str]:
First at clause punctuation (kept attached for TTS prosody, and
never at commas without whitespace so tokens like "1,000,000" and
"12:30" survive), then at word boundaries. A token with no
- whitespace at all is sliced at the hard character cap so a
- punctuation-free run cannot exceed the bound.
+ whitespace at all is sliced at the hard character cap and the word
+ limit so a punctuation-free run cannot exceed either bound.
"""
parts = re.split(r"(?:(?<=[,;:])\s+|(?<=[,、;:]))", text)
pieces: List[str] = []
@@ -220,21 +221,50 @@ def _split_oversized(text: str, max_words: int) -> List[str]:
return pieces
+def _slice_token(word: str, max_words: int, cap: int) -> List[str]:
+ """Slice a single whitespace-free token into packable pieces.
+
+ Cuts before the character cap is exceeded and before the word count
+ is exceeded: each non-spaced-script character counts as one word,
+ so a punctuation-free CJK token is bounded by the word limit too,
+ not only by the character cap.
+ """
+ slices: List[str] = []
+ cur: List[str] = []
+ cur_chars = 0
+ # Any slice is itself one whitespace-delimited word before its
+ # non-spaced-script characters are counted on top.
+ cur_words = 1
+ for ch in word:
+ ch_words = 1 if _NON_SPACED_RUN.match(ch) else 0
+ if cur and (cur_chars + 1 > cap or cur_words + ch_words > max_words):
+ slices.append("".join(cur))
+ cur, cur_chars, cur_words = [], 0, 1
+ cur.append(ch)
+ cur_chars += 1
+ cur_words += ch_words
+ if cur:
+ slices.append("".join(cur))
+ return slices
+
+
def _hard_slices(text: str, max_words: int, cap: int) -> List[str]:
- """Slice TEXT at word boundaries, then at CAP characters when a
- single token has no whitespace to break at. Buffers never exceed
- the word limit or the character cap, so every piece is packable."""
+ """Slice TEXT at word boundaries, then within tokens when a single
+ token (no whitespace to break at) exceeds the character cap or the
+ word limit on its own. Buffers never exceed the word limit or the
+ character cap, so every piece is packable."""
pieces: List[str] = []
buf: List[str] = []
buf_words = 0
buf_chars = 0
for word in text.split():
- if len(word) > cap:
+ if len(word) > cap or _count_words(word) > max_words:
+ # A single token too big to buffer: slice it at the hard
+ # character cap and the word limit together.
if buf:
pieces.append(" ".join(buf))
buf, buf_words, buf_chars = [], 0, 0
- for start in range(0, len(word), cap):
- pieces.append(word[start:start + cap])
+ pieces.extend(_slice_token(word, max_words, cap))
continue
words = _count_words(word)
if buf and buf_words + words <= max_words \
@@ -266,6 +296,41 @@ def _joined_chars(units: List[_Unit]) -> int:
return total
+def _quote_states(pieces: List[str], in_quote_end: bool) -> List[bool]:
+ """The quote state after each piece of an oversized sentence.
+
+ Pieces of one sentence can sit inside a quotation the sentence does
+ not close, so ending a chunk at such a piece would sever the quote
+ (and marking them clean lets the target flush cut mid-quote). The
+ scanner's quote tracking is replayed over the pieces from the state
+ the sentence started in; that start state is unknown, so both
+ possibilities are replayed and the one whose end state matches the
+ unit's known end state wins (a unit rarely starts mid-quote). When
+ neither matches, every piece takes the unit's conservative end
+ state.
+ """
+ def simulate(in_quote: bool, depth: int) -> List[bool]:
+ states: List[bool] = []
+ for piece in pieces:
+ for ch in piece:
+ if ch == '"':
+ in_quote = not in_quote
+ elif ch in "“«":
+ depth += 1
+ elif ch in "”»":
+ depth = max(0, depth - 1)
+ states.append(in_quote or depth > 0)
+ return states
+
+ states = simulate(False, 0)
+ if states and states[-1] == in_quote_end:
+ return states
+ states = simulate(True, 0)
+ if states and states[-1] == in_quote_end:
+ return states
+ return [in_quote_end] * len(pieces)
+
+
def _split_smart(text: str, max_words: int) -> List[str]:
"""Smart strategy: fill chunks up to MAX_WORDS at sentence
boundaries, end them at the target once a quotation has closed or
@@ -318,9 +383,14 @@ def _split_smart(text: str, max_words: int) -> List[str]:
cur_chars = _joined_chars(cur)
continue
# One piece exceeds the limit itself: send its pieces out
- # packed as tightly as the pieces allow.
+ # packed as tightly as the pieces allow, carrying the
+ # sentence's quotation state through the fallback so a chunk
+ # can end at a clean in-sentence boundary — and a piece still
+ # inside a quote is never mistaken for one.
flush()
- for piece_text in _split_oversized(unit.text, max_words):
+ piece_texts = _split_oversized(unit.text, max_words)
+ quote_states = _quote_states(piece_texts, unit.in_quote_end)
+ for index, piece_text in enumerate(piece_texts):
if _count_words(piece_text) > max_words \
or len(piece_text) > char_cap:
# A character-sliced piece of degenerate input: its own
@@ -328,7 +398,10 @@ def _split_smart(text: str, max_words: int) -> List[str]:
flush()
chunks.append(piece_text)
continue
- piece = _Unit(piece_text, _count_words(piece_text), False, False)
+ piece = _Unit(
+ piece_text, _count_words(piece_text),
+ unit.para_after and index == len(piece_texts) - 1,
+ quote_states[index])
if cur and cur_words + piece.words <= max_words \
and cur_chars + 1 + len(piece_text) <= char_cap:
cur.append(piece)