UNPKG

audio

Version:

Audio loading, editing, and rendering for JavaScript

1,104 lines (997 loc) 45.4 kB
#!/usr/bin/env node /** * audio CLI — sox-style positional ops interface * * audio [input] [ops...] [--options] * * Examples: * audio in.mp3 gain -3db trim normalize -o out.wav * audio in.wav gain -3db 1s..10s -o out.wav * cat in.wav | audio gain -3db > out.wav */ import audio from '../audio.js' import { melSpectrum, toMel, fromMel } from '../fn/spectrum.js' import parseDuration from 'parse-duration' import fft from 'fourier-transform' // FIXME: why do we have so many dynamic imports here? They can be static, no? // ── Unit Parsing ───────────────────────────────────────────────────────── function parseValue(str) { if (str.includes('..')) return str // range syntax — handled separately // dB let m = str.match(/^(-?[\d.]+)(db)$/i) if (m) return Number(m[1]) // Hz / kHz m = str.match(/^(-?[\d.]+)(khz)$/i) if (m) return Number(m[1]) * 1000 m = str.match(/^(-?[\d.]+)(hz)$/i) if (m) return Number(m[1]) // bare number if (/^-?[\d.]+$/.test(str)) return Number(str) // comma-separated channel map: 0,1 or 1,0,null if (/^[\d,_]+$/.test(str) && str.includes(',')) { return str.split(',').map(s => s === '_' ? null : Number(s)) } // duration — supports compound expressions (1m30s, 2h20m, 500ms, etc.) let d = parseDuration(str, 's') if (d != null && isFinite(d)) return d return str // pass as-is (e.g. filename, op name) } function parseRange(str) { let [start, end] = str.split('..') let s = start ? parseValue(start) : 0 let e = end ? parseValue(end) : undefined let dur = e != null ? Math.max(0, e - s) : undefined return { offset: s, duration: dur } } /** Check if a string is a bare time value (e.g. "1s", "500ms", "2.5", "1:30") — not an op name. */ function isTime(s) { if (typeof s !== 'string') return false if (s.includes('..')) return true // range if (/^-?[\d.]+$/.test(s)) return false // bare number — ambiguous, don't treat as time if (/^(\d+):(\d{1,2})(?::(\d{1,2}))?(?:\.(\d+))?$/.test(s)) return true // timecode let d = parseDuration(s, 's') return d != null && isFinite(d) } // ── Argument Parsing ───────────────────────────────────────────────────── function isFlag(s) { if (s.startsWith('--')) return true if (!s.startsWith('-')) return false let match = s.match(/^-[\d.]+(db|hz|khz|s|ms)?$/i) return !match } function isOpName(s) { let op = audio.op(s) return (op && !op.hidden) || s === 'split' || s === 'stat' } // ── Per-op Help ────────────────────────────────────────────────────────── const HELP = { gain: { usage: 'gain DB [RANGE]', desc: 'Amplify in dB', examples: ['gain -3db', 'gain 6 1s..5s'] }, fade: { usage: 'fade [IN] [-OUT] [CURVE]', desc: 'Fade in/out (bare = 0.5s both)', examples: ['fade', 'fade 1s', 'fade .2s -1s cos'] }, trim: { usage: 'trim [THR]', desc: 'Auto-trim silence (threshold in dB)', examples: ['trim', 'trim -40'] }, normalize: { usage: 'normalize [DB] [MODE]', desc: 'Normalize peak/loudness', examples: ['normalize', 'normalize -3', 'normalize streaming'] }, crop: { usage: 'crop OFF DUR', desc: 'Crop to time range', examples: ['crop 1s..10s', 'crop 0 5s'] }, clip: { usage: 'clip OFF DUR', desc: 'Create a shared-page clip', examples: ['clip 1s..10s', 'clip 0 5s'] }, remove: { usage: 'remove OFF DUR', desc: 'Delete time range', examples: ['remove 2s..4s'] }, reverse: { usage: 'reverse [RANGE]', desc: 'Reverse audio', examples: ['reverse', 'reverse 1s..5s'] }, repeat: { usage: 'repeat N', desc: 'Repeat N times', examples: ['repeat 3'] }, pad: { usage: 'pad [BEFORE] [AFTER]', desc: 'Add silence to start/end (single arg = both)', examples: ['pad 1s', 'pad 0.5s 2s'] }, speed: { usage: 'speed RATE', desc: 'Change speed — 2 = double, 0.5 = half, -1 = reverse', examples: ['speed 2', 'speed 0.5', 'speed -1'] }, stretch: { usage: 'stretch FACTOR', desc: 'Time-stretch (same pitch) — 2 = 2× slower, 0.5 = 2× faster', examples: ['stretch 2', 'stretch 0.5', 'stretch 1.25'] }, pitch: { usage: 'pitch SEMI', desc: 'Pitch-shift in semitones (same duration)', examples: ['pitch 7', 'pitch -12', 'pitch 5'] }, insert: { usage: 'insert SRC [OFF]', desc: 'Insert audio at position', examples: ['insert other.wav 3s'] }, mix: { usage: 'mix SRC [OFF]', desc: 'Mix in another audio file', examples: ['mix bg.wav 0s'] }, remix: { usage: 'remix CH|MAP', desc: 'Change channel count or remap', examples: ['remix 1', 'remix 2', 'remix 1,0'] }, pan: { usage: 'pan VALUE [RANGE]', desc: 'Stereo balance: -1 left, 0 center, 1 right', examples: ['pan -0.5', 'pan 1 2s..5s'] }, filter: { usage: 'filter TYPE ...ARGS', desc: 'Generic filter dispatch', examples: ['filter highpass 80hz'] }, highpass: { usage: 'highpass FC [ORDER]', desc: 'High-pass filter', examples: ['highpass 80hz', 'highpass 120hz 4'] }, lowpass: { usage: 'lowpass FC [ORDER]', desc: 'Low-pass filter', examples: ['lowpass 8khz', 'lowpass 4khz 4'] }, eq: { usage: 'eq FC GAIN [Q]', desc: 'Parametric EQ', examples: ['eq 1khz -3db', 'eq 300hz 2 0.5'] }, lowshelf: { usage: 'lowshelf FC GAIN [Q]', desc: 'Low shelf filter', examples: ['lowshelf 200hz -3db'] }, highshelf: { usage: 'highshelf FC GAIN [Q]', desc: 'High shelf filter', examples: ['highshelf 8khz 2db'] }, notch: { usage: 'notch FC [Q]', desc: 'Notch (band-reject) filter', examples: ['notch 60hz', 'notch 50hz 50'] }, bandpass: { usage: 'bandpass FC [Q]', desc: 'Band-pass filter', examples: ['bandpass 1khz', 'bandpass 440hz 10'] }, } function showOpHelp(name) { let h = HELP[name] if (!h) { console.error(`No help for: ${name}`); return } console.log(`\n ${h.usage}\n\n ${h.desc}\n`) if (h.examples.length) console.log(' Examples:') for (let ex of h.examples) console.log(` audio in.wav ${ex} -o out.wav`) console.log() } function parseArgs(args) { let input = null, ops_ = [], output = null, format = null let verbose = false, showHelp = false, play = false, force = false, loop = false let macro = null, helpOp = null, concatFiles = [], range = null let i = 0 // First positional arg as input if it looks like a file if (args.length && !isFlag(args[0]) && !isOpName(args[0])) { input = args[i++] } // Process remaining args while (i < args.length) { let arg = args[i] if (arg === '--help' || arg === '-h') { showHelp = true i++ } else if (arg === '--verbose') { verbose = true i++ } else if (arg === '--output' || arg === '-o') { output = args[++i] i++ } else if (arg === '--format') { format = args[++i] i++ } else if (arg === '--play' || arg === '-p') { play = true i++ } else if (arg === '--force' || arg === '-f') { force = true i++ } else if (arg === '--loop' || arg === '-l') { loop = true i++ } else if (arg === '--macro') { macro = args[++i] i++ } else if (arg === '+') { // Concat: `audio a.mp3 + b.wav + c.mp3 ...` i++ if (i < args.length && !isFlag(args[i]) && !isOpName(args[i])) { concatFiles.push(args[i]) i++ } else { throw new Error('Expected file after +') } } else if (isFlag(arg)) { throw new Error(`Unknown flag: ${arg}`) } else if (typeof arg === 'string' && arg.includes('..') && !isOpName(arg)) { // Bare range: `audio song.mp3 10s..20s -p` range = parseRange(arg) i++ } else if (input && !isOpName(arg) && isTime(arg)) { // Bare time: `audio song.mp3 1s -p` = start at 1s range = { offset: parseValue(arg), duration: undefined } i++ } else if (!input && !isOpName(arg)) { // Positional input file (even after flags) input = arg i++ } else { // Parse operation let name = arg let opArgs = [] i++ // Collect args until next op or flag // For stat op, stat names (dc, clip, etc.) are args, not op boundaries while (i < args.length && !isFlag(args[i])) { if (isOpName(args[i]) && !(name === 'stat' && audio.stat(args[i]))) break opArgs.push(parseValue(args[i])) i++ } // Check for range syntax at end of args let offset = null, duration = null if (opArgs.length > 0 && typeof opArgs[opArgs.length - 1] === 'string' && opArgs[opArgs.length - 1].includes('..')) { let range = parseRange(opArgs.pop()) offset = range.offset duration = range.duration } // Per-op help: `gain --help` if (opArgs.length === 0 && i < args.length && (args[i] === '--help' || args[i] === '-h')) { helpOp = name; i++; continue } ops_.push({ name, args: opArgs, offset, duration }) } } // Expand fade shorthand: bare `fade` or `fade IN -OUT` → two fade ops let expanded = [] for (let op of ops_) { if (op.name === 'fade') { let nums = op.args.filter(a => typeof a === 'number') let curve = op.args.find(a => typeof a === 'string') if (nums.length === 0) { // bare `fade` → 0.5s in + 0.5s out expanded.push({ name: 'fade', args: [0.5], curve, offset: null, duration: null }) expanded.push({ name: 'fade', args: [-0.5], curve, offset: null, duration: null }) } else if (nums.length === 1 && nums[0] > 0) { // `fade 0.3` → both at 0.3s expanded.push({ name: 'fade', args: [nums[0]], curve, offset: null, duration: null }) expanded.push({ name: 'fade', args: [-nums[0]], curve, offset: null, duration: null }) } else if (nums.length === 2 && nums[0] > 0 && nums[1] < 0) { // `fade 0.2 -1` → in 0.2s, out 1s expanded.push({ name: 'fade', args: [nums[0]], curve, offset: null, duration: null }) expanded.push({ name: 'fade', args: [nums[1]], curve, offset: null, duration: null }) } else { expanded.push(op) } } else { expanded.push(op) } } ops_ = expanded return { input, ops: ops_, output, format, verbose, showHelp, play, force, loop, macro, helpOp, concatFiles, range } } // ── I/O ────────────────────────────────────────────────────────────────── async function getStdinBuffer() { return new Promise((resolve, reject) => { let chunks = [] let stdin = process.stdin stdin.on('data', chunk => chunks.push(chunk)) stdin.on('end', () => resolve(Buffer.concat(chunks))) stdin.on('error', reject) }) } function formatError(err) { return typeof err === 'string' ? err : err.message || String(err) } function fmtTime(s, full) { s = Math.max(0, s) let h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60), sec = Math.floor(s % 60) return full || h > 0 ? `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}` : `${m}:${String(sec).padStart(2, '0')}` } const STAT_UNITS = { db: 'dBFS', loudness: 'LUFS' } function fmtStat(name, result) { if (result instanceof Float32Array || Array.isArray(result)) { if (!result.length) { console.log(` ${name.padEnd(12)} none`); return } // Array result (spectrum, cepstrum, silence, clip, binned) if (result[0]?.at != null) { // Object array (silence regions) console.log(` ${name}:`) for (let r of result) console.log(` ${r.at.toFixed(3)}s ${r.duration.toFixed(3)}s`) } else { console.log(` ${name}:`) let pad = String(result.length - 1).length for (let i = 0; i < result.length; i++) console.log(` ${String(i).padStart(pad)} ${Number(result[i]).toFixed(4)}`) } } else { let unit = STAT_UNITS[name] || '' let val = typeof result === 'number' ? (Number.isFinite(result) ? result.toFixed(4) : '-Inf') : String(result) console.log(` ${name.padEnd(12)} ${val}${unit ? ' ' + unit : ''}`) } } function spinner(lbl) { let i = 0, info = '', t0 = Date.now(), spin = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏' let id = setInterval(() => process.stderr.write(`\r\x1b[K${spin[i++ % 10]} ${lbl}${info}`), 80) return { label(l) { lbl = l }, set(s) { info = s }, stop() { clearInterval(id); process.stderr.write('\r\x1b[K'); return ((Date.now() - t0) / 1000).toFixed(1) } } } const DIM = '\x1b[2m', RST = '\x1b[0m' function progressBar(played, decoded, total, width) { // When total unknown, add headroom so decoded doesn't fill to the end let ref = total > 0 ? total : decoded > 0 ? decoded + Math.max(decoded * 0.2, 2) : 1 let pFill = Math.round(played / ref * width) let dFill = Math.round(decoded / ref * width) pFill = Math.max(0, Math.min(width, pFill)) dFill = Math.max(pFill, Math.min(width, dFill)) let empty = width - dFill // Dim track for unknown remaining — keeps bar visually full-width return '━'.repeat(pFill) + '─'.repeat(dFill - pFill) + (empty > 0 ? DIM + '─'.repeat(empty) + RST : '') } async function playback(p, totalSec, decodedSec, a, src, opts) { let hasEdits = opts?.hasEdits ?? false let cols = () => process.stderr.columns || 80 let nLines = 1 const SPIN = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏' let spinIdx = 0 // Waveform peak cache (one peak per page, computed incrementally) // Braille centered bars: expand from middle of 2×4 dot grid const WAVE = '\u2800\u2824\u2836\u28B6\u28FE\u28FF' // ⠀ ⠤ ⠶ ⢶ ⣾ ⣿ let pagePeaks = [], peakMax = 0 let updatePagePeaks = () => { if (!a) return for (let i = pagePeaks.length; i < a.pages.length; i++) { let pg = a.pages[i] if (!pg) break let ch0 = pg[0], max = 0 for (let j = 0; j < ch0.length; j++) { let v = Math.abs(ch0[j]); if (v > max) max = v } pagePeaks.push(max) if (max > peakMax) peakMax = max } } let waveBar = (played, decoded, total, w) => { updatePagePeaks() if (!pagePeaks.length) return progressBar(played, decoded, total, w) let ref = total > 0 ? total : decoded > 0 ? decoded : 1 let pCol = Math.round(played / ref * w), dCol = Math.round(decoded / ref * w) pCol = Math.max(0, Math.min(w, pCol)); dCol = Math.max(pCol, Math.min(w, dCol)) let norm = peakMax > 1e-6 ? peakMax : 1 let pStr = '', dStr = '', eStr = '' for (let i = 0; i < w; i++) { let pi = Math.min(Math.floor(i / w * pagePeaks.length), pagePeaks.length - 1) let level = pi < pagePeaks.length ? Math.round(pagePeaks[pi] / norm * 8) : 0 let ch = '\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588'[Math.max(0, Math.min(7, level - 1))] || ' ' if (i < pCol) pStr += ch else if (i < dCol) dStr += ch else eStr += ' ' } return pStr + DIM + dStr + RST + eStr } // FFT spectrum state const N = 1024, SBARS = ' ▁▂▃▄▅▆▇█' let prev = null // Auto-scaling: track running peak dB for spectrum let specMax = -60 let spec = (block, sr, w, paused) => { if (!fft) return DIM + '▁'.repeat(w) + RST let fMin = 30, fMax = Math.min(sr / 2, 20000) if (block && block.length >= N) { let mag = melSpectrum(block.subarray(0, N), sr, { bins: w, fMin, fMax }) if (!prev || prev.length !== w) prev = new Float32Array(w) for (let b = 0; b < w; b++) prev[b] = paused ? mag[b] : Math.max(mag[b], prev[b] * 0.85) } else if (prev && !paused) { for (let b = 0; b < prev.length; b++) prev[b] *= 0.85 } if (!prev) return DIM + '▁'.repeat(w) + RST // Auto-scale: find current max dB, decay peak slowly let curMax = -100 let specDb = new Float32Array(w) for (let b = 0; b < w; b++) { specDb[b] = 20 * Math.log10(prev[b] + 1e-10) if (specDb[b] > curMax) curMax = specDb[b] } specMax = paused ? curMax : Math.max(curMax, specMax - 0.3) let floor = specMax - 48 // 48dB dynamic range, 6dB per level let levels = new Int8Array(w) for (let b = 0; b < w; b++) levels[b] = Math.round((specDb[b] - floor) / 6) // find active range (first..last bin with signal) let lo = 0, hi = w - 1 while (lo < w && levels[lo] <= 0) lo++ while (hi > lo && levels[hi] <= 0) hi-- let out = lo > 0 ? DIM + '▁'.repeat(lo) + RST : '' for (let b = lo; b <= hi; b++) out += SBARS[Math.max(1, Math.min(8, levels[b]))] let tail = w - 1 - hi if (tail > 0) out += DIM + '▁'.repeat(tail) + RST return out } let freqLabels = (sr, w) => { if (!fft || w < 20) return '' let fMax = Math.min(sr / 2, 20000), mMin = toMel(30), mMax = toMel(fMax) let marks = [[50,'50'],[100,'100'],[200,'200'],[500,'500'],[1000,'1k'],[2000,'2k'],[5000,'5k'],[10000,'10k']] let srLbl = fMax >= 1000 ? Math.round(fMax / 1000) + 'k' : Math.round(fMax) + '' let arr = new Array(w).fill(' ') // Max freq at right let srStart = w - srLbl.length if (srStart > 0) for (let i = 0; i < srLbl.length; i++) arr[srStart + i] = srLbl[i] // Freq marks for (let [f, lbl] of marks) { if (f > fMax) continue let pos = Math.round((toMel(f) - mMin) / (mMax - mMin) * (w - 1)) let start = pos - Math.floor(lbl.length / 2) if (start < 0) start = 0 if (start + lbl.length > srStart - 1) continue let ok = true for (let c = Math.max(0, start - 1); c < start + lbl.length + 1 && ok; c++) if (c < w && arr[c] !== ' ') ok = false if (!ok) continue for (let c = 0; c < lbl.length; c++) arr[start + c] = lbl[c] } return arr.join('') } const VOL = '▁▂▃▄▅▆▇' let volBar = v => { let n = Math.max(1, Math.min(7, Math.round(v * 7))) let tail = 7 - n return VOL.slice(0, n) + (tail ? DIM + '▁'.repeat(tail) + RST : '') } // File info (computed eagerly after decode, refreshed after ops) let fileInfo = null, msg = '', msgTimer = null // For edited audio that's already decoded, show basic info immediately if (hasEdits && a?.decoded) fileInfo = `${a.sampleRate >= 1000 ? Math.round(a.sampleRate / 1000) + 'k' : a.sampleRate} ${a.channels}ch ${fmtTime(a.duration)}` let flash = m => { msg = m; clearTimeout(msgTimer); msgTimer = setTimeout(() => { msg = ''; render(p.currentTime) }, 1500) } let fmtRate = sr => { let k = sr / 1000; return (k % 1 ? k.toFixed(1) : k) + 'k' } let refreshInfo = async () => { if (!a?.decoded) return try { let [peak, , l, clips, dcOff] = await a.stat(['db', 'rms', 'loudness', 'clipping', 'dc']) let warn = '' if (clips.length) warn += ` ${clips.length} clip${clips.length > 1 ? 's' : ''}` if (Math.abs(dcOff) > 0.001) warn += ` dc:${dcOff.toFixed(4)}` fileInfo = `${fmtRate(a.sampleRate)} ${a.channels}ch ${fmtTime(a.duration)} ${peak.toFixed(1)}dBFS ${l.toFixed(1)}LUFS${warn}` } catch { fileInfo = '(info unavailable)' } render(p.currentTime) } ;(async () => { if (!a) return if (!a.decoded) await new Promise(r => { let id = setInterval(() => { if (a.decoded) { clearInterval(id); r() } }, 200) }) await refreshInfo() })() let render = t => { let w = cols() let ts = totalSec?.() || 0, ds = decodedSec?.() ?? ts let icon = p.paused ? '▶' : '⏸' let ct = fmtTime(t, true), tt = ts > 0 ? '-' + fmtTime(ts - t, true) : '-0:00:00' let loop = p.loop ? '↻' : ' ' let vb = volBar(p.volume) let barStart = ct.length + 3 // icon + space + time + space let lpad = ' '.repeat(barStart) let pad = barStart + tt.length + 7 + 5 // 7 = vol visual width, +1 loop +1 space let barW = Math.max(10, w - pad) let bar = progressBar(t, ds, ts, barW) // Cursor at playback position in progress bar // Cursor position — same ref as progressBar so cursor stays within the bar's played region let cRef = ts > 0 ? ts : ds > 0 ? ds + Math.max(ds * 0.2, 2) : 1 let pFill = Math.round(Math.min(t / cRef, 1) * barW) let cursorCol = barStart + pFill let out = `\r\x1b[K${icon} ${ct} ${bar} ${tt} ${loop} ${vb}` let newLines = 1 if (fft) { let sw = barW let sr = p.sampleRate || 44100 let s = spec(getBlock(), sr, sw, p.paused) out += `\n\x1b[K${lpad}${s}` newLines++ let fl = freqLabels(sr, sw) if (fl.trim()) { out += `\n\x1b[K${lpad}${DIM}${fl.trimEnd()}${RST}`; newLines++ } } // Info line let decoding = '' if (a && !a.decoded) { updatePagePeaks() let peakDb = peakMax > 1e-10 ? (20 * Math.log10(peakMax)).toFixed(1) + ' dBFS' : '' decoding = ` ${peakDb ? peakDb + ' ' : ''}${SPIN[spinIdx++ % 10]} decoding` } else if (hasEdits && !p.block && !p.ended) { decoding = ` ${SPIN[spinIdx++ % 10]} processing` } let infoStr = msg || (fileInfo ? fileInfo + decoding : (a ? `${fmtRate(a.sampleRate)} ${a.channels}ch${decoding}` : '')) out += '\n\x1b[K'; newLines++ if (infoStr) { out += `\n\x1b[K ${DIM}${infoStr}${RST}`; newLines++ } for (let i = newLines; i < nLines; i++) out += '\n\x1b[K' let up = Math.max(newLines, nLines) - 1 if (up > 0) out += `\x1b[${up}A` out += `\x1b[${cursorCol + 1}G` nLines = newLines process.stderr.write(out) } let getBlock = () => { if (p.block) return p.block if (a?.pages?.[0]?.[0]) return a.pages[0][0].subarray(0, Math.min(1024, a.pages[0][0].length)) return null } render(0) let tick = setInterval(() => render(p.currentTime), 40) if (process.stdin.isTTY) { process.stdin.setRawMode(true) process.stdin.resume() process.stdin.on('data', async key => { let k = key.toString() if (k === ' ') { p.paused ? p.resume() : p.pause(); render(p.currentTime) } else if (k === '\x1b[1;2C' || k === '\x1b[1;5C' || k === '\x1bf') { let t = Math.max(0, p.currentTime + 60); p.seek(t); render(t) } else if (k === '\x1b[1;2D' || k === '\x1b[1;5D' || k === '\x1bb') { let t = Math.max(0, p.currentTime - 60); p.seek(t); render(t) } else if (k === '\x1b[C') { let t = Math.max(0, p.currentTime + 10); p.seek(t); render(t) } else if (k === '\x1b[D') { let t = Math.max(0, p.currentTime - 10); p.seek(t); render(t) } else if (k === '\x1b[A') { p.volume = Math.min(p.volume + 0.1, 1); render(p.currentTime) } else if (k === '\x1b[B') { p.volume = Math.max(p.volume - 0.1, 0); render(p.currentTime) } else if (k === 'l') { p.loop = !p.loop; render(p.currentTime) } else if (k === 'q' || k === '\x03') p.stop() }) } await new Promise(r => { p.on('ended', r) }) clearInterval(tick) if (process.stdin.isTTY) { process.stdin.setRawMode(false) process.stdin.removeAllListeners('data') } let out = '\r\x1b[K' for (let i = 1; i < nLines; i++) out += '\n\x1b[K' if (nLines > 1) out += `\x1b[${nLines - 1}A` process.stderr.write(out) } // ── Plugin Auto-Discovery ──────────────────────────────────────────────── async function discoverPlugins() { try { let { readdir } = await import('fs/promises') let { join } = await import('path') let { createRequire } = await import('module') let require = createRequire(import.meta.url) let nmDir = join(require.resolve('../package.json'), '..', 'node_modules') let entries try { entries = await readdir(nmDir) } catch { return } let plugins = entries.filter(n => n.startsWith('audio-') && !n.startsWith('audio-decode') && !n.startsWith('audio-type') && !n.startsWith('audio-lena') && !n.startsWith('audio-speaker') && !n.startsWith('audio-filter') && !n.startsWith('audio-encode')) for (let name of plugins) { try { let mod = await import(join(nmDir, name, 'index.js')) let plugin = mod.default || mod if (typeof plugin === 'function') audio.use(plugin) } catch {} } } catch {} } // ── Main ───────────────────────────────────────────────────────────────── async function main() { let args = process.argv.slice(2) if (!args.length || args[0] === '--help' || args[0] === '-h') { showUsage() process.exit(args.length ? 0 : 1) } if (args[0] === '--version' || args[0] === '-v' || args[0] === '-V') { console.log(`audio ${audio.version}`) process.exit(0) } // ── Shell Completions ────────────────────────────────────────────────── if (args[0] === '--completions') { let shell = args[1] if (shell === 'zsh') { console.log(`#compdef audio _audio() { local -a reply if (( CURRENT == 2 )); then _files return fi reply=(\${(f)"$(audio --completions-list "\${words[CURRENT-1]}" "\${words[CURRENT]}" 2>/dev/null)"}) if (( \${#reply} )); then compadd -Q -- \${reply} else _files fi } compdef _audio audio`) } else if (shell === 'bash') { console.log(`_audio() { local cur prev cur="\${COMP_WORDS[COMP_CWORD]}" prev="\${COMP_WORDS[COMP_CWORD-1]}" if [[ \$COMP_CWORD -eq 1 ]]; then COMPREPLY=($(compgen -f -- "$cur")) return fi local IFS=$'\\n' COMPREPLY=($(compgen -W "$(audio --completions-list "$prev" "$cur" 2>/dev/null)" -- "$cur")) [[ \${#COMPREPLY[@]} -eq 0 ]] && COMPREPLY=($(compgen -f -- "$cur")) } complete -o default -F _audio audio`) } else if (shell === 'fish') { console.log(`function __audio_needs_command test (count (commandline -cop)) -gt 1 end complete -c audio -n __audio_needs_command -f -a '(audio --completions-list (commandline -cop)[-1] (commandline -ct) 2>/dev/null)'`) } else { console.error('Usage: audio --completions <zsh|bash|fish>') console.error(' eval "$(audio --completions zsh)"') process.exit(1) } process.exit(0) } if (args[0] === '--completions-list') { await discoverPlugins() let prev = args[1] || '', cur = args[2] || '' let ops = Object.keys(HELP).concat('split', 'stat') let flags = ['--play', '--force', '--verbose', '--format', '--macro', '--help', '--version', '-o', '-p', '-f'] // Context-aware completions let out = [] if (prev === '-o' || prev === '--output') { // Need filename — return empty, shell falls back to file completion process.exit(0) } else if (prev === 'normalize') { out = ['streaming', 'podcast', 'broadcast', '-1', '-3', '-6'] } else if (prev === 'fade') { out = ['linear', 'exp', 'log', 'cos'] } else if (prev === 'speed') { out = ['0.5', '2', '-1', '0.25', '1.5'] } else if (prev === 'stretch') { out = ['0.5', '0.75', '1.25', '1.5', '2'] } else if (prev === 'pitch') { out = ['-12', '-7', '-5', '5', '7', '12'] } else if (prev === 'remix') { out = ['1', '2'] } else if (prev === 'stat') { out = ['db', 'rms', 'loudness', 'clipping', 'dc', 'silence', 'spectrum', 'cepstrum'] } else if (prev === 'gain') { out = ['-3db', '-6db', '-12db', '3db', '6db'] } else if (prev === 'highpass') { out = ['80hz', '120hz', '200hz', '400hz'] } else if (prev === 'lowpass') { out = ['4khz', '8khz', '12khz', '16khz'] } else if (prev === 'notch') { out = ['50hz', '60hz'] } else if (prev === 'eq') { out = ['100hz', '300hz', '1khz', '3khz', '8khz'] } else if (prev === 'lowshelf') { out = ['100hz', '200hz', '300hz'] } else if (prev === 'highshelf') { out = ['4khz', '8khz', '12khz'] } else if (prev === 'pan') { out = ['-1', '-0.5', '0', '0.5', '1'] } else if (prev === 'trim') { out = ['-20db', '-30db', '-40db', '-50db'] } else if (prev === '--format') { out = ['wav', 'mp3', 'flac', 'ogg', 'opus', 'aiff'] } else if (cur.startsWith('-')) { out = flags } else { out = ops } console.log(out.join('\n')) process.exit(0) } try { await discoverPlugins() let opts = parseArgs(args) if (opts.showHelp) { showUsage() process.exit(0) } // Per-op help if (opts.helpOp) { showOpHelp(opts.helpOp) process.exit(0) } // Load macro edits let macroOps = [] if (opts.macro) { let { readFileSync } = await import('fs') let raw = JSON.parse(readFileSync(opts.macro, 'utf-8')) let edits = Array.isArray(raw) ? raw : raw.edits || raw.ops if (!Array.isArray(edits)) throw new Error('Macro file must contain an array of edits') macroOps = edits.map(e => ({ name: e.type || e.name, args: e.args || [], offset: e.at ?? null, duration: e.duration ?? null })) } let allOps = [...opts.ops, ...macroOps] // Validate ops early — before any decode for (let op of allOps) { if (op.name !== 'split' && op.name !== 'stat' && !audio.op(op.name)) throw new Error(`Unknown operation: ${op.name}`) } // Resolve input(s) — support glob for batch processing let inputs = [] if (opts.input) { if (opts.input.includes('*') || opts.input.includes('?')) { let { readdirSync } = await import('fs') let { dirname, basename, join } = await import('path') let dir = dirname(opts.input), pat = basename(opts.input) let re = new RegExp('^' + pat.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$') inputs = readdirSync(dir).filter(f => re.test(f)).map(f => join(dir, f)).sort() if (!inputs.length) throw new Error(`No files matching: ${opts.input}`) } else { inputs.push(opts.input) } } // Batch mode: multiple inputs if (inputs.length > 1) { let { basename, extname, dirname, join } = await import('path') let { existsSync } = await import('fs') for (let file of inputs) { let ext = extname(file), name = basename(file, ext) let outFile = opts.output ? opts.output.replace('{name}', name).replace('{ext}', ext.slice(1)) : join(dirname(file), name + '.out' + ext) if (!opts.force && existsSync(outFile)) { process.stderr.write(`audio: ${outFile} already exists (use --force to overwrite)\n`) process.exit(1) } process.stderr.write(`Processing: ${file}\n`) let a = await audio(file) for (let op of allOps) { let fullArgs = op.args.slice() let rangeOpts = {} if (op.offset != null) rangeOpts.at = op.offset if (op.duration != null) rangeOpts.duration = op.duration if (op.curve) rangeOpts.curve = op.curve if (Object.keys(rangeOpts).length) fullArgs.push(rangeOpts) if (typeof a[op.name] !== 'function') throw new Error(`Unknown operation: ${op.name}`) a[op.name](...fullArgs) } await a.save(outFile) process.stderr.write(` → ${outFile}\n`) } process.exit(0) } // Determine input source let source if (inputs.length) { source = inputs[0] } else { process.stderr.write('Reading from stdin...\n') let buf = await getStdinBuffer() source = buf } // Concat mode: `audio a.mp3 + b.wav + c.mp3` if (opts.concatFiles.length && typeof source === 'string') { source = [source, ...opts.concatFiles] } // Streaming player — file source, no ops, no output (default mode) // -p = autoplay, otherwise starts paused // Bare range: `audio song.mp3 10s..20s` scopes playback (seek + stop), not clip if (!allOps.length && !opts.output && typeof source === 'string') { if (opts.verbose) console.error(`Opening: ${source}`) let a = audio(source) await new Promise(r => a.on('metadata', r)) let playOpts = { paused: !opts.play, loop: opts.loop } if (opts.range) { playOpts.at = opts.range.offset; playOpts.duration = opts.range.duration } let p = a.play(playOpts) await playback(p, () => a.decoded ? a.duration : a._.estDur || 0, () => a.pages.length * audio.PAGE_SIZE / a.sampleRate, a, source, {} ) process.exit(0) } // Check if all ops are process-only (can stream during decode) let canStream = allOps.length > 0 && !opts.output if (canStream) for (let op of allOps) { if (op.name === 'stat' || op.name === 'split') { canStream = false; break } let desc = audio.op(op.name) if (!desc || desc.plan || desc.resolve) { canStream = false; break } } // Streaming player with process ops — show player immediately, start playback during decode if (canStream && typeof source === 'string') { if (opts.verbose) console.error(`Opening: ${source}`) let a = audio(source) await new Promise(r => a.on('metadata', r)) // Apply ops (just pushes edits — sync, no computation) for (let op of allOps) { let { name, args, offset: off, duration: dur, curve } = op let fullArgs = args.slice() let rangeOpts = {} if (off != null) rangeOpts.at = off if (dur != null) rangeOpts.duration = dur if (curve) rangeOpts.curve = curve if (Object.keys(rangeOpts).length) fullArgs.push(rangeOpts) if (name === 'clip') a = a[name](...fullArgs) else a[name](...fullArgs) } let playOpts = { paused: !opts.play, loop: opts.loop } if (opts.range) { playOpts.at = opts.range.offset; playOpts.duration = opts.range.duration } let p = a.play(playOpts) await playback(p, () => a.decoded ? a.duration : a._.estDur || 0, () => a.pages.length * audio.PAGE_SIZE / a.sampleRate, a, source, { hasEdits: true } ) process.exit(0) } // Separate stat ops from transform ops let statOps = allOps.filter(op => op.name === 'stat') let transformOps = allOps.filter(op => op.name !== 'stat') // Full-decode playback: show player immediately, decode + apply ops in background // Only when: play-only (no save), no stat, no clip (clip creates new instance), file source let wantsPlay = !opts.output && (opts.play || transformOps.length) let hasClip = transformOps.some(o => o.name === 'clip') if (wantsPlay && !statOps.length && !hasClip && typeof source === 'string') { let a = audio(source) await new Promise(r => a.on('metadata', r)) let playOpts = { paused: true, loop: opts.loop } if (opts.range) { playOpts.at = opts.range.offset; playOpts.duration = opts.range.duration } let p = a.play(playOpts) // Decode + apply ops in background, then resume ;(async () => { await a // full decode for (let op of transformOps) { let { name, args, offset, duration, curve } = op let fullArgs = args.slice() let rangeOpts = {} if (offset != null) rangeOpts.at = offset if (duration != null) rangeOpts.duration = duration if (curve) rangeOpts.curve = curve if (Object.keys(rangeOpts).length) fullArgs.push(rangeOpts) if (name === 'clip') a = a[name](...fullArgs) else a[name](...fullArgs) } if (opts.play) p.resume() })() await playback(p, () => a.decoded ? a.duration : a._.estDur || 0, () => a.pages.length * audio.PAGE_SIZE / a.sampleRate, a, source, { hasEdits: !!transformOps.length } ) process.exit(0) } // Load audio (full decode) — needed for stat, save, non-play paths if (opts.verbose) console.error(`Loading: ${typeof source === 'string' ? source : '(stdin)'}`) let spin = !opts.verbose ? spinner('decoding') : null let a = audio(source) if (opts.verbose) a.on('data', ({ offset }) => process.stderr.write(`\rDecoding... ${fmtTime(offset)}`)) await a let loadTime = spin?.stop() if (opts.verbose) console.error('\n') // No ops, no output, no play → show info and exit if (!allOps.length && !opts.output && !opts.play) { let [peak, , l, clips, dcOff] = await a.stat(['db', 'rms', 'loudness', 'clipping', 'dc']) console.log(` Duration: ${fmtTime(a.duration)}`) console.log(` Channels: ${a.channels}`) console.log(` SampleRate: ${a.sampleRate} Hz`) console.log(` Samples: ${a.length}`) console.log(` Peak: ${peak.toFixed(1)} dBFS`) console.log(` Loudness: ${l.toFixed(1)} LUFS`) console.log(` Clipping: ${clips.length || 'none'}`) console.log(` DC offset: ${Math.abs(dcOff) > 0.0001 ? dcOff.toFixed(4) : 'none'}`) if (loadTime) console.log(` Loaded in: ${loadTime}s`) process.exit(0) } // Split — special handling for multi-output let splitOp = allOps.find(op => op.name === 'split') if (splitOp) { let preOps = allOps.slice(0, allOps.indexOf(splitOp)) let postOps = allOps.slice(allOps.indexOf(splitOp) + 1) for (let op of preOps) { let fullArgs = op.args.slice() let rangeOpts = {} if (op.offset != null) rangeOpts.at = op.offset if (op.duration != null) rangeOpts.duration = op.duration if (op.curve) rangeOpts.curve = op.curve if (Object.keys(rangeOpts).length) fullArgs.push(rangeOpts) a[op.name](...fullArgs) } let parts = a.split(...splitOp.args) for (let op of postOps) for (let part of parts) { let fullArgs = op.args.slice() let rangeOpts = {} if (op.offset != null) rangeOpts.at = op.offset if (op.duration != null) rangeOpts.duration = op.duration if (op.curve) rangeOpts.curve = op.curve if (Object.keys(rangeOpts).length) fullArgs.push(rangeOpts) part[op.name](...fullArgs) } let { basename, extname } = await import('path') let output = opts.output || `split-{i}.wav` let srcExt = typeof source === 'string' ? extname(source) : '.wav' let srcName = typeof source === 'string' ? basename(source, srcExt) : 'audio' for (let [i, part] of parts.entries()) { let outFile = output .replace('{i}', String(i + 1)) .replace('{name}', srcName) .replace('{ext}', srcExt.slice(1)) let fmt = opts.format || outFile.split('.').pop() await part.save(outFile, { format: fmt }) process.stderr.write(` → ${outFile}\n`) } process.exit(0) } // Apply transform operations if (transformOps.length) { if (opts.verbose) console.error(`Applying ${transformOps.length} operation(s)...`) for (let op of transformOps) { let { name, args, offset, duration, curve } = op let fullArgs = args.slice() let rangeOpts = {} if (offset != null) rangeOpts.at = offset if (duration != null) rangeOpts.duration = duration if (curve) rangeOpts.curve = curve if (Object.keys(rangeOpts).length) fullArgs.push(rangeOpts) // clip returns a new audio instance, so we gotta update `a` if (name === 'clip') { if (typeof a[name] !== 'function') throw new Error(`Unknown operation: ${name}`) a = a[name](...fullArgs) } else { if (typeof a[name] !== 'function') throw new Error(`Unknown operation: ${name}`) try { a[name](...fullArgs) } catch (e) { throw new Error(`${name}: ${formatError(e)}`) } } } } // Execute stat queries if (statOps.length) { for (let op of statOps) { let names = op.args.filter(a => typeof a === 'string') if (!names.length) names = ['db', 'rms', 'loudness', 'clipping', 'dc'] for (let name of names) { let idx = op.args.indexOf(name) let bins = idx >= 0 && idx + 1 < op.args.length && typeof op.args[idx + 1] === 'number' ? op.args[idx + 1] : undefined let statOpts = {} if (bins != null) statOpts.bins = bins if (op.offset != null) statOpts.at = op.offset if (op.duration != null) statOpts.duration = op.duration let result = await a.stat(name, Object.keys(statOpts).length ? statOpts : undefined) fmtStat(name, result) } } if (!transformOps.length && !opts.output && !opts.play) process.exit(0) } // Play the result: -p flag, or ops without -o (default to player) if (opts.play || (transformOps.length && !opts.output)) { let playOpts = { loop: opts.loop } if (opts.range) { playOpts.at = opts.range.offset; playOpts.duration = opts.range.duration } await playback(a.play(playOpts), () => a.duration, () => a.duration, a, typeof source === 'string' ? source : null, { hasEdits: !!transformOps.length }) if (!opts.output) process.exit(0) } // Save output if (opts.output) { let output = opts.output || 'out.wav' // Check for overwrite if (!opts.force && output !== '-') { let { existsSync } = await import('fs') if (existsSync(output)) { process.stderr.write(`audio: ${output} already exists (use --force to overwrite)\n`) process.exit(1) } } let fmt = opts.format || (typeof output === 'string' ? output.split('.').pop() : 'wav') try { if (output === '-') { let bytes = await a.read({ format: fmt }) process.stdout.write(Buffer.from(bytes)) } else { let spin = !opts.verbose ? spinner(allOps.length ? 'Processing' : 'Saving') : null await new Promise(r => setTimeout(r, 100)) // let spinner render before blocking render() if (spin) a.on('progress', ({ offset, total }) => spin.set(' ' + Math.round(offset / total * 100) + '%')) await a.save(output, { format: fmt }) let elapsed = spin?.stop() if (opts.verbose) console.error(`Saved: ${output}`) else if (elapsed) console.error(`Saved ${output} in ${elapsed}s`) } } catch (e) { throw new Error(`save ${output}: ${formatError(e)}`) } } } catch (err) { console.error(`audio: ${formatError(err)}`) process.exit(1) } } const FILTERS = new Set(['highpass', 'lowpass', 'eq', 'lowshelf', 'highshelf', 'notch', 'bandpass', 'filter']) function showUsage() { let ops = [], filters = [] for (let [name, h] of Object.entries(HELP)) { let line = ` ${h.usage.padEnd(28)} ${h.desc}` ;(FILTERS.has(name) ? filters : ops).push(line) } console.log(` audio ${audio.version} — load, edit, save, play, analyze Usage: audio [input] [range] [ops...] [-o output] [options] Input: input File path, URL, or omit for stdin range Bare range scopes playback: audio song.mp3 10s..20s -o, --output Output file or '-' for stdout (default: out.wav) Operations (positional): ${ops.join('\n')} Filters (ORDER = steepness: 2 = -12dB/oct, 4 = -24dB/oct, default: 2): ${filters.join('\n')} Range syntax (for offset+duration): 1s..10s From 1s to 10s 0..0.5s First half second -1s.. Last second to end 5s Just offset (no duration) Units: Seconds: 1.5s, 500ms, 1.5 (default) dB: -3db, 0, 6db Hz: 440hz, 2khz Options: --play, -p Autoplay (default opens player paused) --loop, -l Loop playback (or loop within range) --force, -f Overwrite output file if it exists --macro FILE Apply edits from JSON file --verbose Show progress and debug info --format FMT Override output format (default: from extension) --help, -h Show this help (or after an op: audio gain --help) --version, -v Show version --completions SHELL Print tab-completion script (zsh, bash, fish) Batch: audio '*.wav' gain -3db -o '{name}.out.{ext}' Process multiple files Examples: audio in.mp3 Open player audio in.mp3 -p Autoplay audio in.mp3 10s..20s Play range audio in.mp3 10s..20s fade 1s 1s -p Play range with effects audio in.mp3 stat Show file stats audio in.mp3 stat loudness rms Specific stats audio in.mp3 gain -3db trim -o out.wav Edit and save audio in.mp3 normalize streaming -o out.wav audio in.mp3 highpass 80hz eq 300hz -2db lowshelf 200hz -3db -o out.wav audio in.mp3 gain -3db -p -o out.wav Edit, play, and save cat in.wav | audio gain -3db > out.wav Pipe mode Player controls: space Pause / resume ←/→ Seek ±10s ⇧←/⇧→ Seek ±60s ↑/↓ Volume ±10% l Toggle loop q Quit For more info: https://github.com/audiojs/audio `) } // Exports for testing export { parseValue, parseRange, parseArgs, showOpHelp, HELP, progressBar, fmtTime } // Run CLI if invoked directly (not imported) let argv1 = process.argv[1] try { argv1 = (await import('fs')).realpathSync(argv1) } catch {} if (import.meta.url === `file://${argv1}`) { main().catch(err => { console.error(`audio: ${formatError(err)}`) process.exit(1) }) }