602 lines
17 KiB
JavaScript
Executable File
602 lines
17 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
'use strict';
|
|
const execRaw = require('child_process').exec;
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const program = require('commander');
|
|
const fs = require('fs-extra');
|
|
const packageJson = require('./package.json');
|
|
let TMPDIR = os.tmpdir() || '/tmp';
|
|
let TMPPATH;
|
|
let EXE = `sox`;
|
|
let IDENTIFY = `soxi`;
|
|
let SLICE = (1000 / 24) + '';
|
|
let RATE = 48000; //standardize all to rate
|
|
/**
|
|
* Shells out to execute a command with async/await.
|
|
* Async wrapper to exec module.
|
|
*
|
|
* @param {string} cmd Command to execute
|
|
*
|
|
* @returns {Promise} Promise containing the complete stdio
|
|
**/
|
|
async function exec(cmd) {
|
|
return new Promise((resolve, reject) => {
|
|
return execRaw(cmd, (err, stdio, stderr) => {
|
|
if (err)
|
|
return reject(err);
|
|
return resolve(stdio);
|
|
});
|
|
});
|
|
}
|
|
/**
|
|
* Delays process for specified amount of time in milliseconds.
|
|
*
|
|
* @param {integer} ms Milliseconds to delay for
|
|
*
|
|
* @returns {Promise} Promise that resolves after set time
|
|
**/
|
|
async function delay(ms) {
|
|
return new Promise((resolve, reject) => {
|
|
return setTimeout(resolve, ms);
|
|
});
|
|
}
|
|
/**
|
|
* Pads a numerical value with preceding zeros to make strings same length.
|
|
*
|
|
* @param {integer} i Number to pad
|
|
* @param {integer} max (optional) Maximum length of string to pad to
|
|
*
|
|
* @returns {string} Padded number as a string
|
|
**/
|
|
function zeroPad(i, max = 5) {
|
|
const len = (i + '').length;
|
|
let str = i + '';
|
|
for (let x = 0; x < max - len; x++) {
|
|
str = '0' + str;
|
|
}
|
|
return str;
|
|
}
|
|
/**
|
|
* Shuffles an array into a random state.
|
|
*
|
|
* @param {array} a Array to randomize
|
|
**/
|
|
function shuffle(array) {
|
|
let j;
|
|
let temp;
|
|
for (let i = array.length - 1; i > 0; i--) {
|
|
j = Math.floor(Math.random() * (i + 1));
|
|
temp = array[i];
|
|
array[i] = array[j];
|
|
array[j] = temp;
|
|
}
|
|
}
|
|
/**
|
|
* Clears the temporary directory of all files.
|
|
* Establishes a directory if none exists.
|
|
**/
|
|
async function clear() {
|
|
let cmd = `rm -r "${TMPPATH}"`;
|
|
let exists;
|
|
try {
|
|
exists = await fs.exists(TMPPATH);
|
|
}
|
|
catch (err) {
|
|
console.error(err);
|
|
}
|
|
if (exists) {
|
|
console.log(`Clearing temp directory "${TMPPATH}"`);
|
|
try {
|
|
await exec(cmd);
|
|
}
|
|
catch (err) {
|
|
//suppress error
|
|
console.dir(err);
|
|
}
|
|
}
|
|
try {
|
|
await fs.mkdir(TMPPATH);
|
|
}
|
|
catch (err) {
|
|
if (err.code !== 'EEXIST') {
|
|
console.error(err);
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
/**
|
|
* Parses timecode string to float of total seconds
|
|
*
|
|
* @param {string} str Timecode string to parse
|
|
*
|
|
* @returns {float} Seconds at float
|
|
**/
|
|
function parseTC(str) {
|
|
const parts = str.split(':');
|
|
let sec = 0;
|
|
if (parts[0] != 0)
|
|
sec += parseFloat(parts[0]) * 60 * 60;
|
|
if (parts[1] != 0)
|
|
sec += parseFloat(parts[1]) * 60;
|
|
if (parts[2] != 0)
|
|
sec += parseFloat(parts[2]);
|
|
return sec;
|
|
}
|
|
/**
|
|
* Returns offset position of audio file to slice at.
|
|
*
|
|
* @param {integer} i Count of slice to make
|
|
* @param {float} slice Length of slice, might be str
|
|
*
|
|
* @returns {str} New position cast as string
|
|
**/
|
|
async function audioLength(filePath) {
|
|
const exe = IDENTIFY;
|
|
const cmd = `${exe} -d "${filePath}"`;
|
|
let str;
|
|
try {
|
|
str = await exec(cmd);
|
|
}
|
|
catch (err) {
|
|
console.error(err);
|
|
process.exit(11);
|
|
}
|
|
return parseTC(str);
|
|
}
|
|
/**
|
|
* Returns offset position of audio file to slice at.
|
|
*
|
|
* @param {integer} i Count of slice to make
|
|
* @param {float} slice Length of slice, might be str
|
|
*
|
|
* @returns {str} New position cast as string
|
|
**/
|
|
function offset(i, slice) {
|
|
return (i * (parseFloat(slice) / 1000)) + '';
|
|
}
|
|
/**
|
|
* Exports all slices from audio file. Appends number to the string
|
|
* to keep slices in alternating order to be quickly stitched together
|
|
* or re-sorted.
|
|
*
|
|
* @param {string} file String representing path to audio file
|
|
* @param {float} len Length of the slice to make
|
|
* @param {integer} order Integer to be appended to pathname of file
|
|
*
|
|
* @returns {string} String with the export order, not sure why I did this
|
|
**/
|
|
async function slices(file, len, order) {
|
|
let ext = 'wav';
|
|
let exe = EXE;
|
|
let slice = SLICE;
|
|
let tmpoutput;
|
|
let cmd;
|
|
let i = 0;
|
|
let total = Math.floor((len * 1000) / parseFloat(slice));
|
|
console.log(`Exporting ${file} as ${total} slices ${SLICE}ms long...`);
|
|
for (i = 0; i < total; i++) {
|
|
tmpoutput = path.join(TMPPATH, `export-${zeroPad(i)}_${order}.${ext}`);
|
|
cmd = `${exe} "${file}" "${tmpoutput}" rate ${RATE} trim ${offset(i, slice)} ${parseFloat(slice) / 1000}`;
|
|
try {
|
|
console.log(cmd);
|
|
await exec(cmd);
|
|
}
|
|
catch (err) {
|
|
console.error('Error exporting file', err);
|
|
return process.exit(3);
|
|
}
|
|
}
|
|
return path.join(TMPPATH, `export-%05d_${order}`);
|
|
}
|
|
/**
|
|
* Re-arranges the slices into the order specified in the pattern.
|
|
* Calls `patternSort()` to perform the rename and unlink actions
|
|
*
|
|
* @param {array} pattern Pattern of the slices per input
|
|
* @param {boolean} realtime Flag to turn on or off realtime behavior (drop slice / number of files)
|
|
* @param {boolean} random Flag to turn on or off random behavior
|
|
*
|
|
* @returns {array} Array of slice paths
|
|
**/
|
|
async function weave(pattern, realtime, random) {
|
|
let slices;
|
|
let seq;
|
|
let ext = '.wav';
|
|
let alt = false;
|
|
console.log('Weaving slices...');
|
|
try {
|
|
slices = await fs.readdir(TMPPATH);
|
|
}
|
|
catch (err) {
|
|
console.error('Error reading tmp directory', err);
|
|
}
|
|
//console.dir(slices)
|
|
slices = slices.filter(file => {
|
|
if (file.indexOf(ext) !== -1)
|
|
return true;
|
|
});
|
|
for (let el of pattern) {
|
|
if (el !== 1)
|
|
alt = true;
|
|
}
|
|
if (random) {
|
|
try {
|
|
seq = await randomSort(slices, pattern, realtime);
|
|
}
|
|
catch (err) {
|
|
console.error('Error sorting slices');
|
|
}
|
|
}
|
|
else if (!alt) {
|
|
try {
|
|
seq = await standardSort(slices, pattern, realtime);
|
|
}
|
|
catch (err) {
|
|
console.error('Error sorting slices');
|
|
}
|
|
}
|
|
else if (alt) {
|
|
try {
|
|
seq = await altSort(slices, pattern, realtime);
|
|
}
|
|
catch (err) {
|
|
console.error('Error sorting slices');
|
|
}
|
|
}
|
|
//console.dir(seq)
|
|
return seq;
|
|
}
|
|
/**
|
|
* TODO
|
|
* Alternate slice sorting method.
|
|
*
|
|
* @param {array} list List of slices to group
|
|
* @param {array} pattern Array representing pattern
|
|
* @param {boolean} realtime Flag to group with "realtime" behavior
|
|
*
|
|
* @returns {array} Sorted array of slices
|
|
**/
|
|
async function altSort(list, pattern, realtime) {
|
|
let groups = [];
|
|
let newList = [];
|
|
let loops = 0;
|
|
let patternIndexes = [];
|
|
let sliceCount = 0;
|
|
let skipCount;
|
|
let skip;
|
|
let oldName;
|
|
let oldPath;
|
|
let newName;
|
|
let newPath;
|
|
let ext = path.extname(list[0]);
|
|
let x;
|
|
let i;
|
|
for (x = 0; x < pattern.length; x++) {
|
|
groups.push([]);
|
|
for (let i = 0; i < pattern[x]; i++) {
|
|
patternIndexes.push(x);
|
|
}
|
|
}
|
|
for (i = 0; i < list.length; i++) {
|
|
groups[i % pattern.length].push(list[i]);
|
|
}
|
|
loops = Math.ceil(list.length / patternIndexes.length);
|
|
if (realtime) {
|
|
skip = false;
|
|
skipCount = patternIndexes.length + 1;
|
|
}
|
|
for (x = 0; x < loops; x++) {
|
|
for (i = 0; i < patternIndexes.length; i++) {
|
|
if (realtime) {
|
|
skipCount--;
|
|
if (skipCount === 0) {
|
|
skip = !skip;
|
|
skipCount = pattern.length;
|
|
}
|
|
}
|
|
if (typeof groups[patternIndexes[i]][0] === 'undefined') {
|
|
continue;
|
|
}
|
|
oldName = String(groups[patternIndexes[i]][0]);
|
|
oldPath = path.join(TMPPATH, oldName);
|
|
groups[patternIndexes[i]].shift();
|
|
if (skip) {
|
|
console.log(`Skipping ${oldName}`);
|
|
try {
|
|
await fs.unlink(oldPath);
|
|
}
|
|
catch (err) {
|
|
console.log('Error deleting slice', err);
|
|
}
|
|
continue;
|
|
}
|
|
newName = `./render_${zeroPad(sliceCount)}${ext}`;
|
|
newPath = path.join(TMPPATH, newName);
|
|
console.log(`Renaming ${oldName} -> ${newName}`);
|
|
try {
|
|
await fs.move(oldPath, newPath);
|
|
newList.push(newName);
|
|
sliceCount++;
|
|
}
|
|
catch (err) {
|
|
console.log('Error renaming slice', err);
|
|
return process.exit(10);
|
|
}
|
|
}
|
|
}
|
|
return newList;
|
|
}
|
|
/**
|
|
* Standard slice sorting method.
|
|
*
|
|
* @param {array} list List of slices to group
|
|
* @param {array} pattern Array representing pattern
|
|
* @param {boolean} realtime Flag to group with "realtime" behavior
|
|
*
|
|
* @returns {array} Sorted array of slices
|
|
**/
|
|
async function standardSort(list, pattern, realtime) {
|
|
let sliceCount = 0;
|
|
let stepCount;
|
|
let step;
|
|
let skipCount;
|
|
let skip;
|
|
let ext = path.extname(list[0]);
|
|
let oldPath;
|
|
let newName;
|
|
let newPath;
|
|
let newList = [];
|
|
if (realtime) {
|
|
skip = false;
|
|
skipCount = pattern.length + 1;
|
|
}
|
|
for (let i = 0; i < list.length; i++) {
|
|
if (realtime) {
|
|
skipCount--;
|
|
if (skipCount === 0) {
|
|
skip = !skip;
|
|
skipCount = pattern.length;
|
|
}
|
|
}
|
|
oldPath = path.join(TMPPATH, list[i]);
|
|
if (skip) {
|
|
console.log(`Skipping ${list[i]}`);
|
|
try {
|
|
await fs.unlink(oldPath);
|
|
}
|
|
catch (err) {
|
|
console.error(err);
|
|
}
|
|
continue;
|
|
}
|
|
newName = `./render_${zeroPad(sliceCount)}${ext}`;
|
|
newPath = path.join(TMPPATH, newName);
|
|
console.log(`Renaming ${list[i]} -> ${newName}`);
|
|
try {
|
|
await fs.move(oldPath, newPath);
|
|
newList.push(newName);
|
|
sliceCount++;
|
|
}
|
|
catch (err) {
|
|
console.error(err);
|
|
return process.exit(10);
|
|
}
|
|
}
|
|
return newList;
|
|
}
|
|
/**
|
|
* Ramdomly sort slices for re-stitching.
|
|
*
|
|
* @param {array} list List of slices to group
|
|
* @param {array} pattern Array representing pattern
|
|
* @param {boolean} realtime Flag to group with "realtime" behavior
|
|
*
|
|
* @returns {array} Sorted array of slices
|
|
**/
|
|
async function randomSort(list, pattern, realtime) {
|
|
let sliceCount = 0;
|
|
let ext = path.extname(list[0]);
|
|
let oldPath;
|
|
let newName;
|
|
let newPath;
|
|
let newList = [];
|
|
let removeLen = 0;
|
|
let remove = [];
|
|
shuffle(list);
|
|
if (realtime) {
|
|
removeLen = Math.floor(list.length / pattern.length);
|
|
remove = list.slice(removeLen, list.length);
|
|
list = list.slice(0, removeLen);
|
|
console.log(`Skipping extra slices...`);
|
|
for (let i = 0; i < remove.length; i++) {
|
|
oldPath = path.join(TMPPATH, remove[i]);
|
|
console.log(`Skipping ${list[i]}`);
|
|
try {
|
|
await fs.unlink(oldPath);
|
|
}
|
|
catch (err) {
|
|
console.error(err);
|
|
}
|
|
}
|
|
}
|
|
for (let i = 0; i < list.length; i++) {
|
|
oldPath = path.join(TMPPATH, list[i]);
|
|
newName = `./render_${zeroPad(sliceCount)}${ext}`;
|
|
newPath = path.join(TMPPATH, newName);
|
|
console.log(`Renaming ${list[i]} -> ${newName}`);
|
|
try {
|
|
await fs.move(oldPath, newPath);
|
|
newList.push(newName);
|
|
}
|
|
catch (err) {
|
|
console.error(err);
|
|
}
|
|
sliceCount++;
|
|
}
|
|
return newList;
|
|
}
|
|
/**
|
|
* Render the slices into a video using ffmpeg.
|
|
*
|
|
* @param {string} output Path to export the video to
|
|
**/
|
|
async function render(allSlices, output) {
|
|
let ext = path.extname(allSlices[0]);
|
|
let partSize = 500;
|
|
let partCount = Math.ceil(allSlices.length / partSize);
|
|
let partName;
|
|
let partFile;
|
|
let parts = [];
|
|
allSlices = allSlices.map(file => {
|
|
return path.join(TMPPATH, file);
|
|
});
|
|
if (partCount < 2) {
|
|
return await arrToFile(allSlices, output);
|
|
}
|
|
for (let part = 0; part < partCount; part++) {
|
|
partName = `./render_part_${zeroPad(part)}${ext}`;
|
|
partFile = path.join(TMPPATH, partName);
|
|
await arrToFile(allSlices.slice(part * partSize, (part + 1) * partSize), partFile);
|
|
parts.push(partFile);
|
|
//process.exit()
|
|
}
|
|
return await arrToFile(parts, output);
|
|
}
|
|
async function arrToFile(arr, output) {
|
|
let exe = EXE;
|
|
let cmd = `${exe} ${arr.join(' ')} ${output}`;
|
|
console.log(`Exporting audio ${output}`);
|
|
console.log(cmd);
|
|
try {
|
|
await exec(cmd);
|
|
}
|
|
catch (err) {
|
|
console.error(err);
|
|
}
|
|
}
|
|
/**
|
|
* Parses the arguments and runs the process of exporting, sorting and then
|
|
* "weaving" the slices back into an audio file
|
|
*
|
|
* @param {object} arg Object containing all arguments
|
|
**/
|
|
async function main(arg) {
|
|
let input = arg.input.split(':');
|
|
let output = arg.output;
|
|
let pattern = [];
|
|
let realtime = false;
|
|
let random = false;
|
|
let allSlices;
|
|
let len;
|
|
let exists;
|
|
console.time('audioloom');
|
|
if (input.length < 2) {
|
|
console.error('Must provide more than 1 input');
|
|
return process.exit(1);
|
|
}
|
|
if (!output) {
|
|
console.error('Must provide audio output path');
|
|
return process.exit(2);
|
|
}
|
|
if (arg.random) {
|
|
random = true;
|
|
}
|
|
if (arg.tmp) {
|
|
TMPDIR = arg.tmp;
|
|
}
|
|
if (arg.fps) {
|
|
SLICE = (1000 / parseFloat(arg.fps)) + '';
|
|
}
|
|
// ms overrides fps
|
|
if (arg.ms) {
|
|
SLICE = (1000 / parseFloat(arg.ms)) + '';
|
|
}
|
|
if (arg.pattern) {
|
|
pattern = arg.pattern.split(':');
|
|
pattern = pattern.map(function (el) {
|
|
return parseInt(el);
|
|
});
|
|
}
|
|
else {
|
|
for (let i = 0; i < input.length; i++) {
|
|
pattern.push(1);
|
|
}
|
|
}
|
|
if (pattern.length !== input.length) {
|
|
console.error(`Number of inputs (${input.length}) doesn't match the pattern length (${pattern.length})`);
|
|
process.exit(10);
|
|
}
|
|
try {
|
|
exists = await exec(`which ${EXE}`);
|
|
}
|
|
catch (err) {
|
|
console.error(`Error checking for ${EXE}`);
|
|
process.exit(11);
|
|
}
|
|
if (!exists || exists === '' || exists.indexOf(EXE) === -1) {
|
|
console.error(`${EXE} is required and is not installed. Please install ${EXE} to use audioloom.`);
|
|
process.exit(12);
|
|
}
|
|
if (arg.realtime)
|
|
realtime = true;
|
|
TMPPATH = path.join(TMPDIR, 'audioloom');
|
|
try {
|
|
await clear();
|
|
}
|
|
catch (err) {
|
|
console.error(err);
|
|
return process.exit(3);
|
|
}
|
|
console.log(`Processing audio files ${input.join(', ')} into ${output} with pattern ${pattern.join(':')}`);
|
|
for (let i = 0; i < input.length; i++) {
|
|
try {
|
|
len = await audioLength(input[i]);
|
|
}
|
|
catch (err) {
|
|
console.error(err);
|
|
return process.exit(4);
|
|
}
|
|
try {
|
|
await slices(input[i], len, i);
|
|
}
|
|
catch (err) {
|
|
console.error(err);
|
|
return process.exit(4);
|
|
}
|
|
}
|
|
try {
|
|
allSlices = await weave(pattern, realtime, random);
|
|
}
|
|
catch (err) {
|
|
console.error(err);
|
|
return process.exit(5);
|
|
}
|
|
try {
|
|
await render(allSlices, output);
|
|
}
|
|
catch (err) {
|
|
console.error(err);
|
|
return process.exit(6);
|
|
}
|
|
try {
|
|
await clear();
|
|
}
|
|
catch (err) {
|
|
console.error(err);
|
|
return process.exit(7);
|
|
}
|
|
console.timeEnd('audioloom');
|
|
}
|
|
program
|
|
.version(packageJson.version)
|
|
.option('-i, --input [files]', 'Specify input audio files with paths seperated by colon')
|
|
.option('-o, --output [file]', 'Specify output path of audio file')
|
|
.option('-p, --pattern [pattern]', 'Specify a pattern for the alternating 1:1 is standard')
|
|
.option('-r, --realtime', 'Specify if audio files should preserve realtime speed')
|
|
.option('-t, --tmp [dir]', 'Specify tmp directory for exporting slices')
|
|
.option('-m, --ms [ms]', 'Specify length of slices using length in milliseconds, will default to 1/24 sec')
|
|
.option('-R, --random', 'Randomize slices. Ignores pattern if included')
|
|
.parse(process.argv);
|
|
main(program);
|