n8n-nodes-audio-tools
Version:
Community audio processing nodes for n8n: concatWithGap & mergeTracks
217 lines (216 loc) • 8.89 kB
JavaScript
;
/* =========================================================================
* AudioMergeSequence · v6.3
* – variable gaps · 2-sided fades (correct) · single filtergraph
* ========================================================================= */
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.AudioMergeSequence = void 0;
const fluent_ffmpeg_1 = __importDefault(require("fluent-ffmpeg"));
const fs_1 = require("fs");
const os_1 = require("os");
const path_1 = __importDefault(require("path"));
const ffmpeg_utils_1 = require("../helpers/ffmpeg-utils");
const SR = 44100; // target sample-rate (Hz)
const MAX_INPUTS = 30; // < 32 for FFmpeg 4
/* ───────── helpers ───────── */
const tmp = (ext) => path_1.default.join((0, os_1.tmpdir)(), `n8n-audio-${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`);
const writeTemp = async (buf, ext) => {
const p = tmp(ext);
await fs_1.promises.writeFile(p, buf);
return p;
};
/* ──────────────────────────────────────────────────────
* Build ≤30 inputs → one chunk
* ────────────────────────────────────────────────────── */
async function buildChunk(files, gaps, gapDef, fade, fmt) {
const minGap = fade * 2 + 0.01;
const norm = `aresample=${SR},aformat=sample_fmts=s16:channel_layouts=stereo`;
let cmd = (0, fluent_ffmpeg_1.default)();
const filter = [];
const seq = [];
/* add original clips */
files.forEach((f) => {
cmd = cmd.input(f.path);
});
let nextIn = files.length;
files.forEach((_f, i) => {
var _a;
const out = `seg${i}`;
/* 2-sided fade (fade-in then reverse/fade-in to simulate fade-out) */
filter.push(`[${i}:a]` +
`afade=t=in:d=${fade},` +
`areverse,afade=t=in:d=${fade},areverse,` +
`${norm}[${out}]`);
seq.push(`[${out}]`);
/* generate silence after every but the last segment */
if (i === files.length - 1)
return;
let gap = (_a = gaps[i]) !== null && _a !== void 0 ? _a : gapDef;
if (gap < minGap)
gap = minGap;
cmd = cmd
.input(`anullsrc=r=${SR}:cl=stereo:d=${gap}`)
.inputOptions(["-f", "lavfi"]);
const sil = `sil${i}`;
filter.push(`[${nextIn}:a]${norm}[${sil}]`);
seq.push(`[${sil}]`);
nextIn++;
});
/* concat the ordered list */
filter.push(`${seq.join("")}concat=n=${seq.length}:v=0:a=1[out]`);
cmd = cmd.complexFilter(filter, "out");
/* encode */
switch (fmt) {
case "mp3":
cmd.audioCodec("libmp3lame").outputOptions(["-q:a", "0"]);
break;
case "wav":
cmd.audioCodec("pcm_s16le");
break;
case "flac":
cmd.audioCodec("flac");
break;
}
cmd.on("start", (c) => console.log("[ffmpeg]", c));
cmd.on("stderr", (l) => console.log("[ffmpeg]", l)); // keep while testing
const buf = await (0, ffmpeg_utils_1.runFfmpegToBuffer)(cmd, fmt);
if (!buf.length)
throw new Error("FFmpeg produced an empty buffer (chunk pass)");
return buf;
}
/* ───────── concat multiple chunks ───────── */
async function concatChunks(chunks, fmt) {
const paths = await Promise.all(chunks.map((b) => writeTemp(b, fmt)));
let cmd = (0, fluent_ffmpeg_1.default)();
paths.forEach((p) => {
cmd = cmd.input(p);
});
const tags = paths.map((_p, i) => `[${i}:a]`).join("");
cmd = cmd.complexFilter(`${tags}concat=n=${paths.length}:v=0:a=1[out]`, "out");
switch (fmt) {
case "mp3":
cmd.audioCodec("libmp3lame").outputOptions(["-q:a", "0"]);
break;
case "wav":
cmd.audioCodec("pcm_s16le");
break;
case "flac":
cmd.audioCodec("flac");
break;
}
cmd.on("start", (c) => console.log("[ffmpeg]", c));
cmd.on("stderr", (l) => console.log("[ffmpeg]", l));
const out = await (0, ffmpeg_utils_1.runFfmpegToBuffer)(cmd, fmt);
await Promise.all(paths.map((p) => fs_1.promises.unlink(p).catch(() => { })));
if (!out.length)
throw new Error("FFmpeg produced an empty buffer (concat pass)");
return out;
}
/* ───────── n8n node ───────── */
class AudioMergeSequence {
constructor() {
this.description = {
name: "audioMergeSequence",
displayName: "Audio · Merge Sequence",
group: ["transform"],
version: 6.3,
icon: "fa:music",
description: "Merge N audio binaries with variable gaps and per-segment fades",
defaults: { name: "Merge Seq" },
inputs: ["main"],
outputs: ["main"],
properties: [
{
displayName: "Binary property",
name: "bin",
type: "string",
default: "data",
},
{
displayName: "Gap list",
name: "gaps",
type: "string",
default: "",
},
{
displayName: "Fallback gap (s)",
name: "gapDef",
type: "number",
default: 0,
},
{
displayName: "Fade length (s)",
name: "fadeSec",
type: "number",
default: 0.05,
},
{
displayName: "Format",
name: "fmt",
type: "options",
options: [
{ name: "Same as first", value: "auto" },
{ name: "WAV", value: "wav" },
{ name: "MP3", value: "mp3" },
{ name: "FLAC", value: "flac" },
],
default: "auto",
},
{
displayName: "Output binary",
name: "outBin",
type: "string",
default: "merged_data",
},
],
};
}
async execute() {
var _a;
const items = this.getInputData();
if (items.length < 2)
throw new Error("Need at least 2 items");
const bin = this.getNodeParameter("bin", 0);
const rawGap = this.getNodeParameter("gaps", 0);
const gapDef = this.getNodeParameter("gapDef", 0, 0);
const fade = this.getNodeParameter("fadeSec", 0, 0.05);
const fmtSel = this.getNodeParameter("fmt", 0, "auto");
const outBin = this.getNodeParameter("outBin", 0, "merged_data");
/* parse gap list */
let gaps = [];
if (Array.isArray(rawGap))
gaps = rawGap.map(Number);
else if (typeof rawGap === "string" && rawGap.trim()) {
gaps = rawGap.split(",").map((s) => parseFloat(s.trim()));
}
gaps = gaps
.filter((v) => !Number.isNaN(v))
.map((v) => (v > 10 ? v / 1000 : v));
/* write temp files */
const temps = [];
for (const it of items) {
const b = (_a = it.binary) === null || _a === void 0 ? void 0 : _a[bin];
(0, ffmpeg_utils_1.ensureBinaryExists)(b, bin);
const ext = (0, ffmpeg_utils_1.getExtensionFromName)(b.fileName);
const p = tmp(ext);
await fs_1.promises.writeFile(p, Buffer.from(b.data, "base64"));
temps.push({ path: p, ext });
}
/* first pass */
const chunks = [];
for (let i = 0; i < temps.length; i += MAX_INPUTS / 2) {
chunks.push(await buildChunk(temps.slice(i, i + MAX_INPUTS / 2), gaps.slice(i, i + MAX_INPUTS / 2 - 1), gapDef, fade, (fmtSel === "auto" ? temps[0].ext : fmtSel)));
}
/* second pass if needed */
const final = chunks.length === 1
? chunks[0]
: await concatChunks(chunks, (fmtSel === "auto" ? temps[0].ext : fmtSel));
const binary = await this.helpers.prepareBinaryData(final, `merged.${fmtSel === "auto" ? temps[0].ext : fmtSel}`);
return [[{ json: {}, binary: { [outBin]: binary } }]];
}
}
exports.AudioMergeSequence = AudioMergeSequence;
exports.default = AudioMergeSequence;