Skip to content

Instantly share code, notes, and snippets.

@obskyr
Last active September 28, 2024 12:18
Show Gist options
  • Save obskyr/c85459f64fe48fdeb1a674e89d43cb82 to your computer and use it in GitHub Desktop.
Save obskyr/c85459f64fe48fdeb1a674e89d43cb82 to your computer and use it in GitHub Desktop.
Loop a video game music file and fade it out, so that it may be listened to properly in music apps.

Loop and fade video game music

You know when you have a video game music file that's only a single loop long? And that means you can't put it into your playlists? Ugh. Sucks, doesn't it. Thankfully, I've finally written this script to handle that.

Requirements

Ensure that FFmpeg is in your PATH environment variable as well.

Usage

Simply run it with the files you wish to loop and fade as arguments:

loop_and_fade.py file1.opus file2.m4a file3.mp3

You can also drop files onto the script in your file manager.

The script contains a set of options – for example, which directory to output the new files to, which format to encode the files to, how long the fadeout should be, whether to skip looping files that end with silence (in case files that don't loop smoothly are in the mix), and whether to also print a PowerShell command that you can modify and run again should you want to.

#!/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:]))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment