UNPKG

n8n-nodes-audio-tools

Version:

Community audio processing nodes for n8n: concatWithGap & mergeTracks

217 lines (216 loc) 8.89 kB
"use strict"; /* ========================================================================= * 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;