|
#!/usr/bin/env python3 |
|
|
|
"""Loop an audio file to a length that's pleasant to listen to and, fade it out |
|
smoothly, and add trailing silence. Particularly useful to turn looping video |
|
game music into versions that might be more friendly to music-listening apps. |
|
""" |
|
|
|
import math |
|
import os |
|
import re |
|
import shutil |
|
import subprocess |
|
import sys |
|
import tempfile |
|
import sty |
|
|
|
# === Options === |
|
|
|
# Relative to where the original file is located. |
|
OUTPUT_DIR = 'Looped' |
|
|
|
FADEOUT_LENGTH = 10 |
|
TRAILING_SILENCE_LENGTH = 2 |
|
REENCODE_TO = 'm4a' # Set to None if you don't wish to reencode. |
|
IGNORE_FILES_THAT_END_WITH_SILENCE = True # They will still be copied, however. |
|
PRINT_POWERSHELL_SCRIPT = True |
|
|
|
# Only enable this if your FFMpeg version was compiled with support for non- |
|
# libre codecs, such as libfdk_aac, which raises the quality of AAC-encoded |
|
# files (e.g. M4A files). Such a build for Windows can be found here: |
|
# https://github.com/AnimMouse/ffmpeg-stable-autobuild/releases/latest |
|
USE_NONFREE_CODECS = True |
|
|
|
# Personally, I want the file only to loop twice if it's a short loop, |
|
# but otherwise, be at least 2 minutes and 30 seconds long. |
|
# You may change this function to get looped versions that suit your tastes. |
|
def new_duration_from_duration(duration): |
|
if duration < 42: |
|
return duration * 2 |
|
else: |
|
target = 2 * 60 + 30 |
|
return math.ceil(target / duration) * duration |
|
|
|
# =============== |
|
|
|
POWERSHELL_SCRIPT = """[IO.File]::WriteAllLines("loop.txt", "file '{in_path}'`n" * {num_loops}) |
|
$fade = {FADEOUT_LENGTH} |
|
$silence = {TRAILING_SILENCE_LENGTH} |
|
$base_duration = {duration:.02f} * {num_base_loops} |
|
$duration = $base_duration + $fade / 2 + $silence |
|
{ffmpeg_command} |
|
del loop.txt""" |
|
|
|
DURATION_RE = re.compile(r"^\s*Duration: (?P<hours>[0-9]{2,}):(?P<minutes>[0-9]{2}):(?P<seconds>[0-9]{2}).(?P<centiseconds>[0-9]{2})", re.MULTILINE) |
|
|
|
def get_duration(path): |
|
output = subprocess.check_output(['ffprobe', path], stderr=subprocess.STDOUT).decode('utf-8') |
|
return find_duration(output) |
|
|
|
|
|
def find_duration(s): |
|
m = DURATION_RE.search(s) |
|
return sum(int(s) * factor for s, factor in zip(m.groups(), (3600, 60, 1, 0.01))) |
|
|
|
|
|
def duration_and_new_duration_of(path): |
|
duration = get_duration(path) |
|
return (duration, new_duration_from_duration(duration)) |
|
|
|
|
|
def loop_and_fade(in_path, out_path, verbose=False): |
|
codec_args = get_codec_args(out_path) |
|
# Normally when adding a cover image to an M4A file, it's advisable to |
|
# specify `-disposition:v attached_pic`, but since we're copying the cover |
|
# image over from a file where it already has this disposition, |
|
# it's unneeded. |
|
codec_args += ('-map', '1:v?', '-c:v', 'copy') if os.path.splitext(out_path)[1].lower() == '.m4a' else () |
|
|
|
duration, new_duration = duration_and_new_duration_of(in_path) |
|
new_duration_without_envoi = new_duration |
|
fade_start = new_duration - FADEOUT_LENGTH / 2 |
|
new_duration += FADEOUT_LENGTH / 2 + TRAILING_SILENCE_LENGTH |
|
assert FADEOUT_LENGTH / 2 <= duration # Otherwise, the fadeout will start before the audio's start. |
|
if FADEOUT_LENGTH: |
|
fade_args = ('-filter:a', f'afade=t=out:st={fade_start}:d={FADEOUT_LENGTH}') |
|
else: |
|
fade_args = () |
|
|
|
num_loops = math.ceil(new_duration / duration) |
|
concat_s = f'file \'{os.path.abspath(in_path).replace('\'', '\'\\\'\'')}\'\n' * num_loops |
|
|
|
if verbose: |
|
print(sty.fg.yellow + f"[{format_duration(duration)} → {format_duration(new_duration)}] " + sty.fg.li_blue + out_path + sty.fg.rs) |
|
|
|
if verbose and PRINT_POWERSHELL_SCRIPT: |
|
ffmpeg_command = [ |
|
'ffmpeg', |
|
'-t', '$duration', |
|
'-f', 'concat', |
|
'-safe', '0', |
|
'-i', 'loop.txt', |
|
'-i', os.path.relpath(in_path), |
|
'-map', '0:a', |
|
'-map_metadata', '1', |
|
'-t', '$duration', |
|
'-filter:a', f'afade=t=out:st=$($base_duration - $fade / 2):d=${{fade}}', |
|
*codec_args, |
|
'-y', |
|
os.path.relpath(out_path) |
|
] |
|
ffmpeg_command = join_command(ffmpeg_command) |
|
num_base_loops = new_duration_without_envoi / duration |
|
assert abs(num_base_loops - round(num_base_loops)) < 0.0001 |
|
num_base_loops = round(num_base_loops) |
|
print(POWERSHELL_SCRIPT.format( |
|
in_path=os.path.relpath(in_path).replace('\'', '\'\\\'\''), num_loops=num_loops, |
|
FADEOUT_LENGTH=FADEOUT_LENGTH, TRAILING_SILENCE_LENGTH=TRAILING_SILENCE_LENGTH, |
|
duration=duration, num_base_loops=num_base_loops, ffmpeg_command=ffmpeg_command |
|
)) |
|
print() |
|
|
|
with tempfile.NamedTemporaryFile('w+', encoding='utf-8', delete=True, delete_on_close=False) as f: |
|
f.write(concat_s) |
|
f.seek(0) |
|
subprocess.check_output( |
|
[ |
|
'ffmpeg', |
|
'-t', str(new_duration), |
|
'-f', 'concat', |
|
'-safe', '0', |
|
'-i', f.name, |
|
'-i', in_path, |
|
'-map', '0:a', |
|
'-map_metadata', '1', |
|
'-t', str(new_duration), |
|
*fade_args, |
|
*codec_args, |
|
'-y', |
|
out_path |
|
], |
|
stderr=subprocess.PIPE, |
|
input=concat_s, |
|
encoding='utf-8' # Not sure whether this is really what FFMpeg wants. |
|
) |
|
|
|
|
|
def reencode_as_is(path, out_path, verbose=False): |
|
if os.path.splitext(path)[1].lower() == os.path.splitext(out_path)[1].lower(): |
|
codec_args = ('-c:a', 'copy') |
|
else: |
|
codec_args = get_codec_args(out_path) |
|
codec_args += ('-c:v', 'copy') if os.path.splitext(out_path)[1].lower() == '.m4a' else () |
|
ffmpeg_command = [ |
|
'ffmpeg', |
|
'-i', os.path.relpath(path), |
|
'-map_metadata', '0', |
|
*codec_args, |
|
'-y', |
|
os.path.relpath(out_path) |
|
] |
|
if verbose and PRINT_POWERSHELL_SCRIPT: |
|
print(join_command(ffmpeg_command)) |
|
print() |
|
subprocess.check_output(ffmpeg_command, stderr=subprocess.DEVNULL) |
|
|
|
|
|
def get_codec_args(path): |
|
extension = os.path.splitext(path)[1].lower() |
|
codec = { |
|
'm4a': 'libfdk_aac', |
|
'aac': 'libfdk_aac' |
|
}.get(extension[1:], None) |
|
if not USE_NONFREE_CODECS and codec == 'libfdk_aac': |
|
codec = None |
|
if codec: |
|
return ('-c:a', codec) |
|
else: |
|
return () |
|
|
|
|
|
def join_command(bits): |
|
return " ".join((f"\"{bit}\"" if " " in bit or "'" in bit else bit) for bit in bits) |
|
|
|
|
|
def format_duration(seconds): |
|
hours, seconds = divmod(seconds, 3600) |
|
minutes, seconds = divmod(seconds, 60) |
|
seconds, fraction = divmod(seconds, 1) |
|
|
|
if hours: |
|
s = f"{int(hours)}:{int(minutes):02}:{int(seconds):02}" |
|
else: |
|
s = f"{int(minutes):02}:{int(seconds):02}" |
|
if fraction: |
|
s += f".{round(fraction * 100):02}" |
|
return s |
|
|
|
|
|
MAX_VOLUME_RE = re.compile(r"^\[Parsed_volumedetect_0 @ [0-9a-fA-F]+\] max_volume: (?P<decibels>-?[0-9]+(?:\.[0-9]+)?) dB", re.MULTILINE) |
|
|
|
def ends_with_silence(path): |
|
output = subprocess.check_output([ |
|
'ffmpeg', |
|
'-sseof', '-0.25', |
|
'-i', path, |
|
'-filter:a', 'volumedetect', |
|
'-f', 'null', |
|
'-' |
|
], |
|
stderr=subprocess.STDOUT |
|
).decode('utf-8') |
|
m = MAX_VOLUME_RE.search(output) |
|
max_volume = float(m.group('decibels')) |
|
return max_volume <= -55 |
|
|
|
|
|
def main(*argv): |
|
for path in argv: |
|
out_dir = os.path.join(os.path.dirname(path), OUTPUT_DIR) |
|
os.makedirs(out_dir, exist_ok=True) |
|
out_path = os.path.join(out_dir, os.path.basename(path)) |
|
if REENCODE_TO: |
|
out_path = os.path.splitext(out_path)[0] + '.' + REENCODE_TO.lower() |
|
|
|
try: |
|
if not IGNORE_FILES_THAT_END_WITH_SILENCE or not ends_with_silence(path): |
|
loop_and_fade(path, out_path, verbose=True) |
|
else: |
|
_, extension = os.path.splitext(path) |
|
if REENCODE_TO and extension.lower()[1:] != REENCODE_TO.lower(): |
|
print(sty.fg.red + "[Ends with silence; reencoding as-is] " + sty.fg.li_blue + out_path + sty.fg.rs) |
|
reencode_as_is(path, out_path, verbose=True) |
|
else: |
|
print(sty.fg.red + "[Ends with silence; copying as-is] " + sty.fg.li_blue + out_path + sty.fg.rs) |
|
if PRINT_POWERSHELL_SCRIPT: |
|
print() |
|
shutil.copyfile(path, out_path) |
|
except subprocess.SubprocessError as e: |
|
if e.stderr: |
|
print("Error: An FFMPEG error was encountered:", file=sys.stderr) |
|
print(e.stderr, file=sys.stderr) |
|
raise |
|
|
|
return 0 |
|
|
|
if __name__ == '__main__': |
|
sys.exit(main(*sys.argv[1:])) |