diff --git a/README.md b/README.md index 3b420e8..c0c5a04 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,11 @@ Words are grouped into cues of at most two lines (42 characters per line, except an unusually long unbroken word) and six seconds. Cues also break at sentence endings, pauses of at least 0.7 seconds, and speaker changes when speaker labels are available. Timing comes from the words, rounded to -milliseconds. Subtitle output goes to stdout; missing or invalid word timestamps -produce an error. Without `--srt`, output remains plain text. +milliseconds. If word start times move backwards, they are clamped to the +preceding word's start without reordering the transcript; end times are extended +to the adjusted start only when needed. Subtitle output goes to stdout; missing +or invalid word timestamps produce an error. Without `--srt`, output remains +plain text. One file per invocation; the whole recording is sent in one request, so the service's audio length and request size limits apply. diff --git a/subtitles.py b/subtitles.py index f595cc5..1a3e5d2 100644 --- a/subtitles.py +++ b/subtitles.py @@ -59,12 +59,17 @@ def convert(response): or end < start ): raise ValueError("Invalid subtitle word: expected finite 0 <= start <= end") - if start < previous_start: - raise ValueError("Invalid subtitle words: timestamps are out of order") - previous_start = start word = dict(item, word=" ".join(item["word"].split())) if not word["word"]: continue + # Keep transcript order even when the model's word alignment moves + # backwards. Sorting by time would scramble the spoken text. Clamp + # only regressing starts, retaining the end unless it now precedes + # the start. Work on a copy so the API response remains unchanged. + start = max(start, previous_start) + end = max(end, start) + word.update(start=start, end=end) + previous_start = start if len(lines(word["word"])) > 2: raise ValueError("Invalid subtitle word: text exceeds two lines") if current: diff --git a/test_subtitles.py b/test_subtitles.py index fa61075..8b35cfc 100644 --- a/test_subtitles.py +++ b/test_subtitles.py @@ -1,3 +1,4 @@ +import copy import unittest from subtitles import convert, timestamp @@ -60,11 +61,52 @@ class SubtitleTests(unittest.TestCase): {"text": "speech", "words": []}, {"words": [{"word": "bad", "start": 2, "end": 1}]}, {"words": [{"word": "bad", "start": float("nan"), "end": 1}]}, + {"words": [{"word": "bad", "start": -1, "end": 1}]}, + {"words": [{"word": "bad", "start": 0, "end": float("inf")}]}, + {"words": [{"word": "bad", "start": True, "end": 1}]}, {"error": {"message": "failed"}}, ): with self.assertRaises(ValueError): convert(response) + def test_regressing_timestamps_preserve_text_and_response(self): + response = {"words": [ + {"word": "Glasfaser", "start": 10, "end": 10.4}, + {"word": "mit", "start": 9.8, "end": 10.2}, + {"word": "10", "start": 9.7, "end": 9.9}, + {"word": "Gbit/s", "start": 10.5, "end": 11}, + ]} + original = copy.deepcopy(response) + self.assertEqual( + convert(response), + "1\n00:00:10,000 --> 00:00:11,000\nGlasfaser mit 10 Gbit/s\n\n", + ) + self.assertEqual(response, original) + + def test_regression_across_cues_and_zero_duration(self): + self.assertEqual(convert({"words": [ + {"word": "First", "start": 10, "end": 11, "speaker": 0}, + {"word": "Second", "start": 9, "end": 9.5, "speaker": 1}, + {"word": "Third", "start": 12, "end": 13, "speaker": 0}, + ]}), ( + "1\n00:00:10,000 --> 00:00:11,000\nFirst\n\n" + "2\n00:00:10,000 --> 00:00:10,001\nSecond\n\n" + "3\n00:00:12,000 --> 00:00:13,000\nThird\n\n" + )) + + def test_blank_words_do_not_shift_timing(self): + self.assertEqual(convert({"words": [ + {"word": " ", "start": 20, "end": 21}, + {"word": "Speech", "start": 10, "end": 11}, + ]}), "1\n00:00:10,000 --> 00:00:11,000\nSpeech\n\n") + + def test_regression_does_not_hide_invalid_duration(self): + with self.assertRaises(ValueError): + convert({"words": [ + {"word": "valid", "start": 10, "end": 11}, + {"word": "invalid", "start": 9, "end": 8}, + ]}) + def test_empty_and_rounding(self): self.assertEqual(convert({"words": [], "text": ""}), "") self.assertEqual(timestamp(59.9996), "00:01:00,000")