diff --git a/pyproject.toml b/pyproject.toml index e0d855d..7708ae1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -139,6 +139,10 @@ max-complexity = 8 [tool.pytest.ini_options] testpaths = ["tests"] +markers = [ + "integration: tests that execute external tools or full workflows", + "network: tests that require outbound network access", +] [tool.coverage.run] source = ["manim_voiceover"] diff --git a/tests/_render_assertions.py b/tests/_render_assertions.py new file mode 100644 index 0000000..c55404c --- /dev/null +++ b/tests/_render_assertions.py @@ -0,0 +1,80 @@ +import json +import subprocess +from pathlib import Path +from typing import List, Mapping, Sequence, cast + +import numpy as np +from pydub import AudioSegment + + +def assert_video_has_audio_stream(video_path: Path) -> None: + stream_info = _ffprobe_audio_stream(video_path) + assert stream_info["codec_type"] == "audio" + + +def load_audible_audio(video_path: Path) -> AudioSegment: + audio = AudioSegment.from_file(video_path) + assert len(audio) > 0 + assert audio.dBFS > -60 + return audio + + +def assert_audio_is_speech_like(audio: AudioSegment, minimum_median_bandwidth_hz: float = 250) -> None: + bandwidth = _median_spectral_bandwidth(audio) + assert bandwidth > minimum_median_bandwidth_hz, ( + f"Expected speech-like audio bandwidth above {minimum_median_bandwidth_hz} Hz, got {bandwidth:.2f} Hz" + ) + + +def _ffprobe_audio_stream(video_path: Path) -> Mapping[str, object]: + result = subprocess.run( + [ + "ffprobe", + "-v", + "error", + "-select_streams", + "a:0", + "-show_entries", + "stream=codec_type", + "-of", + "json", + str(video_path), + ], + check=True, + capture_output=True, + text=True, + ) + ffprobe_output = cast(Mapping[str, object], json.loads(result.stdout)) + streams = cast(Sequence[Mapping[str, object]], ffprobe_output["streams"]) + assert streams, f"{video_path} does not contain an audio stream" + return streams[0] + + +def _median_spectral_bandwidth(audio: AudioSegment) -> float: + samples_per_second = 16_000 + frame_size = 2048 + step = 1024 + normalized = audio.set_channels(1).set_frame_rate(samples_per_second) + samples = np.array(normalized.get_array_of_samples(), dtype=np.float64) + if samples.size < frame_size: + return 0.0 + + max_amplitude = float(1 << (8 * normalized.sample_width - 1)) + samples = samples / max_amplitude + frequencies = np.fft.rfftfreq(frame_size, d=1 / samples_per_second) + bandwidths: List[float] = [] + for start in range(0, samples.size - frame_size + 1, step): + frame = samples[start : start + frame_size] + if float(np.sqrt(np.mean(frame**2))) < 0.005: + continue + spectrum = np.abs(np.fft.rfft(frame * np.hanning(frame_size))) + magnitude_sum = float(np.sum(spectrum)) + if magnitude_sum <= 0: + continue + centroid = float(np.sum(frequencies * spectrum) / magnitude_sum) + bandwidth = float(np.sqrt(np.sum(((frequencies - centroid) ** 2) * spectrum) / magnitude_sum)) + bandwidths.append(bandwidth) + + if not bandwidths: + return 0.0 + return float(np.median(bandwidths)) diff --git a/tests/test_examples_render.py b/tests/test_examples_render.py index 26c0eb5..cbf6abc 100644 --- a/tests/test_examples_render.py +++ b/tests/test_examples_render.py @@ -1,20 +1,29 @@ -import json import subprocess import sys +from dataclasses import dataclass from pathlib import Path -from typing import List, Mapping, Sequence, Tuple, cast +from typing import Sequence -import numpy as np import pytest -from pydub import AudioSegment -EXAMPLE_SCENES: Sequence[Tuple[str, str]] = [ - ("examples/gtts-example.py", "GTTSExample"), +from _render_assertions import assert_audio_is_speech_like, assert_video_has_audio_stream, load_audible_audio + + +@dataclass(frozen=True) +class RenderableExample: + path: Path + scene_name: str + + +EXAMPLE_SCENES: Sequence[RenderableExample] = [ + RenderableExample(path=Path("examples/gtts-example.py"), scene_name="GTTSExample"), ] -@pytest.mark.parametrize(("example_path", "scene_name"), EXAMPLE_SCENES) -def test_actual_example_renders_video_with_audible_speech(tmp_path: Path, example_path: str, scene_name: str) -> None: +@pytest.mark.integration +@pytest.mark.network +@pytest.mark.parametrize("example", EXAMPLE_SCENES, ids=lambda example: example.scene_name) +def test_gtts_example_renders_speech_like_audio(tmp_path: Path, example: RenderableExample) -> None: if Path.cwd().name == "mutants": pytest.skip("mutmut does not copy example files into its generated worktree") @@ -27,73 +36,14 @@ def test_actual_example_renders_video_with_audible_speech(tmp_path: Path, exampl "--disable_caching", "--media_dir", str(media_dir), - example_path, - scene_name, + str(example.path), + example.scene_name, ] subprocess.run(command, check=True) - video_path = media_dir / "videos" / Path(example_path).stem / "480p15" / f"{scene_name}.mp4" + video_path = media_dir / "videos" / example.path.stem / "480p15" / f"{example.scene_name}.mp4" assert video_path.exists(), f"Manim did not render {video_path}" - stream_info = _ffprobe_audio_stream(video_path) - assert stream_info["codec_type"] == "audio" - - rendered_audio = AudioSegment.from_file(video_path) - assert len(rendered_audio) > 0 - assert rendered_audio.dBFS > -60 - assert _median_spectral_bandwidth(rendered_audio) > 250 - - -def _ffprobe_audio_stream(video_path: Path) -> Mapping[str, object]: - result = subprocess.run( - [ - "ffprobe", - "-v", - "error", - "-select_streams", - "a:0", - "-show_entries", - "stream=codec_type", - "-of", - "json", - str(video_path), - ], - check=True, - capture_output=True, - text=True, - ) - ffprobe_output = cast(Mapping[str, object], json.loads(result.stdout)) - streams = cast(Sequence[Mapping[str, object]], ffprobe_output["streams"]) - assert streams, f"{video_path} does not contain an audio stream" - return streams[0] - - -def _median_spectral_bandwidth(audio: AudioSegment) -> float: - samples_per_second = 16_000 - frame_size = 2048 - step = 1024 - normalized = audio.set_channels(1).set_frame_rate(samples_per_second) - samples = np.array(normalized.get_array_of_samples(), dtype=np.float64) - if samples.size < frame_size: - return 0.0 - - max_amplitude = float(1 << (8 * normalized.sample_width - 1)) - samples = samples / max_amplitude - frequencies = np.fft.rfftfreq(frame_size, d=1 / samples_per_second) - bandwidths: List[float] = [] - for start in range(0, samples.size - frame_size + 1, step): - frame = samples[start : start + frame_size] - if float(np.sqrt(np.mean(frame**2))) < 0.005: - continue - spectrum = np.abs(np.fft.rfft(frame * np.hanning(frame_size))) - magnitude_sum = float(np.sum(spectrum)) - if magnitude_sum <= 0: - continue - centroid = float(np.sum(frequencies * spectrum) / magnitude_sum) - bandwidth = float(np.sqrt(np.sum(((frequencies - centroid) ** 2) * spectrum) / magnitude_sum)) - bandwidths.append(bandwidth) - - if not bandwidths: - return 0.0 - return float(np.median(bandwidths)) + assert_video_has_audio_stream(video_path) + assert_audio_is_speech_like(load_audible_audio(video_path))