feat: transcribe long recordings in overlapping chunks

Sending entire recordings in one request can exceed provider limits.
Decode audio once to a shared PCM sample clock and split uploads at a
configurable maximum of 1 to 30 minutes, defaulting to 30 minutes.
Prefer nearby pauses and include six seconds of overlap to protect words
at audio cuts while keeping each encoded MP3 within the duration cap.

Request word timestamps for chunked text and SRT output. Match overlapping
words and times to retain each passage once, warn when a join falls back
to timestamps, and generate SRT cues on the original recording timeline.
Reuse subtitle timestamp validation without reordering regressing words.
Only emit output after every request and conversion succeeds.

Document the Python dependency, temporary storage, boundary limitations,
and lack of automatic retry or resume. Keep single-request text formatting.

Test Plan:
- python3 -B -m unittest -v: all 29 tests passed, including mocked provider
  failures, native MP3 payload checks, boundary stitching, and SRT timing.
- bash -n transcribe and shellcheck transcribe: passed.
- git diff --cached --check: passed.
- Locally decoded and encoded the supplied 74-minute WebM into three MP3
  chunks; ffprobe confirmed each duration was below 30 minutes.
- Live transcription was not verified: the configured 1Password credential
  was unavailable.
This commit is contained in:
ddidderr committed 2026-09-12 08:53:31 +02:00
1 parent 300203d33f
commit a5c535cfb9
7 files changed
+635 -57

No files matched your search

