feat: chunk long SRT cues within each beat's fixed voice window#15
Merged
Conversation
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #4.
Problem
build_srt()emitted exactly one SRT cue per voiced beat, spanning the beat's entirevoice.start->voice.endwindow with the wholebeat.textas 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 newchunk_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 withdetect_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 viaunicodedata.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 overmax_unitsis 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 reproducestextexactly.chunk_cues: redistributes[start, end]across the chunks proportionally bycjk_width; the last sub-cue's end is pinned toendto 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_chunksrepeatedly 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 clearsmin_dur, or only one chunk is left. This can leave a merged chunk overmax_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.pytests 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 respectingmin_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 thanmin_duritself.test_build_srt_renumbers_cues_across_the_whole_file_not_per_beat— numbering is one contiguous sequence across multiple beats' sub-cues.Test plan
git rebase origin/main(picked up unrelated upstream commits fix: escape quotes in VHS tape Type lines #10-docs: document ledger.json downstream reuse patterns #13): 111 passed.test_srt_cues_come_straight_from_ledger_voice_windows,test_launch_subtitle_is_clean_no_slot_prefix,test_srt_orders_by_voice_start) still pass unchanged.https://claude.ai/code/session_012EwptawkV4dcGhoGefzxgV