SaifuddinHanif/ai-music-engineer
0
1#!/usr/bin/env node2/**3 * AI Music Engineer — CLI4 * Usage:5 * node src/cli.js ingest <files...> [options]6 * node src/cli.js analyze <files...>7 * node src/cli.js pipeline <files...> [options]8 * node src/cli.js status <projectId>9 * node src/cli.js report <projectId>10 * node src/cli.js projects11 */12 13"use strict";14 15const path = require("path");16const fs = require("fs");17const { Orchestrator } = require("./index");18const AnalyzeModule = require("./modules/analyze");19const Project = require("./core/project");20const config = require("../config/default");21 22// ─── ANSI colours ─────────────────────────────────────────────────────────────23const C = {24 reset: "\x1b[0m",25 bold: "\x1b[1m",26 dim: "\x1b[2m",27 green: "\x1b[32m",28 yellow: "\x1b[33m",29 red: "\x1b[31m",30 cyan: "\x1b[36m",31 blue: "\x1b[34m",32 white: "\x1b[37m",33 gray: "\x1b[90m"34};35const c = (col, str) => `${C[col]}${str}${C.reset}`;36const hr = () => console.log(c("gray", "─".repeat(72)));37 38// ─── Argument parsing ─────────────────────────────────────────────────────────39function parseArgs(argv) {40 const args = { files: [], flags: {}, opts: {} };41 for (let i = 0; i < argv.length; i++) {42 const a = argv[i];43 if (a.startsWith("--")) {44 const [key, val] = a.slice(2).split("=");45 args.opts[key] = val !== undefined ? val : argv[i + 1] && !argv[i+1].startsWith("--") ? argv[++i] : true;46 } else if (a.startsWith("-")) {47 args.flags[a.slice(1)] = true;48 } else {49 args.files.push(a);50 }51 }52 return args;53}54 55// ─── Banner ───────────────────────────────────────────────────────────────────56function banner() {57 console.log(c("cyan", C.bold + `58 ╔═══════════════════════════════════════════╗59 ║ AI Music Engineer — MVP v1.0 ║60 ║ Ingest → Analyze → Mix → Master → Deliver║61 ╚═══════════════════════════════════════════╝` + C.reset));62 console.log();63}64 65// ─── Progress bar ─────────────────────────────────────────────────────────────66function progressBar(done, total, width = 40) {67 const pct = total > 0 ? done / total : 0;68 const fill = Math.round(pct * width);69 const bar = "█".repeat(fill) + "░".repeat(width - fill);70 return `[${bar}] ${done}/${total} (${Math.round(pct * 100)}%)`;71}72 73// ─── Main commands ────────────────────────────────────────────────────────────74 75async function cmdAnalyze(files) {76 if (!files.length) { console.error(c("red", "Usage: analyze <file1> [file2] ...")); process.exit(1); }77 78 banner();79 console.log(c("cyan", `Analyzing ${files.length} file(s)...`));80 hr();81 82 const analyzer = new AnalyzeModule();83 for (const fp of files) {84 const abs = path.resolve(fp);85 if (!fs.existsSync(abs)) { console.error(c("red", `File not found: ${fp}`)); continue; }86 87 console.log(c("bold", `\n▶ ${path.basename(fp)}`));88 try {89 const report = await analyzer.analyzeTrack(abs, { name: path.basename(fp) });90 printAnalysisReport(report);91 } catch (e) {92 console.error(c("red", ` Error: ${e.message}`));93 }94 }95 hr();96}97 98function printAnalysisReport(report) {99 const r = report.results || {};100 101 console.log(` ${c("gray","Format:")} ${r.format?.toUpperCase() || "?"} | ${r.sampleRate}Hz | ${r.bitDepth || "?"}bit | ${r.channels === 1 ? "Mono" : "Stereo"}`);102 if (r.duration) {103 const m = Math.floor(r.duration / 60), s = Math.round(r.duration % 60);104 console.log(` ${c("gray","Duration:")} ${m}:${String(s).padStart(2,"0")}`);105 }106 if (r.lufs_integrated !== undefined) {107 const lufsOk = r.lufs_integrated <= -9;108 console.log(` ${c("gray","Loudness:")} ${lufsOk ? c("green","✓") : c("yellow","⚠")} ${r.lufs_integrated?.toFixed(1)} LUFS integrated | True peak: ${r.true_peak_dbfs?.toFixed(1)} dBTP`);109 }110 if (r.peak_dbfs !== undefined) {111 const pkOk = r.peak_dbfs < -0.5;112 console.log(` ${c("gray","Peak/RMS:")} ${pkOk ? c("green","✓") : c("red","✗")} ${r.peak_dbfs?.toFixed(2)} dBFS peak | ${r.mean_rms_dbfs?.toFixed(1)} dBFS RMS`);113 }114 if (r.phase_correlation !== undefined) {115 const phOk = r.phase_correlation > 0.3;116 console.log(` ${c("gray","Stereo:")} ${phOk ? c("green","✓") : c("yellow","⚠")} Phase corr: ${r.phase_correlation?.toFixed(2)} | Width est: ${r.stereo_width_estimate?.toFixed(2)}`);117 }118 if (r.spectrum) {119 const s = r.spectrum;120 console.log(` ${c("gray","Spectrum:")} Low ${s.low_db?.toFixed(1)}dB | Mid ${s.mid_db?.toFixed(1)}dB | High ${s.high_db?.toFixed(1)}dB`);121 }122 if (report.issues?.length) {123 console.log(` ${c("gray","Issues:")} (${report.issues.length})`);124 for (const issue of report.issues) {125 const icon = issue.severity === "error" ? c("red","✗") : issue.severity === "warning" ? c("yellow","⚠") : c("gray","·");126 console.log(` ${icon} ${issue.message}`);127 }128 } else {129 console.log(` ${c("green","✓ No issues detected")}`);130 }131 if (report.suggestedFixes?.length) {132 console.log(` ${c("cyan","Suggested fixes:")}`);133 for (const fix of report.suggestedFixes) {134 console.log(` ${c("cyan","→")} ${fix.fix}`);135 }136 }137}138 139async function cmdIngest(files, opts) {140 if (!files.length) { console.error(c("red", "Usage: ingest <files...> [--artist NAME] [--title TITLE]")); process.exit(1); }141 142 banner();143 const orch = new Orchestrator();144 145 const project = orch.createProject({146 artist: opts.artist || "Unknown Artist",147 title: opts.title || path.basename(files[0], path.extname(files[0])),148 genre: opts.genre || null,149 bpm: opts.bpm ? parseInt(opts.bpm) : null,150 notes: opts.notes || null,151 destinations: opts.dest ? opts.dest.split(",") : ["streaming"],152 options: {153 stemSeparation: opts.separate === "true" || opts.separate === "1",154 stemPreset: opts.stems || "4stem",155 autonomyLevel: parseInt(opts.autonomy || "2")156 }157 });158 159 orch.addFiles(project, files);160 161 console.log(c("cyan", `Project: ${project.id}`));162 console.log(c("cyan", `"${project.title}" by ${project.artist}`));163 hr();164 console.log(c("bold", "Running Ingest stage..."));165 166 const result = await orch.runStage(project, "INGEST");167 const ingestResult = result.stages?.INGEST?.result || {};168 169 console.log(c("green", `\n✓ Ingested ${ingestResult.filesProcessed || 0} file(s)`));170 if (ingestResult.issues?.length) {171 for (const issue of ingestResult.issues) {172 console.log(c("yellow", ` ⚠ ${issue.message || issue}`));173 }174 }175 console.log(`\n Project saved to: ${c("cyan", project.projectDir)}`);176 console.log(` Project ID: ${c("bold", project.id)}`);177 hr();178 return project;179}180 181async function cmdPipeline(files, opts) {182 if (!files.length) { console.error(c("red", "Usage: pipeline <files...> [--artist NAME] [--title TITLE] ...")); process.exit(1); }183 184 banner();185 const orch = new Orchestrator();186 187 const project = orch.createProject({188 artist: opts.artist || "Unknown Artist",189 title: opts.title || path.basename(files[0], path.extname(files[0])),190 genre: opts.genre || null,191 album: opts.album || null,192 bpm: opts.bpm ? parseInt(opts.bpm) : null,193 key: opts.key || null,194 notes: opts.notes || null,195 references: opts.refs ? opts.refs.split(",").map(r => r.trim()).filter(Boolean) : [],196 trackSequence: opts.sequence197 ? opts.sequence.split(",").map((t, i) => ({ title: t.trim(), gapMs: 2000, trackNumber: i + 1 }))198 : [],199 destinations: opts.dest ? opts.dest.split(",") : ["streaming"],200 sampleRate: parseInt(opts.sr || "44100"),201 bitDepth: parseInt(opts.bd || "24"),202 options: {203 stemSeparation: opts.separate === "true" || opts.separate === "1",204 stemPreset: opts.stems || "4stem",205 autonomyLevel: parseInt(opts.autonomy || "2"),206 fadeInMs: parseInt(opts.fadein || "50"),207 fadeOutMs: parseInt(opts.fadeout || "2000"),208 changeBudget: { eq_max_db: parseFloat(opts.eqbudget || "6") },209 lockedTracks: opts.locked ? opts.locked.split(",").map(s => s.trim()) : []210 }211 });212 213 orch.addFiles(project, files);214 if (opts.refs) console.log(c("cyan", `Reference tracks: ${opts.refs}`));215 216 console.log(c("cyan", `Project: ${project.id}`));217 console.log(c("cyan", `"${project.title}" by ${project.artist}`));218 console.log(c("gray", `Autonomy: L${project.options.autonomyLevel} | Destinations: ${project.destinations.join(", ")}`));219 hr();220 221 // Track stage timing222 const stageTimings = {};223 224 const result = await orch.run(project, {225 fromStage: opts.from || "INGEST",226 autonomyLevel: parseInt(opts.autonomy || "2"),227 onEvent: (event, data) => {228 switch (event) {229 case "stage_start":230 stageTimings[data.stage] = Date.now();231 process.stdout.write(`\n ${c("blue","▶")} ${c("bold", data.stage.padEnd(14))} `);232 break;233 case "stage_complete":234 const dur = stageTimings[data.stage] ? `${((Date.now() - stageTimings[data.stage]) / 1000).toFixed(1)}s` : "";235 console.log(c("green", `✓`) + c("gray", ` ${dur}`));236 if (data.result?.summary) console.log(` ${c("gray", data.result.summary)}`);237 break;238 case "stage_failed":239 console.log(c("red", `✗ FAILED: ${data.error}`));240 break;241 case "stage_retry":242 console.log(c("yellow", `↺ Retry ${data.attempt}`));243 break;244 case "escalation":245 console.log(c("red", `\n 🚨 ${data.message}`));246 if (data.userChoices) data.userChoices.forEach(ch => console.log(c("gray", ` → ${ch}`)));247 if (data.fallback) console.log(c("cyan", ` ↪ Fallback: ${data.fallback}`));248 break;249 case "missing_info":250 console.log(c("yellow", `\n ⚠ Missing info — proceeding with safe defaults:`));251 (data.questions||[]).forEach(q => console.log(c("gray", ` ? ${q.question}\n Default: ${q.default}`)));252 break;253 case "approval_required":254 console.log(c("yellow", `\n ⏸ Approval required for ${data.stage}`));255 if (data.summary?.plainLanguageSummary) console.log(c("gray", ` ${data.summary.plainLanguageSummary}`));256 console.log(c("gray", " (L2 mode — auto-proceeding)"));257 break;258 }259 }260 });261 262 console.log();263 hr();264 printPipelineSummary(result.summary, project);265 hr();266 console.log(`\n Project dir: ${c("cyan", project.projectDir)}`);267 console.log(` Project ID: ${c("bold", project.id)}`);268 console.log();269}270 271function printPipelineSummary(summary, project) {272 if (!summary) return;273 const { progress, stages } = summary;274 275 console.log(c("bold", "\n Pipeline Summary"));276 console.log(` ${progressBar(progress.done, progress.total)}\n`);277 278 const stageNames = Object.keys(stages);279 for (const stage of stageNames) {280 const s = stages[stage];281 const icon = s.status === "complete" ? c("green","✓")282 : s.status === "skipped" ? c("gray","–")283 : s.status === "failed" ? c("red","✗")284 : c("yellow","?");285 const dur = s.duration ? c("gray", ` (${s.duration})`) : "";286 const retry = s.retries > 0 ? c("yellow", ` [${s.retries} retries]`) : "";287 console.log(` ${icon} ${stage.padEnd(14)} ${s.status.padEnd(10)}${dur}${retry}`);288 }289 290 // Show output files291 if (project.outputs?.length) {292 console.log(`\n ${c("bold","Output files:")} (${project.outputs.length})`);293 for (const out of project.outputs) {294 console.log(` ${c("gray","·")} [${out.stage}] ${out.filename}`);295 if (out.lufs) console.log(` ${c("gray", `${out.lufs?.toFixed(1)} LUFS | ${out.truePeak?.toFixed(1)} dBTP`)}`);296 }297 }298}299 300async function cmdStatus(projectId) {301 const orch = new Orchestrator();302 const projectsRoot = config.paths.projects;303 const projectDir = path.join(projectsRoot, projectId);304 305 if (!fs.existsSync(path.join(projectDir, "project.json"))) {306 console.error(c("red", `Project not found: ${projectId}`));307 process.exit(1);308 }309 310 const project = orch.loadProject(projectDir);311 banner();312 console.log(c("bold", `Project: ${project.title} by ${project.artist}`));313 console.log(c("gray", `ID: ${project.id} | Created: ${project.createdAt}`));314 hr();315 console.log(c("bold", "Tracks:"));316 for (const t of project.tracks) {317 console.log(` ${c("gray","·")} [${t.role}] ${t.name} (${t.format?.toUpperCase() || "?"})`);318 }319 hr();320 console.log(c("bold", "QC Gates:"));321 for (const [gate, result] of Object.entries(project.qcResults || {})) {322 const icon = result.passed ? c("green","✓") : c("red","✗");323 console.log(` ${icon} ${gate.toUpperCase()}: ${result.passed ? "PASSED" : "FAILED"}`);324 if (result.issues?.length) {325 for (const i of result.issues.slice(0,3)) {326 console.log(` ${c("gray","·")} ${i.message || i}`);327 }328 }329 }330 hr();331 console.log(c("bold", "Outputs:"));332 for (const out of project.outputs || []) {333 console.log(` ${c("cyan","·")} ${out.filename}`);334 }335}336 337async function cmdReport(projectId) {338 const orch = new Orchestrator();339 const projectsRoot = config.paths.projects;340 const projectDir = path.join(projectsRoot, projectId);341 const project = orch.loadProject(projectDir);342 343 banner();344 if (project.analysisReport) {345 console.log(AnalyzeModule.formatReport(project.analysisReport));346 } else {347 console.log(c("yellow", "No analysis report found. Run the analyze stage first."));348 }349}350 351async function cmdProjects() {352 const orch = new Orchestrator();353 const projects = orch.listProjects();354 banner();355 if (!projects.length) {356 console.log(c("gray", "No projects found."));357 return;358 }359 console.log(c("bold", `Projects (${projects.length}):`));360 hr();361 for (const p of projects) {362 console.log(` ${c("cyan", p.id)}`);363 console.log(` "${p.title}" by ${p.artist}`);364 console.log(` ${c("gray", `Created: ${p.createdAt} | Tracks: ${p.tracks.length} | Outputs: ${p.outputs.length}`)}`);365 }366}367 368function printHelp() {369 banner();370 console.log(c("bold", "Commands:"));371 console.log(`372 ${c("cyan","analyze")} <file(s)> Analyze audio files — no project created373 ${c("cyan","ingest")} <file(s)> [opts] Ingest files into a new project374 ${c("cyan","pipeline")} <file(s)> [opts] Run full pipeline on files375 ${c("cyan","status")} <projectId> Show project status376 ${c("cyan","report")} <projectId> Show analysis report377 ${c("cyan","projects")} List all projects378 379${c("bold","Options for ingest/pipeline:")}380 --artist "Name" Artist name381 --title "Title" Track/project title382 --album "Album" Album / EP name383 --genre "Genre" Genre (optional)384 --key "C major" Musical key (optional)385 --bpm 120 BPM (optional; auto-detected if blank)386 --notes "vocal forward" Creative direction notes387 --refs "/ref.wav" Reference track path(s) for mix matching (comma-separated)388 --dest streaming,cd Delivery destinations (comma-separated)389 --sr 44100 Sample rate (default: 44100)390 --bd 24 Bit depth (default: 24)391 --autonomy 2 Autonomy level L0–L3 (default: 2)392 --separate true Enable stem separation393 --stems 4stem Stem preset: 2stem, 4stem, 6stem, 8stem394 --sequence "T1,T2,T3" Track running order for EP/album delivery (comma-separated titles)395 --from ANALYZE Start pipeline from a specific stage396 --fadein 50 Fade-in duration ms (default: 50)397 --fadeout 2000 Fade-out duration ms (default: 2000)398 --eqbudget 6 Max EQ change ±dB per band (default: 6)399 --locked "Drums,Guitar" Locked tracks — will not be processed400 401${c("bold","Destinations:")}402 streaming -14 LUFS / -1 dBTP (Spotify, Apple Music)403 video -14 LUFS / -1 dBTP (Film, YouTube)404 cd -9 LUFS / -0.1 dBTP405 club -6 LUFS / -0.3 dBTP406 broadcast -23 LUFS / -1 dBTP (EBU R128)407 podcast -16 LUFS / -1 dBTP408 409${c("bold","Examples:")}410 node src/cli.js analyze song.wav411 node src/cli.js pipeline song.wav --artist "Jordan" --title "Skyline" --dest streaming,cd412 node src/cli.js pipeline song.mp3 --notes "vocal forward, warm" --refs "/ref.wav" --autonomy 1413 node src/cli.js pipeline mix.wav --dest streaming --fadein 100 --fadeout 3000 --eqbudget 3414 node src/cli.js pipeline stems/*.wav --artist "Mia" --separate true --stems 4stem415 node src/cli.js status proj_1234567_abcd416 `);417}418 419// ─── Entry point ──────────────────────────────────────────────────────────────420 421async function main() {422 const [,, cmd, ...rest] = process.argv;423 const { files, opts } = parseArgs(rest);424 425 // Ensure projects dir exists426 fs.mkdirSync(config.paths.projects, { recursive: true });427 428 try {429 switch (cmd) {430 case "analyze": return await cmdAnalyze(files);431 case "ingest": return await cmdIngest(files, opts);432 case "pipeline": return await cmdPipeline(files, opts);433 case "status": return await cmdStatus(files[0] || opts.id);434 case "report": return await cmdReport(files[0] || opts.id);435 case "projects": return await cmdProjects();436 default: return printHelp();437 }438 } catch (err) {439 console.error(c("red", `\n✗ Error: ${err.message}`));440 if (process.env.DEBUG) console.error(err.stack);441 process.exit(1);442 }443}444 445main();446 