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: # sidechaincompress lowers background music while voiceover is active. 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)