synaudio-cli
Version:
A command line tool for aligning and synchronizing two or more audio clips based on their content.
248 lines (226 loc) • 6.52 kB
JavaScript
import SynAudio from "synaudio";
import fs from "fs";
import { decodeAndSave } from "./decode.js";
import { simpleLinearRegression } from "./linear-regression.js";
import {
getFileInfo,
getTempFile,
runCmd,
roundToSampleRate,
} from "./utilities.js";
const trimAndResample = async (
inputFile,
outputFile,
startSeconds,
endSeconds,
rate,
sampleRate,
bitDepth,
channels,
normalize,
normalizeIndependent,
encodeOptions,
flacThreads,
) => {
const tempFileName = getTempFile();
const tempFiles = [];
try {
// resample
console.log("Adjusting offset and speed...");
const tempTrimmed = tempFileName + ".tmp.flac";
tempFiles.push(tempTrimmed);
await runCmd("sox", [
inputFile,
tempTrimmed,
"rate",
"-v",
...(startSeconds > 0 ? ["trim", startSeconds] : ["pad", -startSeconds]),
"speed",
rate,
"trim",
"0",
endSeconds,
]).promise;
// optionally normalize
const normalizePromises = [];
const normalizeOutputFiles = [];
if (normalizeIndependent) {
console.log("Normalizing each channel...");
for (let i = 1; i <= channels; i++) {
const channelFile = `${tempFileName}.tmp.${i}.flac`;
const normalizedFile = `${tempFileName}.tmp.norm.${i}.flac`;
tempFiles.push(channelFile);
tempFiles.push(normalizedFile);
normalizeOutputFiles.push(normalizedFile);
normalizePromises.push(
runCmd("sox", [
tempTrimmed,
"-c",
"1",
channelFile,
"remix",
i,
]).promise.then(
() =>
runCmd("sox", [channelFile, normalizedFile, "--norm"]).promise,
),
);
}
await Promise.all(normalizePromises);
} else if (normalize) {
console.log("Normalizing...");
const normalizedFile = `${tempFileName}.tmp.norm.flac`;
tempFiles.push(normalizedFile);
normalizeOutputFiles.push(normalizedFile);
normalizePromises.push(
runCmd("sox", [tempTrimmed, normalizedFile, "--norm"]).promise,
);
await Promise.all(normalizePromises);
} else {
normalizeOutputFiles.push(tempTrimmed);
}
console.log("Encoding output file...", outputFile);
const { stdout: soxStdout } = runCmd("sox", [
...(normalizeIndependent ? ["--combine", "merge"] : []),
...normalizeOutputFiles,
"-t",
"raw",
"-r",
sampleRate,
"-c",
channels,
"-e",
"signed",
"-b",
bitDepth,
"-",
]);
const { promise: flacPromise, stdin: flacStdin } = runCmd("flac", [
"-s",
"--endian=little",
"--sign=signed",
"--bps=" + bitDepth,
"--channels=" + channels,
"--sample-rate=" + sampleRate,
"--lax",
encodeOptions,
...(flacThreads > 1 ? ["-j", flacThreads] : []),
"-",
"-f",
"-o",
outputFile,
]);
soxStdout.pipe(flacStdin);
await flacPromise;
} catch (e) {
console.log(e);
} finally {
await Promise.all(tempFiles.map((file) => fs.promises.rm(file).catch()));
}
};
export const syncResampleEncode = async ({
baseFile,
comparisonFile,
threads,
flacThreads,
sampleLength,
sampleGap,
startRange,
endRange,
rateTolerance,
rectify,
deleteComparison,
normalize,
normalizeIndependent,
encodeOptions,
renameString,
}) => {
console.log("Decoding files...");
const fileInfo = await getFileInfo([baseFile, comparisonFile]);
const comparisonFileInfo = fileInfo[1];
const commonSampleRate = Math.max(...fileInfo.map((info) => info.sampleRate));
sampleLength = roundToSampleRate(sampleLength, commonSampleRate);
sampleGap = roundToSampleRate(sampleGap, commonSampleRate);
startRange = roundToSampleRate(startRange, commonSampleRate);
endRange = roundToSampleRate(endRange, commonSampleRate);
let synaudio = new SynAudio({
correlationSampleSize: sampleLength * commonSampleRate,
initialGranularity: 16,
shared: true,
});
let [baseFileDecoded, comparisonFileChunks] = await Promise.all([
decodeAndSave(baseFile, commonSampleRate, rectify),
decodeAndSave(
comparisonFile,
commonSampleRate,
rectify,
sampleLength,
sampleGap,
startRange,
endRange,
),
]);
const baseFileDecodedLength = baseFileDecoded.length;
process.stdout.write("Synchronizing files...");
const syncResults = synaudio.syncOneToMany(
{
channelData: [baseFileDecoded],
samplesDecoded: baseFileDecoded.length,
},
comparisonFileChunks.map((comparisonChunk, i) => ({
name: i,
data: {
channelData: [comparisonChunk.data],
samplesDecoded: comparisonChunk.data.length,
},
syncStart: comparisonChunk.syncStart,
syncEnd: comparisonChunk.syncEnd,
})),
threads,
(progress) =>
process.stdout.write(
`\rSynchronizing files... ${Math.round(progress * 100)}%`,
),
);
// dereference these so they can be garbage collected as soon as possible
baseFileDecoded = null;
comparisonFileChunks.forEach((chunk) => (chunk.data = null));
const results = (await syncResults).map((sr, i) => ({
...sr,
order: i,
difference:
(comparisonFileChunks[i].start - sr.sampleOffset) / commonSampleRate,
start: comparisonFileChunks[i].start,
end: comparisonFileChunks[i].end,
}));
process.stdout.write("\n");
synaudio = null;
const { slope, intercept } = simpleLinearRegression(results, rateTolerance);
const startSeconds = intercept;
const endSeconds = baseFileDecodedLength / 48000;
const rate = 1 + slope / sampleGap;
console.log("Trim start", startSeconds, "Trim end", endSeconds, "Rate", rate);
const outputFile = comparisonFile.replace(/(\.[^\.]+)$/, `${renameString}$1`);
await trimAndResample(
comparisonFile,
outputFile,
startSeconds,
endSeconds,
rate,
comparisonFileInfo.sampleRate,
comparisonFileInfo.bitDepth || 24, // default to 24 in case ffprobe doesn't return a bitdepth
comparisonFileInfo.channels,
normalize,
normalizeIndependent,
encodeOptions,
flacThreads,
);
if (deleteComparison) {
console.log("Deleting comparison file...");
await fs.promises.rm(comparisonFile).catch((e) => {
console.error("failed to delete the comparison file", e);
});
}
console.log("Done");
setTimeout(() => process.exit(0), 3000);
};