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)
|