Skip to content

Commit dcd26cd

Browse files
fix(transcriber): resolve codex findings on CJK bounds, duration limits, and timing overlaps
1 parent 4ee3461 commit dcd26cd

2 files changed

Lines changed: 80 additions & 41 deletions

File tree

src/bilingualsub/core/transcriber.py

Lines changed: 76 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -110,56 +110,96 @@ def _transcribe_single(
110110
raise TranscriptionError(f"Failed to parse transcription result: {e}") from e
111111

112112

113-
def _is_short_text(text: str, min_words: int, min_cjk_chars: int) -> bool:
114-
"""Check if the text segment is too short."""
115-
has_cjk = any(
116-
"\u3400" <= c <= "\u4dbf"
117-
or "\u4e00" <= c <= "\u9fff"
118-
or "\uf900" <= c <= "\ufaff"
113+
def _has_cjk(text: str) -> bool:
114+
"""Check if the text contains CJK characters (Chinese, Japanese, or Korean)."""
115+
return any(
116+
"\u4e00" <= c <= "\u9fff" # CJK Unified Ideographs
117+
or "\u3400" <= c <= "\u4dbf" # Extension A
118+
or "\uf900" <= c <= "\ufaff" # Compatibility Ideographs
119+
or "\u3040" <= c <= "\u309f" # Hiragana
120+
or "\u30a0" <= c <= "\u30ff" # Katakana
121+
or "\uac00" <= c <= "\ud7af" # Hangul Syllables
122+
or "\u1100" <= c <= "\u11ff" # Hangul Jamo
123+
or "\u3000" <= c <= "\u303f" # CJK Symbols and Punctuation (e.g. 。、)
119124
for c in text
120125
)
121-
if has_cjk:
126+
127+
128+
def _is_short_text(text: str, min_words: int, min_cjk_chars: int) -> bool:
129+
"""Check if the text segment is too short."""
130+
if _has_cjk(text):
122131
return len(text) < min_cjk_chars
123132
return len(text.split()) < min_words
124133

125134

126135
def _split_long_part_by_length(
127136
part: str,
137+
part_duration: float,
138+
max_duration_sec: float,
128139
max_chars: int,
129140
min_words: int,
130141
min_cjk_chars: int,
131142
) -> list[str]:
132-
"""Force-split a long text part into length-restricted chunks."""
133-
has_cjk = any(
134-
"\u3400" <= c <= "\u4dbf"
135-
or "\u4e00" <= c <= "\u9fff"
136-
or "\uf900" <= c <= "\ufaff"
137-
for c in part
138-
)
143+
"""Force-split a long text part into length/duration-restricted chunks."""
144+
has_cjk = _has_cjk(part)
145+
139146
if has_cjk:
140-
chunk_size = max_chars
147+
if part_duration > 0:
148+
chars_per_sec = len(part) / part_duration
149+
max_len_by_dur = max(1, int(chars_per_sec * max_duration_sec))
150+
chunk_size = min(max_chars, max_len_by_dur)
151+
else:
152+
chunk_size = max_chars
153+
141154
chunks = [part[i : i + chunk_size] for i in range(0, len(part), chunk_size)]
142155
if len(chunks) > 1 and _is_short_text(chunks[-1], min_words, min_cjk_chars):
143156
merged_len = len(chunks[-2]) + len(chunks[-1])
144-
if merged_len <= max_chars:
157+
merged_duration = part_duration * (merged_len / len(part))
158+
if merged_len <= max_chars and merged_duration <= max_duration_sec:
145159
chunks[-2] = chunks[-2] + chunks[-1]
146160
chunks.pop()
147161
return chunks
148162

149163
words = part.split()
150-
chunk_size = max(1, max_chars // 6)
164+
if not words:
165+
return []
166+
167+
if part_duration > 0:
168+
words_per_sec = len(words) / part_duration
169+
max_words_by_dur = max(1, int(words_per_sec * max_duration_sec))
170+
else:
171+
max_words_by_dur = len(words)
172+
151173
word_chunks = []
152-
for i in range(0, len(words), chunk_size):
153-
chunk = " ".join(words[i : i + chunk_size])
154-
if chunk:
155-
word_chunks.append(chunk)
174+
current_chunk: list[str] = []
175+
176+
for word in words:
177+
prospective_len = len(" ".join([*current_chunk, word]))
178+
if current_chunk and (
179+
prospective_len > max_chars or len(current_chunk) >= max_words_by_dur
180+
):
181+
word_chunks.append(" ".join(current_chunk))
182+
current_chunk = [word]
183+
else:
184+
current_chunk.append(word)
185+
186+
if current_chunk:
187+
word_chunks.append(" ".join(current_chunk))
188+
156189
if len(word_chunks) > 1 and _is_short_text(
157190
word_chunks[-1], min_words, min_cjk_chars
158191
):
159-
merged_len = len(word_chunks[-2]) + len(word_chunks[-1]) + 1
160-
if merged_len <= max_chars:
161-
word_chunks[-2] = f"{word_chunks[-2]} {word_chunks[-1]}"
192+
merged_text = f"{word_chunks[-2]} {word_chunks[-1]}"
193+
merged_words = merged_text.split()
194+
merged_duration = part_duration * (len(merged_text) / len(part))
195+
if (
196+
len(merged_text) <= max_chars
197+
and len(merged_words) <= max_words_by_dur
198+
and merged_duration <= max_duration_sec
199+
):
200+
word_chunks[-2] = merged_text
162201
word_chunks.pop()
202+
163203
return word_chunks
164204

165205

@@ -193,7 +233,9 @@ def _split_long_entries(
193233
continue
194234

195235
# Split by punctuation first (sentences and clauses)
196-
raw_parts = re.split(r"(?<=[.?!,;\uff0c\uff1b])\s*", entry.text)
236+
raw_parts = re.split(
237+
r"(?<=[.?!,;\uff0c\uff1b\u3002\uff01\uff1f\u3001])\s*", entry.text
238+
)
197239
parts = [p.strip() for p in raw_parts if p.strip()]
198240

199241
# Merge adjacent parts that are too short,
@@ -212,12 +254,7 @@ def _split_long_entries(
212254
or _is_short_text(merged_parts[-1], min_words, min_cjk_chars)
213255
) and (part_duration + last_duration <= max_duration_sec):
214256
last_part = merged_parts[-1]
215-
has_cjk = any(
216-
"\u3400" <= c <= "\u4dbf"
217-
or "\u4e00" <= c <= "\u9fff"
218-
or "\uf900" <= c <= "\ufaff"
219-
for c in last_part + part
220-
)
257+
has_cjk = _has_cjk(last_part + part)
221258
if has_cjk:
222259
merged_parts[-1] = f"{last_part}{part}"
223260
else:
@@ -231,7 +268,12 @@ def _split_long_entries(
231268
part_duration = duration * (len(part) / len(entry.text))
232269
if part_duration > max_duration_sec or len(part) > max_chars:
233270
chunks = _split_long_part_by_length(
234-
part, max_chars, min_words, min_cjk_chars
271+
part,
272+
part_duration,
273+
max_duration_sec,
274+
max_chars,
275+
min_words,
276+
min_cjk_chars,
235277
)
236278
refined_parts.extend(chunks)
237279
else:
@@ -251,12 +293,12 @@ def _split_long_entries(
251293
continue
252294

253295
current_time = entry.start
254-
for part in refined_parts:
296+
for idx, part in enumerate(refined_parts):
255297
part_ratio = len(part) / total_len
256298
part_dur = timedelta(seconds=duration * part_ratio)
257299
part_end = current_time + part_dur
258300

259-
if part == refined_parts[-1]:
301+
if idx == len(refined_parts) - 1:
260302
part_end = entry.end
261303

262304
if part_end > current_time:

tests/unit/core/test_transcriber.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -620,11 +620,8 @@ def test_splits_long_entry_by_word_if_no_punctuation(self):
620620
)
621621
res = _split_long_entries([entry], max_duration_sec=6.0, max_chars=80)
622622
assert len(res) == 2
623-
assert (
624-
res[0].text
625-
== "word1 word2 word3 word4 word5 word6 word7 word8 word9 word10 word11 word12 word13"
626-
)
627-
assert res[1].text == "word14 word15"
623+
assert res[0].text == "word1 word2 word3 word4 word5 word6 word7 word8 word9"
624+
assert res[1].text == "word10 word11 word12 word13 word14 word15"
628625

629626
def test_splits_long_entry_by_chars_for_cjk(self):
630627
# Duration 10 seconds, CJK text with no punctuation
@@ -636,8 +633,8 @@ def test_splits_long_entry_by_chars_for_cjk(self):
636633
)
637634
res = _split_long_entries([entry], max_duration_sec=6.0, max_chars=15)
638635
assert len(res) == 2
639-
assert res[0].text == "一二三四五六七八九十一二三四五"
640-
assert res[1].text == "六七八九十"
636+
assert res[0].text == "一二三四五六七八九十一二"
637+
assert res[1].text == "三四五六七八九十"
641638

642639
def test_merges_short_clauses_if_within_limits(self):
643640
# Entry with duration 8 seconds, text has 3 clauses split by commas

0 commit comments

Comments
 (0)