| from __future__ import annotations |
|
|
| from pathlib import Path |
|
|
| from renderer.ffmpeg.command import FFmpegCommand |
| from renderer.ffmpeg.runner import FFmpegRunner |
|
|
|
|
| class AudioMixer: |
| def __init__(self, runner: FFmpegRunner) -> None: |
| self.runner = runner |
|
|
| def ducking_filter(self) -> str: |
| |
| return ( |
| "[1:a]volume=0.316[music_quiet];" |
| "[music_quiet][2:a]sidechaincompress=threshold=0.02:ratio=8:attack=30:release=600[ducked];" |
| "[2:a]volume=1.0[voice];" |
| "[ducked][voice]amix=inputs=2:duration=first:dropout_transition=2[aout]" |
| ) |
|
|
| def mix( |
| self, |
| video: Path, |
| music: str | None, |
| voiceover: str | None, |
| output: Path, |
| normalize: bool = False, |
| *, |
| duration: float | None = None, |
| music_volume: float = 0.316, |
| music_fade_in: float = 0.0, |
| music_fade_out: float = 0.0, |
| music_loop: bool = True, |
| music_start: float = 0.0, |
| music_ducking: bool = True, |
| voice_volume: float = 1.0, |
| ) -> Path: |
| audio_tail = ",loudnorm=I=-16:TP=-1.5:LRA=11" if normalize else "" |
| if not music and not voiceover: |
| command = FFmpegCommand().add("-hide_banner").input(video).add("-c", "copy").overwrite().add(output).build() |
| self.runner.run(command) |
| return output |
| if voiceover and music: |
| music_filter = _music_filter( |
| music_volume, |
| fade_in=music_fade_in, |
| fade_out=music_fade_out, |
| duration=duration, |
| ) |
| if music_ducking: |
| ducking_filter = ( |
| f"[1:a]{music_filter}[music];" |
| "[music][2:a]sidechaincompress=threshold=0.02:ratio=8:attack=30:release=600[ducked];" |
| f"[2:a]volume={voice_volume}[voice];" |
| "[ducked][voice]amix=inputs=2:duration=first:dropout_transition=2[aout]" |
| ) |
| else: |
| ducking_filter = ( |
| f"[1:a]{music_filter}[music];" |
| f"[2:a]volume={voice_volume}[voice];" |
| "[music][voice]amix=inputs=2:duration=first:dropout_transition=2[aout]" |
| ) |
| if normalize: |
| ducking_filter += ";[aout]loudnorm=I=-16:TP=-1.5:LRA=11[anorm]" |
| audio_map = "[anorm]" |
| else: |
| audio_map = "[aout]" |
| command = ( |
| FFmpegCommand() |
| .add("-hide_banner") |
| .input(video) |
| .input(music, **_music_input_options(music_loop, music_start)) |
| .input(voiceover) |
| .add("-filter_complex", ducking_filter) |
| .add("-map", "0:v", "-map", audio_map, "-c:v", "copy", "-c:a", "aac", "-shortest") |
| .overwrite() |
| .add(output) |
| .build() |
| ) |
| else: |
| audio = voiceover or music |
| input_options = {} if voiceover else _music_input_options(music_loop, music_start) |
| volume = voice_volume if voiceover else music_volume |
| audio_filter = f"volume={volume}" |
| if music and not voiceover: |
| audio_filter = _music_filter(music_volume, fade_in=music_fade_in, fade_out=music_fade_out, duration=duration) |
| command = ( |
| FFmpegCommand() |
| .add("-hide_banner") |
| .input(video) |
| .input(audio, **input_options) |
| .add("-filter_complex", f"[1:a]{audio_filter}{audio_tail}[aout]") |
| .add("-map", "0:v", "-map", "[aout]", "-c:v", "copy", "-c:a", "aac", "-shortest") |
| .overwrite() |
| .add(output) |
| .build() |
| ) |
| self.runner.run(command) |
| return output |
|
|
|
|
| def _music_input_options(loop: bool, start: float) -> dict[str, object]: |
| options: dict[str, object] = {} |
| if loop: |
| options["stream_loop"] = -1 |
| if start > 0: |
| options["ss"] = start |
| return options |
|
|
|
|
| def _music_filter(volume: float, *, fade_in: float, fade_out: float, duration: float | None) -> str: |
| filters = [f"volume={volume}"] |
| if fade_in > 0: |
| filters.append(f"afade=t=in:st=0:d={fade_in}") |
| if fade_out > 0 and duration and duration > fade_out: |
| filters.append(f"afade=t=out:st={max(0.0, duration - fade_out):.3f}:d={fade_out}") |
| return ",".join(filters) |
|
|