openvibers/openvibe.tools Commits

Audio: merge.openvibe.tools works. Its host mapped to an operation that did not exist; the merge tool now joins 2 to 5 files in the order given (ffmpeg concat filter, every input resampled to 44.1 kHz, or 48 kHz for Opus, and stereo, so MP3, WAV and FLAC mix), re-encoded to any Convert format and bitrate, at most 3 hours in total; a file without an audio track is refused by number. audio.process jobs take 'files' for merge (receive is now uploadAny: one 'file' or up to 5 'files'); validate refuses a wrong file count per tool before anything runs; the result is <first>-merged.<ext> with fileCount. /api/process/multi is the synchronous form. The page has a Merge tab, a numbered file list with remove and add buttons, and output format and bitrate. apps/audio/test/merge.test.js covers the operation, the job, the host default and the sync endpoint

OP OpenVibers committed 23 Sept 2026 · 614973fbe4 · 10 files, +352 −23


Changes

ddiff --git a/apps/audio/public/css/style.css b/apps/audio/public/css/style.css
iindex 47899b3..690fedf 100644
---- a/apps/audio/public/css/style.css
++++ b/apps/audio/public/css/style.css
@@@ -578,3 +578,8 @@ a:hover { color: var(--accent-hover); }
.footer-inner { flex-direction: column; text-align: center; }
.result-actions { flex-direction: column; align-items: center; }
}
++
++/* Merge: the list of files to join, in order */
++.file-details .file-row { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; padding: 4px 0; }
++.file-details .file-row + .file-row { border-top: 1px solid var(--border); }
++.file-details .btn-sm { padding: 4px 10px; font-size: 12px; margin-top: 4px; }
ddiff --git a/apps/audio/public/index.html b/apps/audio/public/index.html
iindex 7401e3a..12d7047 100644
---- a/apps/audio/public/index.html
++++ b/apps/audio/public/index.html
@@@ -38,6 +38,7 @@
<nav class="tool-tabs" id="tool-tabs">
<button class="tool-tab active" data-tool="convert"><i class="fa-solid fa-arrows-rotate"></i> Convert</button>
<button class="tool-tab" data-tool="trim"><i class="fa-solid fa-scissors"></i> Trim</button>
++ <button class="tool-tab" data-tool="merge"><i class="fa-solid fa-object-group"></i> Merge</button>
<button class="tool-tab" data-tool="pitch"><i class="fa-solid fa-wave-square"></i> Pitch</button>
<button class="tool-tab" data-tool="speed"><i class="fa-solid fa-gauge-high"></i> Speed</button>
<button class="tool-tab" data-tool="reverse"><i class="fa-solid fa-backward"></i> Reverse</button>
@@@ -90,6 +91,22 @@
<p class="option-hint">Higher = better quality, larger file. Lossless formats (WAV, FLAC, AIFF) ignore bitrate.</p>
</div>
++ <!-- Merge Options -->
++ <div class="option-group" id="opt-merge" style="display:none">
++ <p class="option-hint">The files are joined in the order listed above (2 to 5 files, up to 3 hours in total). Different formats can be mixed; the result is re-encoded.</p>
++ <label class="option-label">Output Format</label>
++ <div class="format-grid" id="merge-format-grid">
++ <button class="format-btn active" data-format="mp3">MP3</button>
++ <button class="format-btn" data-format="wav">WAV</button>
++ <button class="format-btn" data-format="flac">FLAC</button>
++ <button class="format-btn" data-format="ogg">OGG</button>
++ <button class="format-btn" data-format="m4a">M4A</button>
++ <button class="format-btn" data-format="opus">OPUS</button>
++ </div>
++ <label class="option-label">Bitrate <span id="merge-bitrate-val">192</span> kbps</label>
++ <input type="range" id="merge-bitrate-slider" min="64" max="320" value="192" step="32" class="slider">
++ </div>
++
<!-- Trim Options -->
<div class="option-group" id="opt-trim" style="display:none">
<div class="time-inputs">
ddiff --git a/apps/audio/public/js/app.js b/apps/audio/public/js/app.js
iindex fcb760d..ff1ad0d 100644
---- a/apps/audio/public/js/app.js
++++ b/apps/audio/public/js/app.js
@@@ -10,6 +10,7 @@
let toolList = []; // from /api/tools
let currentTool = null; // active tool ID
let uploadedFile = null; // File object
++ let uploadedFiles = []; // merge: the files in the order they are joined
let resultId = null; // retention ID from /api/process
/* ---------- DOM refs ---------- */
@@@ -188,9 +189,18 @@
const tool = toolList.find(t => t.id === toolId);
processText.textContent = tool ? `Process — ${tool.label}` : 'Process Audio';
++ // Merge takes several files; the other tools one.
++ fileInput.multiple = isMerge();
++ if (isMerge() && uploadedFile && !uploadedFiles.length) uploadedFiles = [uploadedFile];
++ if (!isMerge() && uploadedFiles.length) { uploadedFile = uploadedFiles[0]; uploadedFiles = []; }
++ if (uploadedFile || uploadedFiles.length) showFiles();
++
updateProcessBtn();
}
++ const MERGE_MAX = 5;
++ function isMerge() { return currentTool === 'merge'; }
++
/* ---------- Upload ---------- */
function initUpload() {
// Click to browse
@@@ -203,7 +213,9 @@
// File selected
fileInput.addEventListener('change', () => {
-- if (fileInput.files[0]) handleFile(fileInput.files[0]);
++ if (isMerge()) addFiles([...fileInput.files]);
++ else if (fileInput.files[0]) handleFile(fileInput.files[0]);
++ fileInput.value = '';
});
// Drag & drop
@@@ -212,7 +224,8 @@
uploadZone.addEventListener('drop', (e) => {
e.preventDefault();
uploadZone.classList.remove('drag-over');
-- if (e.dataTransfer.files[0]) handleFile(e.dataTransfer.files[0]);
++ if (isMerge()) addFiles([...e.dataTransfer.files]);
++ else if (e.dataTransfer.files[0]) handleFile(e.dataTransfer.files[0]);
});
// Clear
@@@ -221,14 +234,50 @@
function handleFile(file) {
uploadedFile = file;
-- const size = formatSize(file.size);
-- const ext = file.name.split('.').pop().toUpperCase();
++ showFiles();
++ }
++ /** Merge: add files to the list (up to MERGE_MAX), in the order chosen. */
++ function addFiles(files) {
++ const room = MERGE_MAX - uploadedFiles.length;
++ if (files.length > room) showToast(`At most ${MERGE_MAX} files can be merged at once`, 'error');
++ uploadedFiles = uploadedFiles.concat(files.slice(0, Math.max(0, room)));
++ uploadedFile = uploadedFiles[0] || null;
++ if (uploadedFiles.length) showFiles(); else clearFile();
++ }
++
++ function showFiles() {
const details = $('#file-details');
-- details.innerHTML = `
-- <div class="file-name">${escHtml(file.name)}</div>
-- <div class="file-meta">${ext} — ${size}</div>
-- `;
++ details.textContent = '';
++ const list = isMerge() ? uploadedFiles : (uploadedFile ? [uploadedFile] : []);
++ list.forEach((file, i) => {
++ const row = document.createElement('div');
++ row.className = 'file-row';
++ const name = document.createElement('div');
++ name.className = 'file-name';
++ name.textContent = (isMerge() ? `${i + 1}. ` : '') + file.name;
++ const meta = document.createElement('div');
++ meta.className = 'file-meta';
++ meta.textContent = `${file.name.split('.').pop().toUpperCase()} — ${formatSize(file.size)}`;
++ row.append(name, meta);
++ if (isMerge()) {
++ const rm = document.createElement('button');
++ rm.type = 'button';
++ rm.className = 'btn btn-ghost btn-sm';
++ rm.textContent = 'Remove';
++ rm.addEventListener('click', () => { uploadedFiles.splice(i, 1); addFiles([]); });
++ row.append(rm);
++ }
++ details.append(row);
++ });
++ if (isMerge() && uploadedFiles.length < MERGE_MAX) {
++ const more = document.createElement('button');
++ more.type = 'button';
++ more.className = 'btn btn-ghost btn-sm';
++ more.textContent = uploadedFiles.length < 2 ? 'Add another file' : 'Add a file';
++ more.addEventListener('click', () => fileInput.click());
++ details.append(more);
++ }
uploadArea.style.display = 'none';
uploadInfo.style.display = 'flex';
@@@ -240,6 +289,7 @@
function clearFile() {
uploadedFile = null;
++ uploadedFiles = [];
fileInput.value = '';
uploadArea.style.display = '';
uploadInfo.style.display = 'none';
@@@ -248,7 +298,7 @@
}
function updateProcessBtn() {
-- processBtn.disabled = !uploadedFile || !currentTool;
++ processBtn.disabled = !currentTool || (isMerge() ? uploadedFiles.length < 2 : !uploadedFile);
}
/* ---------- Options ---------- */
@@@ -316,12 +366,17 @@
if (echoDecay) echoDecay.addEventListener('input', () => { $('#echo-decay-val').textContent = parseFloat(echoDecay.value).toFixed(2); });
if (echoRepeats) echoRepeats.addEventListener('input', () => { $('#echo-repeats-val').textContent = echoRepeats.value; });
++ // Merge bitrate slider
++ const mergeBitrate = $('#merge-bitrate-slider');
++ if (mergeBitrate) mergeBitrate.addEventListener('input', () => { $('#merge-bitrate-val').textContent = mergeBitrate.value; });
++
// Reverb mix slider
const reverbMix = $('#reverb-mix');
if (reverbMix) reverbMix.addEventListener('input', () => { $('#reverb-mix-val').textContent = reverbMix.value; });
// Format grid click handlers (convert)
initGridSelect('#format-grid .format-btn', 'format');
++ initGridSelect('#merge-format-grid .format-btn', 'format');
initGridSelect('#norm-mode-grid .format-btn', 'mode');
initGridSelect('#eq-grid .format-btn', 'preset');
initGridSelect('#voice-grid .format-btn', 'preset');
@@@ -357,9 +412,11 @@
/* ---------- Process ---------- */
async function processAudio() {
if (!uploadedFile || !currentTool) return;
++ const files = isMerge() ? uploadedFiles.slice() : [uploadedFile];
++ if (isMerge() && files.length < 2) return;
const formData = new FormData();
-- formData.append('file', uploadedFile);
++ for (const f of files) formData.append(isMerge() ? 'files' : 'file', f);
formData.append('tool', currentTool);
// Gather tool-specific options
@@@ -377,9 +434,9 @@
// Jobs: the work is accepted, followed live (ffmpeg's progress) and survives a reload.
if (window.OVJobs) {
const input = {};
-- for (const [k, v] of formData.entries()) if (k !== 'file') input[k] = v;
++ for (const [k, v] of formData.entries()) if (k !== 'file' && k !== 'files') input[k] = v;
try {
-- const job = await OVJobs.submit({ type: 'audio.process', input, files: [uploadedFile] });
++ const job = await OVJobs.submit({ type: 'audio.process', input, files, fileField: isMerge() ? 'files' : 'file' });
OVJobs.remember(job.id);
followJob(job);
} catch (err) {
@@@ -391,7 +448,7 @@
// Without the jobs helper: the synchronous endpoint, as before.
try {
-- const res = await fetch('/api/process', { method: 'POST', body: formData });
++ const res = await fetch(isMerge() ? '/api/process/multi' : '/api/process', { method: 'POST', body: formData });
const data = await res.json();
if (!res.ok) {
@@@ -415,6 +472,12 @@
o.bitrate = $('#bitrate-slider').value;
break;
}
++ case 'merge': {
++ const fmt = $('#merge-format-grid .format-btn.active');
++ if (fmt) o.format = fmt.dataset.format;
++ o.bitrate = $('#merge-bitrate-slider').value;
++ break;
++ }
case 'trim':
o.start = $('#trim-start').value || '0';
o.end = $('#trim-end').value;
ddiff --git a/apps/audio/server/index.js b/apps/audio/server/index.js
iindex ed49631..3638e34 100644
---- a/apps/audio/server/index.js
++++ b/apps/audio/server/index.js
@@@ -19,7 +19,7 @@ const { optionalAuth } = auth;
const { resolveContext, DOMAIN_MAP } = require('./domain-map');
const { getTool, listTools } = require('./tools');
const { readMetadata } = require('./tools/metadata');
--const { uploadSingle, uploadMultiple } = require('./middleware/upload');
++const { uploadSingle, uploadMultiple, uploadAny } = require('./middleware/upload');
const { apiLimiter, processLimiter, burstLimiter } = require('./middleware/rate-limit');
const retention = require('./retention/manager');
const { probe, getDuration, cleanTmp } = require('./tools/ffmpeg-helper');
@@@ -173,6 +173,10 @@ app.post('/api/process', burstLimiter, processLimiter, uploadSingle, async (req,
cleanTmp(req.file.path);
return res.status(400).json({ error: `Unknown tool: ${toolId}` });
}
++ if (tool.multiFile) {
++ cleanTmp(req.file.path);
++ return res.status(400).json({ error: `${tool.label} takes several files: send them in "files" to /api/process/multi` });
++ }
// Execute the tool (same options and code path as the audio.process job)
const result = await tool.handler(req.file.path, buildOptions(req.body, toolId, req.ctx));
@@@ -200,6 +204,30 @@ app.post('/api/process', burstLimiter, processLimiter, uploadSingle, async (req,
}
});
++// ── Multi-File Processing Endpoint (merge) ───────────────────
++app.post('/api/process/multi', burstLimiter, processLimiter, uploadMultiple, async (req, res) => {
++ const paths = req.files.map(f => f.path);
++ try {
++ const toolId = req.body.tool || req.ctx.defaultOp;
++ const tool = getTool(toolId);
++ if (!tool || !tool.multiFile) {
++ cleanTmp(...paths);
++ return res.status(400).json({ error: tool ? `Tool "${toolId}" takes one file: use /api/process` : `Unknown tool: ${toolId}` });
++ }
++ const result = await tool.handler(paths, buildOptions(req.body, toolId, req.ctx));
++ cleanTmp(...paths);
++ const first = req.files[0].originalname || 'audio';
++ const saved = retention.saveOutputFromFile(result.outputPath, result.ext, result.mime, !!req.user,
++ `${path.basename(first, path.extname(first))}-merged${path.extname(first)}`);
++ const inputSize = req.files.reduce((n, f) => n + f.size, 0);
++ res.json({ success: true, download: saved, ...describe(toolId, result, saved.size, inputSize), fileCount: req.files.length });
++ } catch (err) {
++ cleanTmp(...paths);
++ console.error('[Process/Multi] Error:', err.message);
++ res.status(422).json({ error: err.message || 'Audio processing failed' });
++ }
++});
++
// ── Jobs (/api/v1/jobs) ──────────────────────────────────────
// The same operation as /api/process, asynchronous and durable: accepted into data/jobs.db,
// followed over SSE (ffmpeg's own progress), cancellable (ffmpeg is killed), reattachable by id
@@@ -208,7 +236,7 @@ const jobs = jobsRuntime.setupJobs({
app, service: 'audio', dataDir: path.resolve(__dirname, '..', config.dataDir), Database, contracts, sdk,
getPublicKey: auth.getPublicKey, issuer: auth.ISSUER,
define: defineJobs,
-- receive: uploadSingle,
++ receive: uploadAny,
limiters: [burstLimiter, processLimiter],
defaults(req, input) {
const out = { ...input };
ddiff --git a/apps/audio/server/middleware/upload.js b/apps/audio/server/middleware/upload.js
iindex 9c387dc..36b19c9 100644
---- a/apps/audio/server/middleware/upload.js
++++ b/apps/audio/server/middleware/upload.js
@@@ -78,4 +78,25 @@ function uploadMultiple(req, res, next) {
});
}
--module.exports = { uploadSingle, uploadMultiple };
++/**
++ * One file in 'file' or up to 5 in 'files' (the job endpoint takes either shape; merge sends 'files').
++ * Attaches req.files as { file?: [...], files?: [...] }.
++ */
++function uploadAny(req, res, next) {
++ upload.fields([{ name: 'file', maxCount: 1 }, { name: 'files', maxCount: 5 }])(req, res, (err) => {
++ if (err) {
++ if (err.code === 'LIMIT_FILE_SIZE') {
++ return res.status(413).json({ error: `File too large. Maximum ${Math.round(config.upload.maxFileSize / 1024 / 1024)}MB per file.` });
++ }
++ if (err.code === 'LIMIT_FILE_COUNT' || err.code === 'LIMIT_UNEXPECTED_FILE') {
++ return res.status(400).json({ error: 'Send one file in "file" or up to 5 in "files".' });
++ }
++ return res.status(400).json({ error: err.message });
++ }
++ const n = req.files ? Object.values(req.files).flat().length : 0;
++ if (!n) return res.status(400).json({ error: 'No file uploaded. Send a file in the "file" field.' });
++ next();
++ });
++}
++
++module.exports = { uploadSingle, uploadMultiple, uploadAny };
ddiff --git a/apps/audio/server/process.js b/apps/audio/server/process.js
iindex 9a4069d..852c8b3 100644
---- a/apps/audio/server/process.js
++++ b/apps/audio/server/process.js
@@@ -55,20 +55,26 @@ function describe(toolId, result, size, inputSize) {
};
}
--/** Job type audio.process: input { tool, …options } + one audio or video file. */
++/** Job type audio.process: input { tool, …options } + one audio or video file (2–5 for merge). */
function defineJobs(system) {
const known = new Set(listTools().map(t => t.id));
system.define({
type: 'audio.process',
version: 1,
minFiles: 1,
-- maxFiles: 1,
++ maxFiles: Math.max(...listTools().map(t => t.maxFiles || 1)),
maxAttempts: 2,
onRestart: 'requeue', // ffmpeg over the stored input: safe to run again
timeoutMs: 15 * 60 * 1000,
-- validate(input) {
++ validate(input, files) {
const id = String(input.tool || 'convert');
if (!known.has(id)) return `Unknown tool: ${id}`;
++ const tool = getTool(id);
++ if (tool.multiFile) {
++ if (files.length < tool.minFiles || files.length > tool.maxFiles) return `${tool.label} takes ${tool.minFiles} to ${tool.maxFiles} files (in "files")`;
++ } else if (files.length !== 1) {
++ return `Tool "${id}" takes exactly one file`;
++ }
for (const [k, v] of Object.entries(input)) if (v != null && typeof v === 'object') return `Option ${k} must be a string or a number`;
return null;
},
@@@ -76,14 +82,16 @@ function defineJobs(system) {
const toolId = String(input.tool || 'convert');
const tool = getTool(toolId);
const src = files[0];
-- progress(5, 'Reading the audio');
-- const result = await jobContext.run({ signal, progress }, () => tool.handler(src.path, buildOptions(input, toolId, null)));
++ progress(5, files.length > 1 ? `Reading ${files.length} files` : 'Reading the audio');
++ const arg = tool.multiFile ? files.map(f => f.path) : src.path;
++ const result = await jobContext.run({ signal, progress }, () => tool.handler(arg, buildOptions(input, toolId, null)));
if (signal.aborted) { fs.rm(result.outputPath, { force: true }, () => {}); throw new Error('cancelled'); }
const size = fs.statSync(result.outputPath).size;
const base = path.basename(src.name || 'audio', path.extname(src.name || '')) || 'openvibeaudio-output';
++ const inputSize = files.reduce((n, f) => n + (f.size || 0), 0);
return {
-- files: [{ path: result.outputPath, name: `${base}.${result.ext}`, mime: result.mime }],
-- data: describe(toolId, result, size, src.size),
++ files: [{ path: result.outputPath, name: `${base}${tool.multiFile ? '-merged' : ''}.${result.ext}`, mime: result.mime }],
++ data: { ...describe(toolId, result, size, inputSize), ...(tool.multiFile && { fileCount: files.length }) },
};
},
});
ddiff --git a/apps/audio/server/tools/convert.js b/apps/audio/server/tools/convert.js
iindex e6a783e..f07a05b 100644
---- a/apps/audio/server/tools/convert.js
++++ b/apps/audio/server/tools/convert.js
@@@ -90,5 +90,6 @@ async function convert(inputPath, options = {}) {
}
convert.formats = Object.keys(FORMAT_CONFIG);
++convert.FORMAT_CONFIG = FORMAT_CONFIG; // merge encodes to the same formats
module.exports = convert;
ddiff --git a/apps/audio/server/tools/index.js b/apps/audio/server/tools/index.js
iindex 44dac82..108bd48 100644
---- a/apps/audio/server/tools/index.js
++++ b/apps/audio/server/tools/index.js
@@@ -8,6 +8,7 @@
const convert = require('./convert');
const trim = require('./trim');
++const merge = require('./merge');
const pitch = require('./pitch');
const speed = require('./speed');
const reverse = require('./reverse');
@@@ -55,6 +56,18 @@ const TOOLS = {
category: 'editing',
handler: trim,
},
++ merge: {
++ id: 'merge',
++ label: 'Merge',
++ description: 'Join 2–5 audio files into one',
++ faIcon: 'fa-object-group',
++ accepts: AUDIO_FORMATS,
++ category: 'editing',
++ multiFile: true, // handler(paths[], options); 2–5 files in the order given
++ minFiles: merge.MIN_FILES,
++ maxFiles: merge.MAX_FILES,
++ handler: merge,
++ },
pitch: {
id: 'pitch',
label: 'Pitch',
@@@ -282,6 +295,7 @@ function listTools() {
return Object.values(TOOLS).map(t => ({
id: t.id, label: t.label, description: t.description,
faIcon: t.faIcon, accepts: t.accepts, category: t.category,
++ ...(t.multiFile && { multiFile: true, minFiles: t.minFiles, maxFiles: t.maxFiles }),
}));
}
ddiff --git a/apps/audio/server/tools/merge.js b/apps/audio/server/tools/merge.js
nnew file mode 100644
iindex 0000000..34ebea3
---- /dev/null
++++ b/apps/audio/server/tools/merge.js
@@@ -0,0 +1,72 @@
++'use strict';
++
++// ═══════════════════════════════════════════════════════════════
++// Audio.OpenVibe — Merge (join) Tool
++// Joins 2–5 audio files, in the order given, into one file. Every input is resampled to one
++// rate and to stereo, so an MP3, a WAV and a FLAC can be joined; the result is re-encoded to
++// the chosen format (the same formats and codecs as Convert).
++// ═══════════════════════════════════════════════════════════════
++
++const ffmpeg = require('fluent-ffmpeg');
++const { tmpFile, probe, getAudioStream, getDuration, getMime } = require('./ffmpeg-helper');
++const { FORMAT_CONFIG } = require('./convert');
++
++const MIN_FILES = 2;
++const MAX_FILES = 5; // the upload limit (middleware/upload.js)
++const MAX_TOTAL_SECONDS = 3 * 60 * 60; // three hours of joined audio
++const LOSSY = new Set(['mp3', 'ogg', 'm4a', 'aac', 'opus', 'wma', 'ac3', 'webm']);
++
++function refuse(message) { return Object.assign(new Error(message), { status: 422, expose: true }); }
++
++/**
++ * @param {string[]} inputPaths files in the order they are joined
++ * @param {Object} options
++ * @param {string} [options.format='mp3'] output format (any Convert format)
++ * @param {number} [options.bitrate=192] kbps, lossy formats only
++ * @returns {Promise<{ outputPath, mime, ext, duration, inputs }>}
++ */
++async function merge(inputPaths, options = {}) {
++ const paths = Array.isArray(inputPaths) ? inputPaths : [inputPaths];
++ if (paths.length < MIN_FILES) throw refuse(`Upload at least ${MIN_FILES} files to merge.`);
++ if (paths.length > MAX_FILES) throw refuse(`At most ${MAX_FILES} files can be merged at once.`);
++ const format = String(options.format || 'mp3').toLowerCase();
++ const cfg = FORMAT_CONFIG[format];
++ if (!cfg) throw refuse(`Unsupported output format: ${format}`);
++ const bitrate = Math.min(320, Math.max(32, parseInt(options.bitrate, 10) || 192));
++
++ // Every file must carry audio, and the whole must stay within the length limit.
++ let total = 0;
++ for (let i = 0; i < paths.length; i++) {
++ let info;
++ try { info = await probe(paths[i]); } catch { throw refuse(`File ${i + 1} is not an audio or video file ffmpeg can read.`); }
++ if (!getAudioStream(info)) throw refuse(`File ${i + 1} has no audio track.`);
++ total += getDuration(info) || 0;
++ }
++ if (total > MAX_TOTAL_SECONDS) throw refuse(`The merged audio would be ${Math.round(total / 60)} minutes long; the limit is ${MAX_TOTAL_SECONDS / 60} minutes.`);
++
++ // Opus only encodes at 48 kHz (and 24/16/12/8); everything else is joined at 44.1 kHz.
++ const rate = ['opus', 'webm'].includes(format) ? 48000 : 44100;
++ const graph = paths.map((_, i) => `[${i}:a:0]aresample=${rate},aformat=sample_fmts=fltp:sample_rates=${rate}:channel_layouts=stereo[a${i}]`)
++ .concat(`${paths.map((_, i) => `[a${i}]`).join('')}concat=n=${paths.length}:v=0:a=1[out]`)
++ .join(';');
++
++ const outputPath = tmpFile(cfg.ext);
++ await new Promise((resolve, reject) => {
++ let cmd = ffmpeg();
++ for (const p of paths) cmd = cmd.input(p);
++ cmd = cmd.complexFilter(graph, 'out').audioCodec(cfg.codec);
++ if (LOSSY.has(format)) cmd = cmd.audioBitrate(bitrate);
++ for (const arg of cfg.extraArgs) cmd = cmd.outputOption(arg);
++ cmd.on('error', reject).on('end', resolve).save(outputPath);
++ });
++
++ let duration = 0;
++ try { duration = getDuration(await probe(outputPath)); } catch { /* ok */ }
++ return { outputPath, mime: getMime(cfg.ext), ext: cfg.ext, duration, inputs: paths.length };
++}
++
++merge.MIN_FILES = MIN_FILES;
++merge.MAX_FILES = MAX_FILES;
++merge.MAX_TOTAL_SECONDS = MAX_TOTAL_SECONDS;
++
++module.exports = merge;
ddiff --git a/apps/audio/test/merge.test.js b/apps/audio/test/merge.test.js
nnew file mode 100644
iindex 0000000..0e8498b
---- /dev/null
++++ b/apps/audio/test/merge.test.js
@@@ -0,0 +1,100 @@
++'use strict';
++// Merge (merge.openvibe.tools): the host pointed at an operation that did not exist. Now 2–5 files of
++// any format are re-encoded to one rate and stereo and joined in order, as a job (files in "files")
++// and on /api/process/multi; wrong file counts are refused before anything runs.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const { execFileSync } = require('child_process');
++const { startApp } = require('../../_shared/test/spawn');
++
++const sleep = (ms) => new Promise(r => setTimeout(r, ms));
++
++try { execFileSync('ffmpeg', ['-version'], { stdio: 'ignore' }); } catch {
++ console.log('audio merge: SKIPPED (no ffmpeg on this machine)');
++ process.exit(0);
++}
++
++const seconds = (file) => parseFloat(execFileSync('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', file]).toString());
++
++(async () => {
++ const data = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-audio-merge-'));
++ process.env.DATA_DIR = data; // the tool's temp files go here, not into the app directory
++ const env = { DATA_DIR: data, UPLOADS_DIR: path.join(data, 'uploads'), OUTPUT_DIR: path.join(data, 'output') };
++ // Different formats, rates and channel counts: a 1 s mono MP3 at 44.1 kHz and a 0.5 s stereo WAV at 22.05 kHz.
++ const mp3 = path.join(data, 'one.mp3'), wav = path.join(data, 'two.wav');
++ execFileSync('ffmpeg', ['-loglevel', 'error', '-y', '-f', 'lavfi', '-i', 'sine=frequency=440:duration=1', '-ac', '1', '-ar', '44100', mp3]);
++ execFileSync('ffmpeg', ['-loglevel', 'error', '-y', '-f', 'lavfi', '-i', 'sine=frequency=660:duration=0.5', '-ac', '2', '-ar', '22050', wav]);
++
++ // The operation itself.
++ const { getTool, listTools } = require('../server/tools');
++ const merge = getTool('merge');
++ assert.ok(merge && merge.multiFile, 'merge is a registered tool');
++ assert.ok(listTools().find(t => t.id === 'merge').multiFile);
++ const out = await merge.handler([mp3, wav], { format: 'wav' });
++ assert.strictEqual(out.ext, 'wav');
++ assert.ok(Math.abs(seconds(out.outputPath) - 1.5) < 0.1, `joined length ${seconds(out.outputPath)}`);
++ const probe = execFileSync('ffprobe', ['-v', 'error', '-show_entries', 'stream=channels,sample_rate', '-of', 'csv=p=0', out.outputPath]).toString().trim();
++ assert.strictEqual(probe, '44100,2', 'one rate, stereo');
++ fs.rmSync(out.outputPath, { force: true });
++ await assert.rejects(merge.handler([mp3], {}), /at least 2/);
++ await assert.rejects(merge.handler([mp3, wav], { format: 'exe' }), /Unsupported output format/);
++ const text = path.join(data, 'notes.txt'); fs.writeFileSync(text, 'not audio');
++ await assert.rejects(merge.handler([mp3, text], {}), /File 2/);
++
++ // As a real process.
++ const app = await startApp('audio', env);
++ try {
++ const fd = new FormData();
++ fd.append('type', 'audio.process');
++ fd.append('input', JSON.stringify({ tool: 'merge', format: 'mp3', bitrate: '64' }));
++ fd.append('files', new Blob([fs.readFileSync(mp3)], { type: 'audio/mpeg' }), 'intro.mp3');
++ fd.append('files', new Blob([fs.readFileSync(wav)], { type: 'audio/wav' }), 'outro.wav');
++ let r = await fetch(`${app.base}/api/v1/jobs`, { method: 'POST', body: fd });
++ assert.strictEqual(r.status, 202, await r.clone().text());
++ const cookie = r.headers.get('set-cookie').split(';')[0];
++ let job = await r.json();
++ for (let i = 0; i < 200 && !['succeeded', 'failed'].includes(job.state); i++) {
++ await sleep(100);
++ job = await (await fetch(`${app.base}/api/v1/jobs/${job.id}`, { headers: { cookie } })).json();
++ }
++ assert.strictEqual(job.state, 'succeeded', JSON.stringify(job.error));
++ assert.strictEqual(job.result.files[0].name, 'intro-merged.mp3');
++ assert.strictEqual(job.result.data.fileCount, 2);
++ const file = path.join(data, 'merged.mp3');
++ fs.writeFileSync(file, Buffer.from(await (await fetch(`${app.base}${job.result.files[0].url}`, { headers: { cookie } })).arrayBuffer()));
++ assert.ok(Math.abs(seconds(file) - 1.5) < 0.15, `job result length ${seconds(file)}`);
++
++ // The merge host submits merge without naming the tool.
++ const one = new FormData();
++ one.append('type', 'audio.process');
++ one.append('files', new Blob([fs.readFileSync(mp3)], { type: 'audio/mpeg' }), 'a.mp3');
++ r = await fetch(`${app.base}/api/v1/jobs`, { method: 'POST', body: one, headers: { cookie, 'X-OV-Tool': 'merge' } });
++ assert.strictEqual(r.status, 400, 'one file is not enough to merge');
++ assert.match((await r.json()).detail, /2 to 5 files/);
++ const two = new FormData();
++ two.append('type', 'audio.process');
++ two.append('input', JSON.stringify({ tool: 'trim' }));
++ two.append('files', new Blob([fs.readFileSync(mp3)], { type: 'audio/mpeg' }), 'a.mp3');
++ two.append('files', new Blob([fs.readFileSync(mp3)], { type: 'audio/mpeg' }), 'b.mp3');
++ r = await fetch(`${app.base}/api/v1/jobs`, { method: 'POST', body: two, headers: { cookie } });
++ assert.strictEqual(r.status, 400, 'single-file tools take one file');
++
++ // The synchronous endpoint (after the burst limiter's window: the jobs above count too).
++ await sleep(5100);
++ const sync = new FormData();
++ sync.append('tool', 'merge');
++ sync.append('files', new Blob([fs.readFileSync(wav)], { type: 'audio/wav' }), 'x.wav');
++ sync.append('files', new Blob([fs.readFileSync(mp3)], { type: 'audio/mpeg' }), 'y.mp3');
++ r = await fetch(`${app.base}/api/process/multi`, { method: 'POST', body: sync });
++ const body = await r.json();
++ assert.strictEqual(r.status, 200, JSON.stringify(body));
++ assert.strictEqual(body.fileCount, 2);
++ assert.strictEqual(body.output.ext, 'mp3');
++ } finally {
++ await app.kill();
++ fs.rmSync(data, { recursive: true, force: true });
++ }
++ console.log('audio merge: all checks passed');
++})().catch((err) => { console.error(err); process.exit(1); });