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
Changes
@@@ -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; }@@@ -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">@@@ -10,6 +10,7 @@let toolList = []; // from /api/toolslet currentTool = null; // active tool IDlet uploadedFile = null; // File object++ let uploadedFiles = []; // merge: the files in the order they are joinedlet 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 selectedfileInput.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 sliderconst 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;@@@ -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 };@@@ -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 };@@@ -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 againtimeoutMs: 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 }) },};},});@@@ -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 formatsmodule.exports = convert;@@@ -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 }),}));}nnew file mode 100644@@@ -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;nnew file mode 100644@@@ -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); });
@@ -578,3 +578,8 @@ a:hover { color: var(--accent-hover); }
@@ -578,3 +578,8 @@ a:hover { color: var(--accent-hover); }
.footer-inner { flex-direction: column; text-align: center; }
.footer-inner { flex-direction: column; text-align: center; }
.result-actions { flex-direction: column; align-items: 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; }
@@ -38,6 +38,7 @@
@@ -38,6 +38,7 @@
<nav class="tool-tabs" id="tool-tabs">
<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 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="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="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="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>
<button class="tool-tab" data-tool="reverse"><i class="fa-solid fa-backward"></i> Reverse</button>
@@ -90,6 +91,22 @@
@@ -90,6 +91,22 @@
<p class="option-hint">Higher = better quality, larger file. Lossless formats (WAV, FLAC, AIFF) ignore bitrate.</p>
<p class="option-hint">Higher = better quality, larger file. Lossless formats (WAV, FLAC, AIFF) ignore bitrate.</p>
</div>
</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 -->
<!-- Trim Options -->
<div class="option-group" id="opt-trim" style="display:none">
<div class="option-group" id="opt-trim" style="display:none">
<div class="time-inputs">
<div class="time-inputs">
@@ -10,6 +10,7 @@
@@ -10,6 +10,7 @@
let toolList = []; // from /api/tools
let toolList = []; // from /api/tools
let currentTool = null; // active tool ID
let currentTool = null; // active tool ID
let uploadedFile = null; // File object
let uploadedFile = null; // File object
+ let uploadedFiles = []; // merge: the files in the order they are joined
let resultId = null; // retention ID from /api/process
let resultId = null; // retention ID from /api/process
/* ---------- DOM refs ---------- */
/* ---------- DOM refs ---------- */
@@ -188,9 +189,18 @@
@@ -188,9 +189,18 @@
const tool = toolList.find(t => t.id === toolId);
const tool = toolList.find(t => t.id === toolId);
processText.textContent = tool ? `Process — ${tool.label}` : 'Process Audio';
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();
updateProcessBtn();
}
}
+ const MERGE_MAX = 5;
+ function isMerge() { return currentTool === 'merge'; }
+
/* ---------- Upload ---------- */
/* ---------- Upload ---------- */
function initUpload() {
function initUpload() {
// Click to browse
// Click to browse
@@ -203,7 +213,9 @@
@@ -203,7 +213,9 @@
// File selected
// File selected
fileInput.addEventListener('change', () => {
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
// Drag & drop
@@ -212,7 +224,8 @@
@@ -212,7 +224,8 @@
uploadZone.addEventListener('drop', (e) => {
uploadZone.addEventListener('drop', (e) => {
e.preventDefault();
e.preventDefault();
uploadZone.classList.remove('drag-over');
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
// Clear
@@ -221,14 +234,50 @@
@@ -221,14 +234,50 @@
function handleFile(file) {
function handleFile(file) {
uploadedFile = 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');
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';
uploadArea.style.display = 'none';
uploadInfo.style.display = 'flex';
uploadInfo.style.display = 'flex';
@@ -240,6 +289,7 @@
@@ -240,6 +289,7 @@
function clearFile() {
function clearFile() {
uploadedFile = null;
uploadedFile = null;
+ uploadedFiles = [];
fileInput.value = '';
fileInput.value = '';
uploadArea.style.display = '';
uploadArea.style.display = '';
uploadInfo.style.display = 'none';
uploadInfo.style.display = 'none';
@@ -248,7 +298,7 @@
@@ -248,7 +298,7 @@
}
}
function updateProcessBtn() {
function updateProcessBtn() {
- processBtn.disabled = !uploadedFile || !currentTool;
+ processBtn.disabled = !currentTool || (isMerge() ? uploadedFiles.length < 2 : !uploadedFile);
}
}
/* ---------- Options ---------- */
/* ---------- Options ---------- */
@@ -316,12 +366,17 @@
@@ -316,12 +366,17 @@
if (echoDecay) echoDecay.addEventListener('input', () => { $('#echo-decay-val').textContent = parseFloat(echoDecay.value).toFixed(2); });
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; });
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
// Reverb mix slider
const reverbMix = $('#reverb-mix');
const reverbMix = $('#reverb-mix');
if (reverbMix) reverbMix.addEventListener('input', () => { $('#reverb-mix-val').textContent = reverbMix.value; });
if (reverbMix) reverbMix.addEventListener('input', () => { $('#reverb-mix-val').textContent = reverbMix.value; });
// Format grid click handlers (convert)
// Format grid click handlers (convert)
initGridSelect('#format-grid .format-btn', 'format');
initGridSelect('#format-grid .format-btn', 'format');
+ initGridSelect('#merge-format-grid .format-btn', 'format');
initGridSelect('#norm-mode-grid .format-btn', 'mode');
initGridSelect('#norm-mode-grid .format-btn', 'mode');
initGridSelect('#eq-grid .format-btn', 'preset');
initGridSelect('#eq-grid .format-btn', 'preset');
initGridSelect('#voice-grid .format-btn', 'preset');
initGridSelect('#voice-grid .format-btn', 'preset');
@@ -357,9 +412,11 @@
@@ -357,9 +412,11 @@
/* ---------- Process ---------- */
/* ---------- Process ---------- */
async function processAudio() {
async function processAudio() {
if (!uploadedFile || !currentTool) return;
if (!uploadedFile || !currentTool) return;
+ const files = isMerge() ? uploadedFiles.slice() : [uploadedFile];
+ if (isMerge() && files.length < 2) return;
const formData = new FormData();
const formData = new FormData();
- formData.append('file', uploadedFile);
+ for (const f of files) formData.append(isMerge() ? 'files' : 'file', f);
formData.append('tool', currentTool);
formData.append('tool', currentTool);
// Gather tool-specific options
// Gather tool-specific options
@@ -377,9 +434,9 @@
@@ -377,9 +434,9 @@
// Jobs: the work is accepted, followed live (ffmpeg's progress) and survives a reload.
// Jobs: the work is accepted, followed live (ffmpeg's progress) and survives a reload.
if (window.OVJobs) {
if (window.OVJobs) {
const input = {};
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 {
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);
OVJobs.remember(job.id);
followJob(job);
followJob(job);
} catch (err) {
} catch (err) {
@@ -391,7 +448,7 @@
@@ -391,7 +448,7 @@
// Without the jobs helper: the synchronous endpoint, as before.
// Without the jobs helper: the synchronous endpoint, as before.
try {
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();
const data = await res.json();
if (!res.ok) {
if (!res.ok) {
@@ -415,6 +472,12 @@
@@ -415,6 +472,12 @@
o.bitrate = $('#bitrate-slider').value;
o.bitrate = $('#bitrate-slider').value;
break;
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':
case 'trim':
o.start = $('#trim-start').value || '0';
o.start = $('#trim-start').value || '0';
o.end = $('#trim-end').value;
o.end = $('#trim-end').value;
@@ -19,7 +19,7 @@ const { optionalAuth } = auth;
@@ -19,7 +19,7 @@ const { optionalAuth } = auth;
const { resolveContext, DOMAIN_MAP } = require('./domain-map');
const { resolveContext, DOMAIN_MAP } = require('./domain-map');
const { getTool, listTools } = require('./tools');
const { getTool, listTools } = require('./tools');
const { readMetadata } = require('./tools/metadata');
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 { apiLimiter, processLimiter, burstLimiter } = require('./middleware/rate-limit');
const retention = require('./retention/manager');
const retention = require('./retention/manager');
const { probe, getDuration, cleanTmp } = require('./tools/ffmpeg-helper');
const { probe, getDuration, cleanTmp } = require('./tools/ffmpeg-helper');
@@ -173,6 +173,10 @@ app.post('/api/process', burstLimiter, processLimiter, uploadSingle, async (req,
@@ -173,6 +173,10 @@ app.post('/api/process', burstLimiter, processLimiter, uploadSingle, async (req,
cleanTmp(req.file.path);
cleanTmp(req.file.path);
return res.status(400).json({ error: `Unknown tool: ${toolId}` });
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)
// 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));
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,
@@ -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) ──────────────────────────────────────
// ── Jobs (/api/v1/jobs) ──────────────────────────────────────
// The same operation as /api/process, asynchronous and durable: accepted into data/jobs.db,
// 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
// followed over SSE (ffmpeg's own progress), cancellable (ffmpeg is killed), reattachable by id
@@ -208,7 +236,7 @@ const jobs = jobsRuntime.setupJobs({
@@ -208,7 +236,7 @@ const jobs = jobsRuntime.setupJobs({
app, service: 'audio', dataDir: path.resolve(__dirname, '..', config.dataDir), Database, contracts, sdk,
app, service: 'audio', dataDir: path.resolve(__dirname, '..', config.dataDir), Database, contracts, sdk,
getPublicKey: auth.getPublicKey, issuer: auth.ISSUER,
getPublicKey: auth.getPublicKey, issuer: auth.ISSUER,
define: defineJobs,
define: defineJobs,
- receive: uploadSingle,
+ receive: uploadAny,
limiters: [burstLimiter, processLimiter],
limiters: [burstLimiter, processLimiter],
defaults(req, input) {
defaults(req, input) {
const out = { ...input };
const out = { ...input };
@@ -78,4 +78,25 @@ function uploadMultiple(req, res, next) {
@@ -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 };
@@ -55,20 +55,26 @@ function describe(toolId, result, size, inputSize) {
@@ -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) {
function defineJobs(system) {
const known = new Set(listTools().map(t => t.id));
const known = new Set(listTools().map(t => t.id));
system.define({
system.define({
type: 'audio.process',
type: 'audio.process',
version: 1,
version: 1,
minFiles: 1,
minFiles: 1,
- maxFiles: 1,
+ maxFiles: Math.max(...listTools().map(t => t.maxFiles || 1)),
maxAttempts: 2,
maxAttempts: 2,
onRestart: 'requeue', // ffmpeg over the stored input: safe to run again
onRestart: 'requeue', // ffmpeg over the stored input: safe to run again
timeoutMs: 15 * 60 * 1000,
timeoutMs: 15 * 60 * 1000,
- validate(input) {
+ validate(input, files) {
const id = String(input.tool || 'convert');
const id = String(input.tool || 'convert');
if (!known.has(id)) return `Unknown tool: ${id}`;
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`;
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;
return null;
},
},
@@ -76,14 +82,16 @@ function defineJobs(system) {
@@ -76,14 +82,16 @@ function defineJobs(system) {
const toolId = String(input.tool || 'convert');
const toolId = String(input.tool || 'convert');
const tool = getTool(toolId);
const tool = getTool(toolId);
const src = files[0];
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'); }
if (signal.aborted) { fs.rm(result.outputPath, { force: true }, () => {}); throw new Error('cancelled'); }
const size = fs.statSync(result.outputPath).size;
const size = fs.statSync(result.outputPath).size;
const base = path.basename(src.name || 'audio', path.extname(src.name || '')) || 'openvibeaudio-output';
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 {
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 }) },
};
};
},
},
});
});
@@ -90,5 +90,6 @@ async function convert(inputPath, options = {}) {
@@ -90,5 +90,6 @@ async function convert(inputPath, options = {}) {
}
}
convert.formats = Object.keys(FORMAT_CONFIG);
convert.formats = Object.keys(FORMAT_CONFIG);
+convert.FORMAT_CONFIG = FORMAT_CONFIG; // merge encodes to the same formats
module.exports = convert;
module.exports = convert;
@@ -8,6 +8,7 @@
@@ -8,6 +8,7 @@
const convert = require('./convert');
const convert = require('./convert');
const trim = require('./trim');
const trim = require('./trim');
+const merge = require('./merge');
const pitch = require('./pitch');
const pitch = require('./pitch');
const speed = require('./speed');
const speed = require('./speed');
const reverse = require('./reverse');
const reverse = require('./reverse');
@@ -55,6 +56,18 @@ const TOOLS = {
@@ -55,6 +56,18 @@ const TOOLS = {
category: 'editing',
category: 'editing',
handler: trim,
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: {
pitch: {
id: 'pitch',
id: 'pitch',
label: 'Pitch',
label: 'Pitch',
@@ -282,6 +295,7 @@ function listTools() {
@@ -282,6 +295,7 @@ function listTools() {
return Object.values(TOOLS).map(t => ({
return Object.values(TOOLS).map(t => ({
id: t.id, label: t.label, description: t.description,
id: t.id, label: t.label, description: t.description,
faIcon: t.faIcon, accepts: t.accepts, category: t.category,
faIcon: t.faIcon, accepts: t.accepts, category: t.category,
+ ...(t.multiFile && { multiFile: true, minFiles: t.minFiles, maxFiles: t.maxFiles }),
}));
}));
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,72 @@
@@ -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;
new file mode 100644
new file mode 100644
@@ -0,0 +1,100 @@
@@ -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); });