diff --git a/audiofile/__init__.py b/audiofile/__init__.py index 3a6fd8d8..13367be2 100644 --- a/audiofile/__init__.py +++ b/audiofile/__init__.py @@ -2,6 +2,7 @@ from audiofile.core.info import bit_depth from audiofile.core.info import channels from audiofile.core.info import duration +from audiofile.core.info import has_video from audiofile.core.info import samples from audiofile.core.info import sampling_rate from audiofile.core.io import convert_to_wav diff --git a/audiofile/core/info.py b/audiofile/core/info.py index 4181d9bf..501a1203 100644 --- a/audiofile/core/info.py +++ b/audiofile/core/info.py @@ -182,6 +182,47 @@ def duration(file: str, sloppy=False) -> float: return samples(file) / sampling_rate(file) +def has_video(file: str) -> bool: + """If file contains video. + + For WAV, FLAC, MP3, or OGG files + ``False`` is returned. + All other files are probed by mediainfo. + + Args: + file: file name of input file + + Returns: + ``True`` if file contains video data + + Raises: + FileNotFoundError: if mediainfo binary is needed, + but cannot be found + RuntimeError: if ``file`` is missing + and does not end with ``"wav"``, ``"flac"``, ``"mp3"``, ``"ogg"`` + + Examples: + >>> has_video("stereo.wav") + False + + """ + if file_extension(file) in SNDFORMATS: + return False + else: + try: + path = audeer.path(file) + if not os.path.exists(path): + raise RuntimeError(f"'{file}' does not exist.") + cmd = ["mediainfo", "--Inform=Video;%Format%", path] + video_format = run(cmd) + if len(video_format) > 0: + return True + else: + return False + except FileNotFoundError: + raise binary_missing_error("mediainfo") + + def samples(file: str) -> int: """Number of samples in audio file. diff --git a/docs/api-src/audiofile.rst b/docs/api-src/audiofile.rst index 05e73e2e..e1eb42ac 100644 --- a/docs/api-src/audiofile.rst +++ b/docs/api-src/audiofile.rst @@ -12,6 +12,7 @@ audiofile bit_depth channels duration + has_video samples sampling_rate convert_to_wav diff --git a/tests/assets/README.md b/tests/assets/README.md index 0afaf161..dd40765c 100644 --- a/tests/assets/README.md +++ b/tests/assets/README.md @@ -1,4 +1,6 @@ -# Non SND format audio test files +# Test files + +## Non SND format audio test files This folder contains files to test file formats that cannot be stored with `audiofile.write()` @@ -10,5 +12,13 @@ They are excerpts from "Furious Freak" and "Galway", Kevin MacLeod (incompetech.com), -Licensed under Creative Commons: +licensed under Creative Commons: +[CC-BY-3.0](http://creativecommons.org/licenses/by/3.0/). + +## Video test files + +The folder contains the video file `video.mp4`, +which is a short excerpt from "Big Bunny" +from Blender Foundation | www.blender.org, +licensed under Creative Commons: [CC-BY-3.0](http://creativecommons.org/licenses/by/3.0/). diff --git a/tests/assets/video.mp4 b/tests/assets/video.mp4 new file mode 100644 index 00000000..a9bc1828 Binary files /dev/null and b/tests/assets/video.mp4 differ diff --git a/tests/test_audiofile.py b/tests/test_audiofile.py index 6de8ef31..98636634 100644 --- a/tests/test_audiofile.py +++ b/tests/test_audiofile.py @@ -211,6 +211,8 @@ def test_missing_binaries(tmpdir, hide_system_path, empty_file): af.duration(empty_file) with pytest.raises(expected_error, match="mediainfo"): af.duration(empty_file, sloppy=True) + with pytest.raises(expected_error, match="mediainfo"): + af.has_video(empty_file) with pytest.raises(expected_error, match="ffmpeg"): af.samples(empty_file) with pytest.raises(expected_error, match="mediainfo"): @@ -247,6 +249,22 @@ def test_missing_file(tmpdir, ext): af.convert_to_wav(missing_file, converted_file) +@pytest.mark.parametrize( + "file, expected_error, expected_error_message", + [ + ("missing_file.bin", RuntimeError, "'missing_file.bin' does not exist"), + ("missing_file.mp4", RuntimeError, "'missing_file.mp4' does not exist"), + ("missing_file.wav", None, None), + ], +) +def test_missing_file_has_video(file, expected_error, expected_error_message): + if expected_error is not None: + with pytest.raises(expected_error, match=expected_error_message): + af.has_video(file) + else: + assert af.has_video(file) is False + + @pytest.mark.parametrize( "non_audio_file", ("bin", "mp4", "wav"), @@ -488,40 +506,40 @@ def test_file_type(tmpdir, file_type, magnitude, sampling_rate, channels): if file_type in ["mp3", "ogg"]: bit_depth = None assert af.bit_depth(file) == bit_depth + assert af.has_video(file) is False -def test_other_formats(): - files = [ - "gs-16b-1c-44100hz.opus", - "gs-16b-1c-8000hz.amr", - "gs-16b-1c-44100hz.m4a", - "gs-16b-1c-44100hz.aac", - ] - header_durations = [ # as given by mediainfo - 15.839, - 15.840000, - 15.833, - None, - ] - files = [os.path.join(ASSETS_DIR, f) for f in files] - for file, header_duration in zip(files, header_durations): - signal, sampling_rate = af.read(file) - assert af.channels(file) == _channels(signal) - assert af.sampling_rate(file) == sampling_rate - assert af.samples(file) == _samples(signal) +@pytest.mark.parametrize( + "file, header_duration, audio, video", # header duration as given by mediainfo + [ + ("gs-16b-1c-44100hz.opus", 15.839, True, False), + ("gs-16b-1c-8000hz.amr", 15.840000, True, False), + ("gs-16b-1c-44100hz.m4a", 15.833, True, False), + ("gs-16b-1c-44100hz.aac", None, True, False), + ("video.mp4", None, False, True), + ], +) +def test_other_formats(file, header_duration, audio, video): + path = os.path.join(ASSETS_DIR, file) + if audio: + signal, sampling_rate = af.read(path) + assert af.channels(path) == _channels(signal) + assert af.sampling_rate(path) == sampling_rate + assert af.samples(path) == _samples(signal) duration = _duration(signal, sampling_rate) - assert af.duration(file) == duration + assert af.duration(path) == duration if header_duration is None: # Here we expect samplewise precision - assert af.duration(file, sloppy=True) == duration + assert af.duration(path, sloppy=True) == duration else: # Here we expect limited precision # as the results differ between soxi and mediainfo precision = 1 - sloppy_duration = round(af.duration(file, sloppy=True), precision) + sloppy_duration = round(af.duration(path, sloppy=True), precision) header_duration = round(header_duration, precision) assert sloppy_duration == header_duration - assert af.bit_depth(file) is None + assert af.bit_depth(path) is None + assert af.has_video(path) is video @pytest.mark.parametrize(