2022-10-03 19:58:45 +02:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
|
|
# ffmpeg wrapper
|
2022-10-04 18:16:56 +02:00
|
|
|
import multiprocessing
|
|
|
|
from os.path import isdir, isfile
|
2022-10-03 19:58:45 +02:00
|
|
|
import ffmpy
|
|
|
|
|
|
|
|
# argument parsing
|
|
|
|
import argparse
|
|
|
|
|
2022-10-04 18:16:56 +02:00
|
|
|
# multiprocessing stuff
|
|
|
|
from multiprocessing import Pool
|
|
|
|
from multiprocessing import cpu_count
|
|
|
|
|
2022-10-03 19:58:45 +02:00
|
|
|
# executing some commands
|
|
|
|
import subprocess
|
|
|
|
|
2022-10-03 21:39:38 +02:00
|
|
|
# parsing json output of loudnorm
|
|
|
|
import json
|
|
|
|
|
2022-10-04 18:16:56 +02:00
|
|
|
# file/directory handling
|
|
|
|
import os
|
2022-10-03 19:58:45 +02:00
|
|
|
|
2022-10-04 18:16:56 +02:00
|
|
|
# most recent starttime for program
|
|
|
|
import time
|
2022-10-03 19:58:45 +02:00
|
|
|
|
2022-10-04 18:16:56 +02:00
|
|
|
from random import randint
|
2022-10-03 19:58:45 +02:00
|
|
|
|
2022-11-14 20:15:52 +01:00
|
|
|
from typing import Any, Optional
|
2022-11-13 21:54:24 +01:00
|
|
|
|
2022-10-03 19:58:45 +02:00
|
|
|
"""
|
|
|
|
|
|
|
|
"""
|
|
|
|
|
2022-10-04 18:16:56 +02:00
|
|
|
musicfile_extensions = (".flac", ".wav", ".mp3", ".m4a", ".aac", ".opus")
|
2022-10-03 19:58:45 +02:00
|
|
|
|
|
|
|
|
2024-03-23 18:32:01 +01:00
|
|
|
def loudness_info(inputfile) -> dict[str, str]:
|
2022-10-04 18:16:56 +02:00
|
|
|
"""
|
2024-03-23 18:32:01 +01:00
|
|
|
Measure loudness of the given input file
|
2022-10-04 18:16:56 +02:00
|
|
|
|
|
|
|
Parameters:
|
2024-03-23 18:32:01 +01:00
|
|
|
inputfile
|
2022-10-04 18:16:56 +02:00
|
|
|
|
2024-03-23 18:32:01 +01:00
|
|
|
Output:
|
|
|
|
loudness (dict[str, str]): decoded json dictionary containing all loudness information
|
|
|
|
"""
|
2022-10-04 18:16:56 +02:00
|
|
|
|
2022-10-04 18:55:10 +02:00
|
|
|
print("Measuring loudness of ", os.path.basename(inputfile))
|
2024-03-23 18:32:01 +01:00
|
|
|
|
2022-10-03 19:58:45 +02:00
|
|
|
ff = ffmpy.FFmpeg(
|
|
|
|
inputs={inputfile: None},
|
|
|
|
outputs={"/dev/null": "-pass 1 -filter:a loudnorm=print_format=json -f null"},
|
|
|
|
global_options=("-y"),
|
|
|
|
)
|
|
|
|
|
2022-10-03 21:39:19 +02:00
|
|
|
proc = subprocess.Popen(
|
|
|
|
ff.cmd, shell=True, stderr=subprocess.STDOUT, stdout=subprocess.PIPE
|
|
|
|
)
|
2024-03-23 18:32:01 +01:00
|
|
|
|
2022-10-03 21:39:19 +02:00
|
|
|
# NOTE get loudness info from subprocess
|
|
|
|
# rstrip: remove trailing newline
|
|
|
|
# decode: convert from binary string to utf8
|
|
|
|
# splitlines: list of lines (only 12 last ones, length of the output json)
|
|
|
|
# join: reassembles the list of lines and separates with "\n"
|
|
|
|
loudness_json: str = "\n".join(
|
|
|
|
proc.stdout.read().rstrip().decode("utf8").splitlines()[-12:]
|
|
|
|
)
|
|
|
|
# decode json to dict
|
|
|
|
loudness: dict[str, str] = json.loads(loudness_json)
|
|
|
|
return loudness
|
2022-10-03 19:58:45 +02:00
|
|
|
|
|
|
|
|
2024-03-23 18:32:01 +01:00
|
|
|
def convert(
|
|
|
|
inputfile: str,
|
|
|
|
outputfile: str,
|
|
|
|
codec: str,
|
|
|
|
compression: int,
|
|
|
|
loudness: dict[str, str],
|
|
|
|
bitrate: str = "0k",
|
|
|
|
) -> Optional[list[Any]]:
|
|
|
|
"""
|
|
|
|
Convert the input file to the desired format
|
|
|
|
|
|
|
|
Parameters:
|
|
|
|
inputfile (str)
|
|
|
|
outputfile (str)
|
|
|
|
loudness (dict[str, str])
|
|
|
|
|
|
|
|
Output:
|
|
|
|
dynamically normalised files (list)
|
|
|
|
"""
|
|
|
|
|
2022-10-04 18:55:10 +02:00
|
|
|
print("Working on ", os.path.basename(inputfile))
|
2024-03-23 18:32:01 +01:00
|
|
|
|
2022-10-08 14:15:24 +02:00
|
|
|
# NOTE including covers into ogg/opus containers currently doesn't work
|
|
|
|
# https://trac.ffmpeg.org/ticket/4448
|
|
|
|
inputcmd = {inputfile: None}
|
2024-03-23 18:32:01 +01:00
|
|
|
# NOTE bitrate is set to 0k when converting to flac. This does not have any effect however and is simply ignored
|
2022-10-08 14:15:24 +02:00
|
|
|
outputcmd = {
|
|
|
|
outputfile: "-pass 2"
|
|
|
|
" "
|
|
|
|
"-filter:a"
|
|
|
|
" "
|
2023-12-22 12:40:24 +01:00
|
|
|
"loudnorm=I=-30.0:"
|
2024-01-27 20:46:14 +01:00
|
|
|
"LRA=10.0:"
|
2022-10-08 14:15:24 +02:00
|
|
|
"measured_I={input_i}:"
|
|
|
|
"measured_LRA={input_lra}:"
|
2022-11-13 21:54:24 +01:00
|
|
|
"measured_tp={input_tp}:measured_thresh={input_thresh}:"
|
|
|
|
"print_format=json"
|
2022-10-08 14:15:24 +02:00
|
|
|
" "
|
2024-03-23 18:32:01 +01:00
|
|
|
"-c:a {codec}"
|
2022-10-08 14:15:24 +02:00
|
|
|
" "
|
2024-03-23 18:32:01 +01:00
|
|
|
"-b:a {bitrate}"
|
|
|
|
" "
|
|
|
|
"-compression_level {compression}".format(
|
2022-10-08 14:15:24 +02:00
|
|
|
input_i=loudness["input_i"],
|
|
|
|
input_lra=loudness["input_lra"],
|
|
|
|
input_tp=loudness["input_tp"],
|
|
|
|
input_thresh=loudness["input_thresh"],
|
2024-03-23 18:32:01 +01:00
|
|
|
codec=codec,
|
|
|
|
bitrate=bitrate,
|
|
|
|
compression=compression,
|
2022-10-08 14:15:24 +02:00
|
|
|
)
|
|
|
|
}
|
|
|
|
|
2022-10-03 21:39:53 +02:00
|
|
|
ff = ffmpy.FFmpeg(
|
2022-10-08 14:15:24 +02:00
|
|
|
inputs=inputcmd,
|
|
|
|
outputs=outputcmd,
|
2022-11-13 21:54:24 +01:00
|
|
|
global_options=("-y"),
|
2022-10-03 21:39:53 +02:00
|
|
|
)
|
2024-03-23 18:32:01 +01:00
|
|
|
|
2022-11-13 21:54:24 +01:00
|
|
|
proc = subprocess.Popen(
|
|
|
|
ff.cmd, shell=True, stderr=subprocess.STDOUT, stdout=subprocess.PIPE
|
|
|
|
)
|
2024-03-23 18:32:01 +01:00
|
|
|
|
2022-11-13 21:54:24 +01:00
|
|
|
# NOTE get loudness info from subprocess
|
|
|
|
# rstrip: remove trailing newline
|
|
|
|
# decode: convert from binary string to utf8
|
|
|
|
# splitlines: list of lines (only 12 last ones, length of the output json)
|
|
|
|
# join: reassembles the list of lines and separates with "\n"
|
|
|
|
loudness_json: str = "\n".join(
|
|
|
|
proc.stdout.read().rstrip().decode("utf8").splitlines()[-12:]
|
|
|
|
)
|
2024-03-23 18:32:01 +01:00
|
|
|
|
2022-11-13 21:54:24 +01:00
|
|
|
# decode json to dict
|
|
|
|
loudness_new: dict[str, str] = json.loads(loudness_json)
|
|
|
|
if loudness_new["normalization_type"] != "linear":
|
2022-11-14 20:15:52 +01:00
|
|
|
nonlinear: list[Any] = [inputfile, loudness_new]
|
|
|
|
return nonlinear
|
2022-10-03 19:58:45 +02:00
|
|
|
|
|
|
|
|
2022-11-14 20:15:52 +01:00
|
|
|
def main(inputfile: str) -> Optional[list[Any]]:
|
2022-10-04 18:16:56 +02:00
|
|
|
"""
|
|
|
|
Main program loop
|
|
|
|
|
|
|
|
Parameters:
|
|
|
|
inputfile (str): Path to input file
|
2024-03-23 18:32:01 +01:00
|
|
|
|
|
|
|
Output:
|
|
|
|
dynamically normalised audio files (list)
|
2022-10-04 18:16:56 +02:00
|
|
|
"""
|
2024-03-23 18:32:01 +01:00
|
|
|
|
2022-10-04 18:16:56 +02:00
|
|
|
# set output folder to parent path + "normalized"
|
|
|
|
outputfolder = os.path.join(os.path.dirname(inputfile), "normalized")
|
2024-03-23 18:32:01 +01:00
|
|
|
|
2022-10-04 18:16:56 +02:00
|
|
|
# NOTE create output folder
|
|
|
|
# because multiple parallel processes are at work here,
|
|
|
|
# there might be conflicts with one trying to create the directory although it already exists
|
|
|
|
# this while loop makes sure the directory does exist
|
|
|
|
# the try/except block ensures the error is caught and (hopefully) doesn't happen again just after with random sleep
|
|
|
|
# there's very likely a better way to do this, idk
|
|
|
|
while not os.path.isdir(outputfolder):
|
|
|
|
try:
|
|
|
|
os.mkdir(outputfolder)
|
|
|
|
except:
|
|
|
|
time.sleep(randint(0, 4))
|
|
|
|
|
|
|
|
# output file path
|
2024-03-23 18:32:01 +01:00
|
|
|
infile_noextension: str = os.path.splitext(os.path.basename(inputfile))[0]
|
|
|
|
infile_extension: str = os.path.splitext(os.path.basename(inputfile))[1]
|
|
|
|
|
|
|
|
match infile_extension:
|
|
|
|
case ".flac" | ".wav":
|
|
|
|
outputfile: str = os.path.join(outputfolder, infile_noextension + ".flac")
|
|
|
|
codec: str = "flac"
|
|
|
|
compression: int = 12 # best compression
|
|
|
|
bitrate: str = "0k"
|
|
|
|
case ".mp3" | ".m4a" | ".aac" | ".opus":
|
|
|
|
outputfile: str = os.path.join(outputfolder, infile_noextension + ".opus")
|
|
|
|
codec: str = "libopus"
|
|
|
|
compression: int = 10 # best compression
|
|
|
|
bitrate: str = "192k"
|
|
|
|
case _:
|
|
|
|
print(inputfile, "does not use a known extension. Conversion skipped")
|
|
|
|
return
|
|
|
|
|
|
|
|
loudness: dict[str, str] = loudness_info(inputfile=inputfile)
|
2022-11-14 20:15:52 +01:00
|
|
|
nonlinear: Optional[list[Any]] = convert(
|
2022-11-13 21:54:24 +01:00
|
|
|
inputfile=inputfile,
|
|
|
|
outputfile=outputfile,
|
2024-03-23 18:32:01 +01:00
|
|
|
codec=codec,
|
|
|
|
compression=compression,
|
2022-11-13 21:54:24 +01:00
|
|
|
loudness=loudness,
|
2024-03-23 18:32:01 +01:00
|
|
|
bitrate=bitrate,
|
2022-11-13 21:54:24 +01:00
|
|
|
)
|
|
|
|
|
2022-11-14 20:15:52 +01:00
|
|
|
return nonlinear
|
2022-10-04 18:16:56 +02:00
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
2024-03-23 18:32:01 +01:00
|
|
|
"""
|
|
|
|
Handle arguments and other details for interactive usage
|
|
|
|
"""
|
2022-10-08 14:15:24 +02:00
|
|
|
# start time of program
|
|
|
|
starttime = time.time()
|
|
|
|
|
2022-10-04 18:16:56 +02:00
|
|
|
parser = argparse.ArgumentParser(description="")
|
|
|
|
|
|
|
|
# Input directory
|
|
|
|
parser.add_argument(
|
|
|
|
"-i", "--input-dir", required=True, type=str, help="Input source directory"
|
|
|
|
)
|
|
|
|
|
|
|
|
# number of cpus/threads to use, defaults to all available
|
|
|
|
parser.add_argument(
|
|
|
|
"-c",
|
|
|
|
"--cpu-count",
|
|
|
|
required=False,
|
|
|
|
type=int,
|
|
|
|
help="Number of cpu cores",
|
|
|
|
default=multiprocessing.cpu_count(),
|
|
|
|
)
|
|
|
|
|
2022-10-04 18:40:40 +02:00
|
|
|
# in case you wanted to rerun the conversion for everything
|
|
|
|
parser.add_argument(
|
|
|
|
"-r",
|
|
|
|
"--reset",
|
|
|
|
required=False,
|
|
|
|
action="store_true",
|
|
|
|
help="Rerun conversion for all files",
|
|
|
|
)
|
|
|
|
|
2022-10-04 18:16:56 +02:00
|
|
|
args = parser.parse_args()
|
|
|
|
|
|
|
|
srcfolder = args.input_dir
|
|
|
|
|
|
|
|
cpu = args.cpu_count
|
|
|
|
|
2022-10-04 18:40:40 +02:00
|
|
|
reset = args.reset
|
|
|
|
|
|
|
|
# file where last run timestamp is stored
|
2022-10-04 18:16:56 +02:00
|
|
|
timefile = os.path.join(srcfolder, "run.time")
|
|
|
|
|
2022-11-13 21:54:24 +01:00
|
|
|
# list of non-linear normalizations
|
2022-11-14 20:15:52 +01:00
|
|
|
nonlinear_all: Optional[list[Any]] = []
|
2022-11-13 21:54:24 +01:00
|
|
|
|
2022-10-04 18:16:56 +02:00
|
|
|
# get time of previous run
|
2022-10-04 18:40:40 +02:00
|
|
|
if reset:
|
|
|
|
timeprev = 0
|
|
|
|
elif os.path.isfile(timefile):
|
2022-10-04 18:16:56 +02:00
|
|
|
with open(timefile, "r") as file:
|
|
|
|
timeprev = file.read()
|
|
|
|
else:
|
|
|
|
timeprev = 0
|
|
|
|
|
2022-11-14 20:15:52 +01:00
|
|
|
musicfiles: list[str] = []
|
2022-10-04 18:16:56 +02:00
|
|
|
for root, dirs, files in os.walk(srcfolder):
|
|
|
|
# ignore the "normalized" subfolder
|
|
|
|
dirs[:] = [d for d in dirs if d not in ["normalized"]]
|
|
|
|
for file in files:
|
|
|
|
if file.endswith(musicfile_extensions):
|
|
|
|
filepath = os.path.join(root, file)
|
|
|
|
# only file newer than the last run are added
|
|
|
|
if os.path.getmtime(filepath) >= float(timeprev):
|
|
|
|
musicfiles.append(os.path.join(root, file))
|
|
|
|
|
|
|
|
with Pool(cpu) as p:
|
2022-11-14 20:15:52 +01:00
|
|
|
nonlinear_all: Optional[list[Any]] = p.map(main, musicfiles)
|
2022-10-04 18:16:56 +02:00
|
|
|
|
|
|
|
# write this run's time into file
|
|
|
|
with open(timefile, "w") as file:
|
2022-10-08 14:15:24 +02:00
|
|
|
file.write(str(starttime))
|
2022-11-13 21:54:24 +01:00
|
|
|
|
|
|
|
print("Dynamically normalized music:")
|
2022-11-14 20:15:52 +01:00
|
|
|
for i in nonlinear_all:
|
|
|
|
# NOTE ignore empty and "None" values
|
|
|
|
if i:
|
|
|
|
print(i)
|