+154
View File
@@ -0,0 +1,154 @@
import contextlib
import copy
import io
from pathlib import Path
import tempfile
import unittest
import wave
from chunks import CONTEXT, ENCODING_MARGIN, merge, plan, stitch
from subtitles import convert
def word(text, start, end=None):
return {"word": text, "start": start, "end": start + 0.4 if end is None else end}
def make_audio(path, duration, pauses=()):
rate = 16000
samples = bytearray(b"\x10\x27" * round(rate * duration))
for start, end in pauses:
first, last = round(start * rate) * 2, round(end * rate) * 2
samples[first:last] = bytes(last - first)
with wave.open(str(path), "wb") as audio:
audio.setparams((1, 2, rate, 0, "NONE", "not compressed"))
audio.writeframes(samples)
class ChunkPlanTests(unittest.TestCase):
def planned(self, duration, pauses=(), limit=60):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "audio.wav"
make_audio(path, duration, pauses)
return plan(path, limit)
def test_short_audio_is_one_upload(self):
self.assertEqual(self.planned(20), [{"start": 0, "end": 20, "seam": 0}])
def test_continuous_speech_is_bounded_and_has_no_gaps(self):
for duration in (59.9, 60, 70, 120, 140):
with self.subTest(duration=duration):
chunks = self.planned(duration)
self.assertEqual(chunks[0]["start"], 0)
self.assertEqual(chunks[-1]["end"], duration)
for chunk in chunks:
self.assertGreater(chunk["end"], chunk["start"])
self.assertLessEqual(chunk["end"] - chunk["start"], 60 - ENCODING_MARGIN + 1e-8)
for previous, current in zip(chunks, chunks[1:]):
self.assertAlmostEqual(previous["end"] - current["start"], CONTEXT * 2)
self.assertAlmostEqual(current["seam"] - current["start"], CONTEXT)
def test_uses_last_pause_before_limit(self):
chunks = self.planned(130, [(30, 31), (53, 54)])
self.assertAlmostEqual(chunks[1]["seam"], 53.5)
self.assertAlmostEqual(chunks[0]["end"], 56.5)
self.assertAlmostEqual(chunks[1]["start"], 50.5)
def test_short_quiet_sound_does_not_count_as_pause(self):
chunks = self.planned(70, [(53, 53.1)])
self.assertAlmostEqual(chunks[1]["seam"], 56.9)
def test_all_silence_still_makes_progress(self):
chunks = self.planned(140, [(0, 140)])
self.assertEqual(chunks[-1]["end"], 140)
self.assertLess(len(chunks), 10)
self.assertTrue(all(0 < c["end"] - c["start"] <= 60 for c in chunks))
def test_invalid_limit_and_empty_audio(self):
for limit in (0, 59, 1801, float("inf"), float("nan")):
with self.subTest(limit=limit), self.assertRaises(ValueError):
self.planned(1, limit=limit)
with self.assertRaisesRegex(ValueError, "no samples"):
self.planned(0)
class ChunkMergeTests(unittest.TestCase):
def test_boundary_word_survives_once_despite_timestamp_drift(self):
left = [word("before", 8), word("boundary,", 9.8, 10.4), word("after", 10.5)]
right = [word("Boundary", 10.0, 10.6), word("after", 10.6), word("later", 14)]
result = stitch(left, right, 10)
self.assertEqual([w["word"] for w in result], ["before", "boundary,", "after", "later"])
def test_words_cut_at_upload_edges_are_discarded(self):
left = [word("Before", 5), word("shared", 9), word("words", 10), word("incom-", 12.8)]
right = [word("-fore", 7), word("shared", 9.1), word("words", 10.1), word("incomplete", 12.6)]
result = stitch(left, right, 10)
self.assertEqual([w["word"] for w in result], ["Before", "shared", "words", "incomplete"])
def test_intentional_repeated_words_are_not_removed(self):
left = [word("yes", start, start + 0.1) for start in (9.4, 9.8, 10.2, 10.6)]
right = copy.deepcopy(left) + [word("done", 14)]
result = stitch(left, right, 10)
self.assertEqual([w["word"] for w in result], ["yes"] * 4 + ["done"])
def test_identical_text_outside_overlap_is_not_deduplicated(self):
left = [word("again", 1)]
right = [word("again", 20)]
self.assertEqual(stitch(left, right, 10), left + right)
def test_no_match_uses_whole_words_and_warns(self):
left = [word("before", 8), word("left", 9.5, 10.1), word("discard", 11)]
right = [word("discard", 8), word("right", 10.1), word("after", 14)]
with contextlib.redirect_stderr(io.StringIO()) as errors:
result = stitch(left, right, 10)
self.assertEqual([w["word"] for w in result], ["before", "left", "right", "after"])
self.assertIn("joining by timestamps", errors.getvalue())
def test_silent_overlap_does_not_warn(self):
with contextlib.redirect_stderr(io.StringIO()) as errors:
self.assertEqual(stitch([word("before", 1)], [word("after", 20)], 10),
[word("before", 1), word("after", 20)])
self.assertEqual(errors.getvalue(), "")
def test_offsets_three_chunks_and_keeps_srt_numbering(self):
chunks = [{"start": 0, "end": 63, "seam": 0},
{"start": 57, "end": 120, "seam": 60},
{"start": 114, "end": 140, "seam": 117}]
responses = [
{"words": [word("First.", 1), word("Join.", 59.8, 60.2)]},
{"words": [word("Join.", 2.9, 3.3), word("Second.", 20), word("Next.", 59.8, 60.2)]},
{"words": [word("Next.", 2.9, 3.3), word("Last.", 20)]},
]
original = copy.deepcopy(responses)
result = merge(chunks, responses)
self.assertEqual(result["text"], "First. Join. Second. Next. Last.")
self.assertEqual([w["start"] for w in result["words"]], [1, 59.8, 77, 116.8, 134])
self.assertEqual(responses, original)
srt = convert(result)
self.assertIn("5\n00:02:14,000", srt)
def test_regression_preserves_order_and_scopes_speakers_per_chunk(self):
chunks = [{"start": 0, "end": 63, "seam": 0}, {"start": 57, "end": 100, "seam": 60}]
first = dict(word("First", 10), speaker=0)
second = dict(word("Second", 9), speaker=0)
last = dict(word("Last", 20), speaker=0)
result = merge(chunks, [{"words": [first, second]}, {"words": [last]}])
self.assertEqual([w["start"] for w in result["words"]], [10, 10, 77])
self.assertNotEqual(result["words"][0]["speaker"], result["words"][-1]["speaker"])
def test_missing_or_malformed_words_fail(self):
chunks = [{"start": 0, "end": 30, "seam": 0}]
for response in ({"text": "missing"}, {"text": "missing", "words": []},
{"text": "missing", "words": [word(" ", 1)]},
{"text": None, "words": []},
{"words": [word("invalid", 100)]}, {"words": [word("invalid", -1)]},
{"error": "bad response"}, []):
with self.subTest(response=response), self.assertRaisesRegex(ValueError, "Chunk 1"):
merge(chunks, [response])
with self.assertRaises(ValueError):
merge(chunks, [])
self.assertEqual(merge(chunks, [{"text": "", "words": []}])["text"], "")
if __name__ == "__main__":
unittest.main()