diff --git a/CHANGELOG.md b/CHANGELOG.md index c8064e331..e4a3c77bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed opacity error blitting VideoClips [\#1552](https://github.com/Zulko/moviepy/pull/1552) - Fixed rotation metadata of input not being taken into account rendering VideoClips [\#577](https://github.com/Zulko/moviepy/pull/577) - Fixed mono clips crashing when `audio_fadein` FX applied [\#1574](https://github.com/Zulko/moviepy/pull/1574) +- Fixed mono clips crashing when `audio_fadeout` FX applied [\#1578](https://github.com/Zulko/moviepy/pull/1578) ## [v2.0.0.dev2](https://github.com/zulko/moviepy/tree/v2.0.0.dev2) (2020-10-05) diff --git a/moviepy/audio/fx/audio_fadeout.py b/moviepy/audio/fx/audio_fadeout.py index a5b693be0..c7a1316ca 100644 --- a/moviepy/audio/fx/audio_fadeout.py +++ b/moviepy/audio/fx/audio_fadeout.py @@ -1,24 +1,51 @@ import numpy as np -from moviepy.decorators import audio_video_fx, requires_duration +from moviepy.decorators import ( + audio_video_fx, + convert_parameter_to_seconds, + requires_duration, +) + + +def _mono_factor_getter(clip_duration): + return lambda t, duration: np.minimum(1.0 * (clip_duration - t) / duration, 1) + + +def _stereo_factor_getter(clip_duration, nchannels): + def getter(t, duration): + factor = np.minimum(1.0 * (clip_duration - t) / duration, 1) + return np.array([factor for _ in range(nchannels)]).T + + return getter @audio_video_fx @requires_duration +@convert_parameter_to_seconds(["duration"]) def audio_fadeout(clip, duration): """Return a sound clip where the sound fades out progressively over ``duration`` seconds at the end of the clip. - """ - def fading(get_frame, t): - frame = get_frame(t) + Parameters + ---------- + + duration : float + How long does it take for the sound to reach the zero level at the end + of the clip. - if np.isscalar(t): - factor = min(1.0 * (clip.duration - t) / duration, 1) - factor = np.array([factor, factor]) - else: - factor = np.minimum(1.0 * (clip.duration - t) / duration, 1) - factor = np.vstack([factor, factor]).T - return factor * frame + Examples + -------- + + >>> clip = VideoFileClip("media/chaplin.mp4") + >>> clip.fx(audio_fadeout, "00:00:06") + """ + get_factor = ( + _mono_factor_getter(clip.duration) + if clip.nchannels == 1 + else _stereo_factor_getter(clip.duration, clip.nchannels) + ) - return clip.transform(fading, keep_duration=True) + return clip.transform( + lambda get_frame, t: get_factor(t, duration) * get_frame(t), + keep_duration=True, + ) diff --git a/tests/test_fx.py b/tests/test_fx.py index 3de4f55d3..52bdb838a 100644 --- a/tests/test_fx.py +++ b/tests/test_fx.py @@ -20,6 +20,7 @@ from moviepy.audio.fx import ( audio_delay, audio_fadein, + audio_fadeout, audio_normalize, multiply_stereo_volume, multiply_volume, @@ -1342,5 +1343,61 @@ def test_audio_fadein(sound_type, fps, clip_duration, fadein_duration): assert round(subclip_max_volume, 4) == 1 +@pytest.mark.parametrize("sound_type", ("stereo", "mono")) +@pytest.mark.parametrize("fps", (44100, 22050)) +@pytest.mark.parametrize( + ("clip_duration", "fadeout_duration"), + ( + ( + (0.2, 0.1), + (0.7, "00:00:00,4"), + (0.3, 0.13), + ) + ), +) +def test_audio_fadeout(sound_type, fps, clip_duration, fadeout_duration): + if sound_type == "stereo": + make_frame = lambda t: np.array( + [np.sin(440 * 2 * np.pi * t), np.sin(160 * 2 * np.pi * t)] + ).T.copy(order="C") + else: + make_frame = lambda t: np.sin(440 * 2 * np.pi * t) + + clip = AudioClip(make_frame, duration=clip_duration, fps=fps) + new_clip = audio_fadeout(clip, fadeout_duration) + + fadeout_duration = convert_to_seconds(fadeout_duration) + + n_parts = 10 + + # cut transformed part into subclips and check the expected max_volume for + # each one + time_foreach_part = fadeout_duration / n_parts + start_times = np.arange( + clip_duration - fadeout_duration, + clip_duration, + time_foreach_part, + ) + for i, start_time in enumerate(start_times): + end_time = start_time + time_foreach_part + subclip_max_volume = new_clip.subclip(start_time, end_time).max_volume() + + possible_value = 1 - i * 0.1 + assert round(subclip_max_volume, 2) in [ + round(possible_value, 2), + round(possible_value - 0.01, 5), + ] + + # cut non transformed part into subclips and check the expected max_volume + # for each one (almost 1) + time_foreach_part = (clip_duration - fadeout_duration) / n_parts + start_times = np.arange(0, clip_duration - fadeout_duration, time_foreach_part) + for i, start_time in enumerate(start_times): + end_time = start_time + time_foreach_part + subclip_max_volume = new_clip.subclip(start_time, end_time).max_volume() + + assert round(subclip_max_volume, 4) == 1 + + if __name__ == "__main__": pytest.main()