Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
80 changes: 80 additions & 0 deletions tests/_render_assertions.py
Original file line number Diff line number Diff line change
@@ -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))
94 changes: 22 additions & 72 deletions tests/test_examples_render.py
Original file line number Diff line number Diff line change
@@ -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")

Expand All @@ -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))