304b639b4d
Frame extraction now runs a perceptual dedup pass (default on, --no-dedup to disable) that drops near-identical frames before the budget cap, so the budget goes to distinct content instead of held slides/static recordings. The Frames report line notes how many near-duplicates were dropped. Whisper transcription splits audio over the 25 MB upload cap into evenly sized chunks, transcribes each, shifts segment timestamps back into source time, and tolerates partial failures (only fails if every chunk fails) — length alone no longer fails transcription. token-burner is exempt from the long-video sparse-scan warning since it keeps every scene-change frame. README/SKILL.md updated. Adds test_dedup.py and test_whisper.py. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
158 lines
5.5 KiB
Python
158 lines
5.5 KiB
Python
"""Whisper auto-chunking: plan, split, and timestamp stitching."""
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
import whisper
|
|
|
|
|
|
MB = 1024 * 1024
|
|
|
|
|
|
class TestPlanChunks:
|
|
def test_under_limit_is_single_chunk(self):
|
|
plan = whisper.plan_chunks(total_seconds=600.0, total_bytes=5 * MB, max_bytes=24 * MB)
|
|
assert plan == [(0.0, 600.0)]
|
|
|
|
def test_at_limit_is_single_chunk(self):
|
|
plan = whisper.plan_chunks(total_seconds=600.0, total_bytes=24 * MB, max_bytes=24 * MB)
|
|
assert plan == [(0.0, 600.0)]
|
|
|
|
def test_over_limit_splits_into_enough_chunks(self):
|
|
# 71 MB against a 24 MB cap → ceil(71/24) = 3 chunks.
|
|
plan = whisper.plan_chunks(total_seconds=3600.0, total_bytes=71 * MB, max_bytes=24 * MB)
|
|
assert len(plan) == 3
|
|
|
|
def test_chunks_are_contiguous_and_cover_full_duration(self):
|
|
total = 3600.0
|
|
plan = whisper.plan_chunks(total_seconds=total, total_bytes=71 * MB, max_bytes=24 * MB)
|
|
# Offsets start at 0 and each picks up where the previous ended.
|
|
assert plan[0][0] == 0.0
|
|
for (off, dur), (next_off, _) in zip(plan, plan[1:]):
|
|
assert math.isclose(off + dur, next_off)
|
|
last_off, last_dur = plan[-1]
|
|
assert math.isclose(last_off + last_dur, total)
|
|
|
|
def test_each_chunk_estimated_under_limit(self):
|
|
total_seconds, total_bytes, cap = 3600.0, 71 * MB, 24 * MB
|
|
plan = whisper.plan_chunks(total_seconds, total_bytes, cap)
|
|
bytes_per_second = total_bytes / total_seconds
|
|
for _off, dur in plan:
|
|
assert dur * bytes_per_second <= cap
|
|
|
|
def test_zero_duration_is_single_chunk(self):
|
|
plan = whisper.plan_chunks(total_seconds=0.0, total_bytes=0, max_bytes=24 * MB)
|
|
assert plan == [(0.0, 0.0)]
|
|
|
|
|
|
class TestShiftSegments:
|
|
def test_adds_offset_to_start_and_end(self):
|
|
segs = [{"start": 0.0, "end": 2.5, "text": "hi"}, {"start": 2.5, "end": 4.0, "text": "there"}]
|
|
shifted = whisper.shift_segments(segs, 1800.0)
|
|
assert shifted == [
|
|
{"start": 1800.0, "end": 1802.5, "text": "hi"},
|
|
{"start": 1802.5, "end": 1804.0, "text": "there"},
|
|
]
|
|
|
|
def test_zero_offset_is_identity(self):
|
|
segs = [{"start": 1.0, "end": 2.0, "text": "x"}]
|
|
assert whisper.shift_segments(segs, 0.0) == segs
|
|
|
|
def test_does_not_mutate_input(self):
|
|
segs = [{"start": 0.0, "end": 1.0, "text": "x"}]
|
|
whisper.shift_segments(segs, 10.0)
|
|
assert segs[0]["start"] == 0.0
|
|
|
|
|
|
def _make_mp3(path: Path, seconds: float) -> None:
|
|
"""Synthesize a mono 16k 64k mp3 of a sine tone — mirrors extract_audio's format."""
|
|
subprocess.run(
|
|
[
|
|
"ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
|
|
"-f", "lavfi", "-t", str(seconds), "-i", "sine=frequency=440:sample_rate=16000",
|
|
"-acodec", "libmp3lame", "-ar", "16000", "-ac", "1", "-b:a", "64k",
|
|
str(path),
|
|
],
|
|
check=True,
|
|
)
|
|
|
|
|
|
class TestSplitAudio:
|
|
def test_creates_one_file_per_plan_entry(self, tmp_path: Path):
|
|
full = tmp_path / "audio.mp3"
|
|
_make_mp3(full, 6.0)
|
|
plan = [(0.0, 3.0), (3.0, 3.0)]
|
|
|
|
chunks = whisper.split_audio(full, tmp_path, plan)
|
|
|
|
assert len(chunks) == 2
|
|
for chunk_path, _offset in chunks:
|
|
assert chunk_path.exists() and chunk_path.stat().st_size > 0
|
|
|
|
def test_returns_plan_offsets(self, tmp_path: Path):
|
|
full = tmp_path / "audio.mp3"
|
|
_make_mp3(full, 6.0)
|
|
plan = [(0.0, 3.0), (3.0, 3.0)]
|
|
|
|
chunks = whisper.split_audio(full, tmp_path, plan)
|
|
|
|
assert [offset for _path, offset in chunks] == [0.0, 3.0]
|
|
|
|
def test_chunks_are_smaller_than_full(self, tmp_path: Path):
|
|
full = tmp_path / "audio.mp3"
|
|
_make_mp3(full, 6.0)
|
|
plan = [(0.0, 3.0), (3.0, 3.0)]
|
|
|
|
chunks = whisper.split_audio(full, tmp_path, plan)
|
|
|
|
full_size = full.stat().st_size
|
|
for chunk_path, _offset in chunks:
|
|
assert chunk_path.stat().st_size < full_size
|
|
|
|
|
|
class TestAudioDuration:
|
|
def test_reads_duration_of_synthesized_clip(self, tmp_path: Path):
|
|
audio = tmp_path / "audio.mp3"
|
|
_make_mp3(audio, 5.0)
|
|
assert whisper.audio_duration(audio) == pytest.approx(5.0, abs=0.5)
|
|
|
|
|
|
class TestTranscribeChunks:
|
|
def test_shifts_and_concatenates_each_chunk(self):
|
|
chunks = [(Path("a.mp3"), 0.0), (Path("b.mp3"), 100.0)]
|
|
|
|
def fake_transcribe(path: Path) -> list[dict]:
|
|
return [{"start": 0.0, "end": 2.0, "text": path.stem}]
|
|
|
|
out = whisper.transcribe_chunks(chunks, fake_transcribe)
|
|
|
|
assert out == [
|
|
{"start": 0.0, "end": 2.0, "text": "a"},
|
|
{"start": 100.0, "end": 102.0, "text": "b"},
|
|
]
|
|
|
|
def test_keeps_successful_chunks_when_one_fails(self):
|
|
chunks = [(Path("a.mp3"), 0.0), (Path("b.mp3"), 100.0)]
|
|
|
|
def flaky(path: Path) -> list[dict]:
|
|
if path.stem == "b":
|
|
raise SystemExit("chunk b failed")
|
|
return [{"start": 1.0, "end": 2.0, "text": "a"}]
|
|
|
|
out = whisper.transcribe_chunks(chunks, flaky)
|
|
|
|
assert out == [{"start": 1.0, "end": 2.0, "text": "a"}]
|
|
|
|
def test_raises_when_every_chunk_fails(self):
|
|
chunks = [(Path("a.mp3"), 0.0), (Path("b.mp3"), 100.0)]
|
|
|
|
def always_fail(path: Path) -> list[dict]:
|
|
raise SystemExit("boom")
|
|
|
|
with pytest.raises(SystemExit):
|
|
whisper.transcribe_chunks(chunks, always_fail)
|