Skip to content

feat: chunk long SRT cues within each beat's fixed voice window#15

Merged
htlin222 merged 1 commit into
mainfrom
feat/chunk-srt-cues
Jul 5, 2026
Merged

feat: chunk long SRT cues within each beat's fixed voice window#15
htlin222 merged 1 commit into
mainfrom
feat/chunk-srt-cues

Conversation

@htlin222

@htlin222 htlin222 commented Jul 5, 2026

Copy link
Copy Markdown
Owner

Closes #4.

Problem

build_srt() emitted exactly one SRT cue per voiced beat, spanning the beat's entire voice.start -> voice.end window with the whole beat.text as a single block. Fine for short beats, but intro/outro beats can carry a full paragraph — worst case in production: one cue, 40.0s, 216 chars shown as a single static block. Bad subtitle UX.

Fix

build_srt() now calls a new chunk_cues(text, start, end, max_units=34, min_dur=1.2) per beat instead of emitting the beat verbatim, and renumbers cues across the whole file (not per-beat). The audio-reconciled [voice.start, voice.end] window itself is never touched — only the caption layer inside it is re-sliced, so this has zero interaction with detect_anchors.py/splice.py/mux()'s amix/adelay timing.

Algorithm (matches the issue's sketch, with the two open pieces filled in):

  • cjk_width(text): rendered subtitle width via unicodedata.east_asian_width — Wide/Fullwidth chars (CJK ideographs, kana, hangul, fullwidth punctuation) count as 2 units, everything else as 1.
  • split_on_punctuation(text, max_units): split on sentence punctuation (。!?) first; any sentence still over max_units is split further on clause punctuation (,、,); a clause still over budget with no punctuation at all (e.g. a long English run) falls back to a hard width-based wrap. Concatenating the returned chunks always reproduces text exactly.
  • chunk_cues: redistributes [start, end] across the chunks proportionally by cjk_width; the last sub-cue's end is pinned to end to absorb rounding drift, so total duration is preserved exactly.

Merge-back rule (the trailing-fragment edge case called out in the issue): punctuation splitting is width-aware but duration-blind, so a short window can produce a chunk whose proportional share of the duration falls below min_dur (an unreadably brief flash). _merge_short_chunks repeatedly folds the first offending chunk into its neighbour (forward, or into its predecessor if it's the last chunk) until every remaining chunk's proportional share clears min_dur, or only one chunk is left. This can leave a merged chunk over max_units — documented trade-off: a floor on reading time trumps the char-count budget, since a legible-but-long cue beats an unreadably brief one.

A beat short enough not to need splitting collapses back to exactly the old one-cue-per-beat behaviour (verified explicitly in tests) — all pre-existing test_overlay.py tests pass unchanged.

Tests added (tests/test_overlay.py)

  • test_chunk_cues_short_beat_collapses_to_one_cue_spanning_full_window — short text reduces to one cue, same window.
  • test_chunk_cues_long_beat_splits_into_multiple_readable_sub_cues — ~193-char/40s realistic zh-TW narration splits into multiple cues; verifies contiguous windows, exact duration sum, exact text reconstruction via concatenation, and per-cue reading-speed/width bounds.
  • test_chunk_cues_merges_short_trailing_fragments_to_respect_min_dur — 8 comma-separated clauses crammed into a 3s window would otherwise yield 8 sub-1.2s cues; verifies the merge-back rule collapses them while still respecting min_dur, exact duration sum, and exact text reconstruction.
  • test_chunk_cues_single_leftover_chunk_cannot_grow_past_the_fixed_window — degenerate case where the window is shorter than min_dur itself.
  • test_build_srt_renumbers_cues_across_the_whole_file_not_per_beat — numbering is one contiguous sequence across multiple beats' sub-cues.

Test plan

https://claude.ai/code/session_012EwptawkV4dcGhoGefzxgV

…ixed voice window

Long intro/outro beats (worst case: 40s, 216 chars) rendered as one static
SRT block are bad subtitle UX. build_srt() now re-slices each voiced beat's
caption text into readable sub-cues via chunk_cues(), splitting on sentence
punctuation (。!?) first, falling back to clause punctuation (,、,), then a
hard width-based wrap for punctuation-free runs, and redistributing the
beat's fixed [voice.start, voice.end] window proportionally by rendered
char-width (cjk_width: CJK/fullwidth = 2 units, ASCII = 1). The audio-
reconciled window itself never moves — only the caption layer inside it is
re-cut, so splice.py/mux() timing is untouched.

A short window that would otherwise produce sub-1.2s trailing fragments
(duration-blind punctuation splitting vs. a duration floor) triggers a
merge-back rule: short chunks fold into their neighbour until every
remaining cue clears MIN_CUE_DUR, even if that leaves a merged chunk over
MAX_CUE_UNITS — a readable-but-long cue beats an unreadably brief flash.
Cue numbers are now renumbered across the whole SRT file rather than
per-beat.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EwptawkV4dcGhoGefzxgV
@htlin222
htlin222 merged commit 54b4fab into main Jul 5, 2026
5 checks passed
@htlin222
htlin222 deleted the feat/chunk-srt-cues branch July 5, 2026 04:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Suggestion: chunk long SRT cues within each beat's fixed voice window (deterministic, sync-safe)

1 participant