Skip to content

Instantly share code, notes, and snippets.

@tedkulp
Created June 13, 2023 18:50
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save tedkulp/810d48cc37e20641686f7db90e9c7131 to your computer and use it in GitHub Desktop.
Save tedkulp/810d48cc37e20641686f7db90e9c7131 to your computer and use it in GitHub Desktop.
WIP 2 pass tdarr audio normalisation plugin. See: https://github.com/HaveAGitGat/Tdarr_Plugins/issues/305
/* eslint-disable no-unused-vars, no-await-in-loop */
module.exports.dependencies = ["axios@0.27.2"];
// PLugin runs multipass loudnorm filter
// first run gets the required details and stores for the next pass
// second pass applies the values
// stages
// Determined Loudnorm Values
// Applying Normalisation
// Normalisation Complete
// tdarrSkipTest
const details = () => ({
id: "Tdarr_Plugin_NIfPZuCLU_2_Pass_Loudnorm_Audio_Normalisation-modified",
Stage: "Pre-processing",
Name: "2 Pass Loudnorm Volume Normalisation (Modified)",
Type: "Video",
Operation: "Transcode",
Description: `PLEASE READ FULL DESCRIPTION BEFORE USE
Uses multiple passes to normalise audio streams of videos using loudnorm.
The first pass will create an log file in the same directory as the video.
Second pass will apply the values determined in the first pass to the file.
Output will be MKV to allow metadata to be added for tracking normalisation stage.`,
Version: "0.1",
Tags: "pre-processing,ffmpeg,configurable",
Inputs: [
// (Optional) Inputs you'd like the user to enter to allow your plugin to be easily configurable from the UI
{
name: "i",
type: "string",
defaultValue: "-23.0",
inputUI: {
type: "text",
},
tooltip: `"i" value used in loudnorm pass \\n
defaults to -23.0`,
},
{
name: "lra",
type: "string",
defaultValue: "7.0",
inputUI: {
type: "text",
},
tooltip: `Desired lra value. \\n Defaults to 7.0
`,
},
{
name: "tp",
type: "string",
defaultValue: "-2.0",
inputUI: {
type: "text",
},
tooltip: `Desired "tp" value. \\n Defaults to -2.0
`,
},
{
name: "serverIp",
type: "string",
defaultValue: "",
inputUI: {
type: "text",
},
tooltip:
"Enter the IP address of the server if plugin having trouble connecting.",
},
{
name: "serverPort",
type: "string",
defaultValue: "",
inputUI: {
type: "text",
},
tooltip:
"Enter the port number of the server if plugin having trouble connecting.",
},
{
name: "enableDebug",
type: "string",
defaultValue: "false",
inputUI: {
type: "text",
},
tooltip: "Display debug info in node console log",
},
],
});
const getloudNormValues = async (inputs, response, file) => {
// eslint-disable-next-line import/no-unresolved
const axios = require("axios");
const util = require("util");
const debugLog = (obj, label = null, alwaysShow = false) => {
if (inputs?.enableDebug !== "true" && !alwaysShow) {
return;
}
if (label) {
response.infoLog += label + ": " + util.inspect(obj, { maxArrayLength: null, depth: null }) + "\n"
console.log(
label,
util.inspect(obj, { maxArrayLength: null, depth: null })
);
} else {
response.infoLog += util.inspect(obj, { maxArrayLength: null, depth: null }) + "\n"
console.log(util.inspect(obj, { maxArrayLength: null, depth: null }));
}
};
const parseJobName = (text) => {
const parts0 = text.split(".txt");
const parts1 = parts0[0].split("()");
return {
footprintId: parts1[0],
jobId: parts1[3],
start: Number(parts1[4]),
};
};
const getReportList = async (footprintId) => {
const logFilesReq = await axios
.post(`${serverUrl}/api/v2/list-footprintId-reports`, {
data: {
footprintId,
},
headers: {
'Cache-Control': 'no-cache',
'Pragma': 'no-cache',
'Expires': '0',
},
})
.catch((err) => {
console.error("err list-footprintId-reports");
console.error(err);
debugLog(err, "Error calling list-footprintId-reports");
});
return logFilesReq;
};
const getJobFile = async (filename) => {
const parts = parseJobName(filename);
const reportReq = await axios
.post(`${serverUrl}/api/v2/read-job-file`, {
data: {
footprintId: parts.footprintId,
jobId: parts.jobId,
jobFileId: filename,
},
headers: {
'Cache-Control': 'no-cache',
'Pragma': 'no-cache',
'Expires': '0',
},
})
.catch((err) => {
console.error("err read-job-file");
console.error(err);
debugLog(err, "Error calling read-job-file");
});
return reportReq;
};
const serverIp = inputs.serverIp ? inputs.serverIp : process.env.serverIp;
const serverPort = inputs.serverPort
? inputs.serverPort
: process.env.serverPort;
const serverUrl = `http://${serverIp}:${serverPort}`;
let loudNormValues = false;
let tries = 0;
let error = false;
the_loop: while (tries < 25) {
try {
tries += 1;
// wait for job report to be updated by server,
await new Promise((resolve) => setTimeout(resolve, 15000));
const logFilesReq = await getReportList(file.footprintId);
if (!logFilesReq || logFilesReq.status !== 200) {
debugLog(`Failed to get log files for footprintId '${footprintId}', trying again...`, "error", true);
continue the_loop;
}
let logFiles = logFilesReq.data;
logFiles = logFiles
.filter((filename) => {
return filename.includes("transcode()");
})
.sort((a, b) => {
const joba = parseJobName(a);
const jobb = parseJobName(b);
return joba.start - jobb.start;
});
let report = "";
// debugLog(logFiles, `logFiles, try #${tries}`);
for (const idx in logFiles) {
const filename = logFiles[idx];
// debugLog(filename, `filename, try #${tries}`);
const reportReq = await getJobFile(filename);
if (!reportReq || reportReq.status !== 200) {
debugLog(`Failed to get read log file "${filename}" on try #${tries}, trying again...`, "error", true);
continue the_loop;
}
report += reportReq.data.text + "\n";
}
const lines = report.split("\n");
let idx = -1;
// get last index of Parsed_loudnorm
lines.forEach((line, i) => {
if (line.includes("Parsed_loudnorm") && !line.includes("Error")) {
let startPos = -1;
let endPos = -1;
const curPos = idx = i;
//debugLog(`Loop from ${curPos + 1} to ${curPos + 15}`);
for (let j = curPos + 1; j < curPos + 15; j += 1) {
const newLine = lines[j].split(' ').slice(1).join(' ').replace(/^\t+/gm, '');
//debugLog(`Line ${j}: ${newLine}`);
if (newLine.startsWith("{")) {
startPos = j;
}
if (newLine.startsWith("}")) {
endPos = j;
}
}
//debugLog(`startPos ${startPos}, endPos ${endPos}`);
if (startPos > -1 && endPos > -1) {
const normParams = lines.slice(startPos, endPos + 1).map((line) => {
return line.split(' ').slice(1).join(' ').replace(/^\t+/gm, '');
}).join(' ');
//debugLog(normParams, `normParams text`);
try {
const tmpJson = JSON.parse(normParams);
debugLog(tmpJson, `tmpJson`);
loudNormValues = tmpJson;
return;
} catch (jsonErr) {
// console.error(
// `Failed to parse loudnorm values json on try #${tries}, trying again...`
// );
debugLog(`Failed to parse loudnorm values json on try #${tries}, trying again...`, "error", true);
debugLog(lines[lines.length-1], "last line in log", true);
error = jsonErr; // Just so we have something to throw if it continues to fail
}
}
}
});
if (loudNormValues) {
break;
} else {
debugLog(`Failed to find valid loudnorm values in combined report on try #${tries}, trying again...`, "error", true);
debugLog(lines[lines.length-1], "last line in log", true);
continue the_loop;
}
} catch (err) {
console.error(err);
response.infoLog += err + "\n";
error = err;
}
}
if (loudNormValues === false) {
if (error) {
throw new Error(error);
} else {
throw new Error("Failed to get loudNormValues");
}
}
return loudNormValues;
};
// eslint-disable-next-line no-unused-vars
const plugin = async (file, librarySettings, inputs, otherArguments) => {
const lib = require("../methods/lib")();
const fs = require("fs");
// eslint-disable-next-line no-unused-vars,no-param-reassign
inputs = lib.loadDefaultValues(inputs, details);
// Must return this object at some point
const response = {
processFile: false,
preset: "",
container: ".mkv",
handBrakeMode: false,
FFmpegMode: false,
infoLog: "",
};
response.infoLog += "";
const probeData = file.ffProbeData;
// setup required varibles
let loudNorm_i = -23.0;
let lra = 7.0;
let tp = -2.0;
// create local varibles for inputs
if (inputs !== undefined) {
if (inputs.i !== undefined) loudNorm_i = inputs.i;
if (inputs.lra !== undefined) lra = inputs.lra;
if (inputs.tp !== undefined) tp = inputs.tp;
}
// check for previous pass tags
if (!probeData?.format?.tags?.NORMALISATIONSTAGE) {
console.log("------------------");
console.log("NO PASS TAGS! (#1)");
console.log("------------------");
// no metadata found first pass is required
response.infoLog += "Searching for required normalisation values. \n";
response.infoLog += "Normalisation first pass processing \n";
// Do the first pass, output the log to the out file and use a secondary output for an unchanged file to
// allow Tdarr to track, Set metadata stage
response.preset =
`<io>-af loudnorm=I=${loudNorm_i}:LRA=${lra}:TP=${tp}:print_format=json` +
" -f null NUL -map 0 -c copy -metadata NORMALISATIONSTAGE=FirstPassComplete";
response.FFmpegMode = true;
response.processFile = true;
return response;
}
if (probeData.format.tags.NORMALISATIONSTAGE === "FirstPassComplete") {
console.log("-------------------------");
console.log("First Pass Complete! (#2)");
console.log("-------------------------");
const loudNormValues = await getloudNormValues(inputs, response, file);
response.infoLog += `Loudnorm first pass values returned: \n${JSON.stringify(
loudNormValues
)}`;
// use parsed values in second pass
response.preset =
`-y<io>-af loudnorm=print_format=summary:linear=true:I=${loudNorm_i}:LRA=${lra}:TP=${tp}:` +
`measured_i=${loudNormValues.input_i}:` +
`measured_lra=${loudNormValues.input_lra}:` +
`measured_tp=${loudNormValues.input_tp}:` +
`measured_thresh=${loudNormValues.input_thresh}:offset=${loudNormValues.target_offset} ` +
"-c:a aac -b:a 192k -c:s copy -c:v copy -metadata NORMALISATIONSTAGE=Complete";
response.FFmpegMode = true;
response.processFile = true;
response.infoLog += "Normalisation pass processing \n";
return response;
}
if (probeData.format.tags.NORMALISATIONSTAGE === "Complete") {
console.log("--------------------------");
console.log("Second Pass Complete! (#3)");
console.log("--------------------------");
response.infoLog += "File is already marked as normalised \n";
return response;
}
console.log("--------------------------");
console.log("IT ALL FAILED! (#4)");
console.log("--------------------------");
// what is this tag?
debugLog(`Unknown normalisation stage tag: \n${probeData.format.tags.NORMALISATIONSTAGE}`, "error", true);
throw new Error(
`Unknown normalisation stage tag: \n${probeData.format.tags.NORMALISATIONSTAGE}`
);
};
module.exports.details = details;
module.exports.plugin = plugin;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment