From 098fe111acf7eb633a6e16c1a32902a25a93c475 Mon Sep 17 00:00:00 2001 From: Burak Eyler Date: Sun, 13 Sep 2026 15:58:50 +0300 Subject: [PATCH 1/3] fix: keep subtitle timing continuous across concatenated audio clips VideoClipper.clip() concatenated several audio regions but called generate_srt_clip() without time_acc_ost, so every region's subtitles restarted at 00:00:00 and overlapped the first one. Accumulate the output duration of each region and pass it on, as video_clip() already does. Fixes #216 Co-Authored-By: Claude Opus 5 --- funclip/videoclipper.py | 9 ++- tests/test_audio_clip_subtitle_timeline.py | 65 ++++++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 tests/test_audio_clip_subtitle_timeline.py diff --git a/funclip/videoclipper.py b/funclip/videoclipper.py index 8655940..e8bc4cb 100644 --- a/funclip/videoclipper.py +++ b/funclip/videoclipper.py @@ -221,6 +221,7 @@ def clip(self, dest_text, start_ost, end_ost, state, dest_spk=None, output_dir=N ts = all_ts # ts.sort() srt_index = 0 + time_acc_ost = 0.0 clip_srt = "" if len(ts): start, end = ts[0] @@ -228,16 +229,20 @@ def clip(self, dest_text, start_ost, end_ost, state, dest_spk=None, output_dir=N end = min(max(0, end+end_ost*16), len(data)) res_audio = data[start:end] start_end_info = "from {} to {}".format(start/16000, end/16000) - srt_clip, _, srt_index = generate_srt_clip(sentences, start/16000.0, end/16000.0, begin_index=srt_index) + srt_clip, _, srt_index = generate_srt_clip(sentences, start/16000.0, end/16000.0, begin_index=srt_index, time_acc_ost=time_acc_ost) clip_srt += srt_clip + # Each later region is appended after the audio already concatenated, + # so its subtitles start at that output time, not at zero. + time_acc_ost += (end - start) / 16000.0 for _ts in ts[1:]: # multiple sentence input or multiple output matched start, end = _ts start = min(max(0, start+start_ost*16), len(data)) end = min(max(0, end+end_ost*16), len(data)) start_end_info += ", from {} to {}".format(start, end) res_audio = np.concatenate([res_audio, data[start:end]], -1) - srt_clip, _, srt_index = generate_srt_clip(sentences, start/16000.0, end/16000.0, begin_index=srt_index-1) + srt_clip, _, srt_index = generate_srt_clip(sentences, start/16000.0, end/16000.0, begin_index=srt_index-1, time_acc_ost=time_acc_ost) clip_srt += srt_clip + time_acc_ost += (end - start) / 16000.0 if len(ts): message = "{} periods found in the speech: ".format(len(ts)) + start_end_info + log_append else: diff --git a/tests/test_audio_clip_subtitle_timeline.py b/tests/test_audio_clip_subtitle_timeline.py new file mode 100644 index 0000000..3178eec --- /dev/null +++ b/tests/test_audio_clip_subtitle_timeline.py @@ -0,0 +1,65 @@ +import copy +import re +import sys +import unittest +from pathlib import Path + +import numpy as np + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "funclip")) + +from videoclipper import VideoClipper # noqa: E402 + + +CUE_TIMES = re.compile(r"(\d\d:\d\d:\d\d,\d\d\d) --> (\d\d:\d\d:\d\d,\d\d\d)") + + +class TestAudioClipSubtitleTimeline(unittest.TestCase): + """Subtitles of concatenated audio regions follow the concatenated output.""" + + def setUp(self): + sentences = [ + {"text": "hello", "timestamp": [[1000, 2000]], "spk": 0}, + {"text": "world", "timestamp": [[4000, 5000]], "spk": 0}, + ] + self.state = { + "audio_input": (16000, np.zeros(96000)), + "recog_res_raw": "hello world", + "timestamp": [[1000, 2000], [4000, 5000]], + "sentences": sentences, + "sd_sentences": copy.deepcopy(sentences), + } + + def clip(self, *args, **kwargs): + return VideoClipper(None).clip(*args, state=copy.deepcopy(self.state), **kwargs) + + def test_second_region_subtitle_starts_after_the_first_region(self): + (rate, audio), _, subtitles = self.clip("hello#world", 0, 0) + + self.assertEqual((rate, len(audio)), (16000, 32000)) + self.assertEqual( + CUE_TIMES.findall(subtitles), + [("00:00:00,000", "00:00:01,000"), ("00:00:01,000", "00:00:02,000")], + ) + + def test_explicit_timestamps_follow_the_concatenated_output(self): + (_, audio), _, subtitles = self.clip( + None, 0, 0, timestamp_list=[[16000, 32000], [64000, 80000]] + ) + + self.assertEqual(len(audio), 32000) + self.assertEqual( + CUE_TIMES.findall(subtitles), + [("00:00:00,000", "00:00:01,000"), ("00:00:01,000", "00:00:02,000")], + ) + + def test_single_region_is_unchanged(self): + (_, audio), _, subtitles = self.clip("hello", 0, 0) + + self.assertEqual(len(audio), 16000) + self.assertEqual(CUE_TIMES.findall(subtitles), [("00:00:00,000", "00:00:01,000")]) + + +if __name__ == "__main__": + unittest.main() From 9f90de7bf71be96bc47a59e0c16301b662ea89ad Mon Sep 17 00:00:00 2001 From: Burak Eyler Date: Sun, 13 Sep 2026 16:17:49 +0300 Subject: [PATCH 2/3] fix: accumulate subtitle time from the samples actually appended Offsets are clamped per bound, so a positive start_ost can leave a region with start > end. NumPy appends an empty slice, but (end - start) went negative and moved every later subtitle backwards. Use the appended slice length at both accumulator updates, and cover an emptied first region, an emptied middle region and an end offset clamped to the input length. Co-Authored-By: Claude Opus 5 --- funclip/videoclipper.py | 8 +-- tests/test_audio_clip_subtitle_timeline.py | 60 ++++++++++++++++++++++ 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/funclip/videoclipper.py b/funclip/videoclipper.py index e8bc4cb..6fb1847 100644 --- a/funclip/videoclipper.py +++ b/funclip/videoclipper.py @@ -232,8 +232,10 @@ def clip(self, dest_text, start_ost, end_ost, state, dest_spk=None, output_dir=N srt_clip, _, srt_index = generate_srt_clip(sentences, start/16000.0, end/16000.0, begin_index=srt_index, time_acc_ost=time_acc_ost) clip_srt += srt_clip # Each later region is appended after the audio already concatenated, - # so its subtitles start at that output time, not at zero. - time_acc_ost += (end - start) / 16000.0 + # so its subtitles start at that output time, not at zero. Count the + # samples actually appended: offsets can clamp a region to start > end, + # which appends nothing. + time_acc_ost += len(data[start:end]) / 16000.0 for _ts in ts[1:]: # multiple sentence input or multiple output matched start, end = _ts start = min(max(0, start+start_ost*16), len(data)) @@ -242,7 +244,7 @@ def clip(self, dest_text, start_ost, end_ost, state, dest_spk=None, output_dir=N res_audio = np.concatenate([res_audio, data[start:end]], -1) srt_clip, _, srt_index = generate_srt_clip(sentences, start/16000.0, end/16000.0, begin_index=srt_index-1, time_acc_ost=time_acc_ost) clip_srt += srt_clip - time_acc_ost += (end - start) / 16000.0 + time_acc_ost += len(data[start:end]) / 16000.0 if len(ts): message = "{} periods found in the speech: ".format(len(ts)) + start_end_info + log_append else: diff --git a/tests/test_audio_clip_subtitle_timeline.py b/tests/test_audio_clip_subtitle_timeline.py index 3178eec..3912846 100644 --- a/tests/test_audio_clip_subtitle_timeline.py +++ b/tests/test_audio_clip_subtitle_timeline.py @@ -60,6 +60,66 @@ def test_single_region_is_unchanged(self): self.assertEqual(len(audio), 16000) self.assertEqual(CUE_TIMES.findall(subtitles), [("00:00:00,000", "00:00:01,000")]) + def clip_with(self, sentences, seconds, *args, **kwargs): + state = { + "audio_input": (16000, np.zeros(16000 * seconds)), + "recog_res_raw": "", + "timestamp": [], + "sentences": sentences, + "sd_sentences": copy.deepcopy(sentences), + } + return VideoClipper(None).clip(*args, state=state, **kwargs) + + def test_region_emptied_by_start_offset_does_not_shift_later_subtitles(self): + # start_ost=1500ms turns the first region into 2.5-2.0s (nothing appended); + # the second contributes 5.5-7.0s, so "world" (6-7s) sits at 0.5-1.5s. + sentences = [ + {"text": "hello", "timestamp": [[1000, 2000]], "spk": 0}, + {"text": "world", "timestamp": [[6000, 7000]], "spk": 0}, + ] + (_, audio), _, subtitles = self.clip_with( + sentences, 8, None, 1500, 0, timestamp_list=[[16000, 32000], [64000, 112000]] + ) + + self.assertEqual(len(audio), 24000) + self.assertEqual(CUE_TIMES.findall(subtitles), [("00:00:00,500", "00:00:01,500")]) + + def test_middle_region_emptied_by_start_offset_adds_no_time(self): + # start_ost=1000ms: the first region becomes 1-3s, the middle one 5-4.5s + # (start > end, nothing appended) and the last 7-8s. + sentences = [ + {"text": "one", "timestamp": [[2000, 2800]], "spk": 0}, + {"text": "two", "timestamp": [[7200, 7800]], "spk": 0}, + ] + (_, audio), _, subtitles = self.clip_with( + sentences, + 8, + None, + 1000, + 0, + timestamp_list=[[0, 48000], [64000, 72000], [96000, 128000]], + ) + + self.assertEqual(len(audio), 48000) + self.assertEqual( + CUE_TIMES.findall(subtitles), + [("00:00:01,000", "00:00:01,800"), ("00:00:02,200", "00:00:02,800")], + ) + + def test_end_offset_clamped_to_audio_length(self): + # end_ost=2000ms extends the first region to 0-3s and would push the + # second past the 4s input; it is clamped to 3-4s. + sentences = [ + {"text": "one", "timestamp": [[0, 1000]], "spk": 0}, + {"text": "two", "timestamp": [[3000, 4000]], "spk": 0}, + ] + (_, audio), _, subtitles = self.clip_with( + sentences, 4, None, 0, 2000, timestamp_list=[[0, 16000], [48000, 64000]] + ) + + self.assertEqual(len(audio), 64000) + self.assertEqual(CUE_TIMES.findall(subtitles)[-1], ("00:00:03,000", "00:00:04,000")) + if __name__ == "__main__": unittest.main() From b00e900b22197472a84533d1ed543895037f87bc Mon Sep 17 00:00:00 2001 From: Burak Eyler Date: Sun, 13 Sep 2026 17:31:57 +0300 Subject: [PATCH 3/3] fix: accumulate the subtitle offset in integer samples Summing per-region float durations drifted just below exact millisecond boundaries (7.14 + 6.6 == 13.739999...), which the SRT formatter truncated to 13.739s. Keep the output position as an integer sample count and convert it once per generate_srt_clip() call, with a regression test on the 114240 + 105600 sample boundary from the real-model replay. Co-Authored-By: Claude Opus 5 --- funclip/videoclipper.py | 12 +++++++----- tests/test_audio_clip_subtitle_timeline.py | 21 +++++++++++++++++++++ 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/funclip/videoclipper.py b/funclip/videoclipper.py index 6fb1847..ce9e735 100644 --- a/funclip/videoclipper.py +++ b/funclip/videoclipper.py @@ -221,7 +221,9 @@ def clip(self, dest_text, start_ost, end_ost, state, dest_spk=None, output_dir=N ts = all_ts # ts.sort() srt_index = 0 - time_acc_ost = 0.0 + # Output position in samples; kept as an integer and converted per call so + # summing float durations cannot drift a cue below its millisecond. + acc_samples = 0 clip_srt = "" if len(ts): start, end = ts[0] @@ -229,22 +231,22 @@ def clip(self, dest_text, start_ost, end_ost, state, dest_spk=None, output_dir=N end = min(max(0, end+end_ost*16), len(data)) res_audio = data[start:end] start_end_info = "from {} to {}".format(start/16000, end/16000) - srt_clip, _, srt_index = generate_srt_clip(sentences, start/16000.0, end/16000.0, begin_index=srt_index, time_acc_ost=time_acc_ost) + srt_clip, _, srt_index = generate_srt_clip(sentences, start/16000.0, end/16000.0, begin_index=srt_index, time_acc_ost=acc_samples / 16000.0) clip_srt += srt_clip # Each later region is appended after the audio already concatenated, # so its subtitles start at that output time, not at zero. Count the # samples actually appended: offsets can clamp a region to start > end, # which appends nothing. - time_acc_ost += len(data[start:end]) / 16000.0 + acc_samples += len(data[start:end]) for _ts in ts[1:]: # multiple sentence input or multiple output matched start, end = _ts start = min(max(0, start+start_ost*16), len(data)) end = min(max(0, end+end_ost*16), len(data)) start_end_info += ", from {} to {}".format(start, end) res_audio = np.concatenate([res_audio, data[start:end]], -1) - srt_clip, _, srt_index = generate_srt_clip(sentences, start/16000.0, end/16000.0, begin_index=srt_index-1, time_acc_ost=time_acc_ost) + srt_clip, _, srt_index = generate_srt_clip(sentences, start/16000.0, end/16000.0, begin_index=srt_index-1, time_acc_ost=acc_samples / 16000.0) clip_srt += srt_clip - time_acc_ost += len(data[start:end]) / 16000.0 + acc_samples += len(data[start:end]) if len(ts): message = "{} periods found in the speech: ".format(len(ts)) + start_end_info + log_append else: diff --git a/tests/test_audio_clip_subtitle_timeline.py b/tests/test_audio_clip_subtitle_timeline.py index 3912846..46724a3 100644 --- a/tests/test_audio_clip_subtitle_timeline.py +++ b/tests/test_audio_clip_subtitle_timeline.py @@ -120,6 +120,27 @@ def test_end_offset_clamped_to_audio_length(self): self.assertEqual(len(audio), 64000) self.assertEqual(CUE_TIMES.findall(subtitles)[-1], ("00:00:03,000", "00:00:04,000")) + def test_accumulated_offset_does_not_lose_a_millisecond_to_float_sums(self): + # 114240 + 105600 samples is exactly 13.74s, but 7.14 + 6.6 in floats is + # 13.739999..., which the millisecond formatter truncated to 13.739s. + seconds = 20 + sentences = [ + {"text": "a", "timestamp": [[0, 7140]], "spk": 0}, + {"text": "b", "timestamp": [[8000, 14600]], "spk": 0}, + {"text": "c", "timestamp": [[15000, 16000]], "spk": 0}, + ] + (_, audio), _, subtitles = self.clip_with( + sentences, + seconds, + None, + 0, + 0, + timestamp_list=[[0, 114240], [128000, 233600], [240000, 256000]], + ) + + self.assertEqual(len(audio), 114240 + 105600 + 16000) + self.assertEqual(CUE_TIMES.findall(subtitles)[2], ("00:00:13,740", "00:00:14,740")) + if __name__ == "__main__": unittest.main()