#!/usr/bin/env node /** * AutoMixer CLI * * Command-line interface for the automixer library. * Provides an easy way to mix multiple audio files from the terminal. * * Usage: * automixer mix track1.mp3 track2.mp3 -o output.mp3 * automixer analyze track.mp3 */ import { Command } from 'commander'; import chalk from 'chalk'; import ora from 'ora'; import cliProgress from 'cli-progress'; import path from 'path'; import fs from 'fs/promises'; import { AutoMixer } from '../src/core/AutoMixer.js'; import { BPMDetector } from '../src/audio/BPMDetector.js'; import { AudioAnalyzer } from '../src/audio/AudioAnalyzer.js'; const program = new Command(); // Package info const pkg = JSON.parse( await fs.readFile(new URL('../package.json', import.meta.url), 'utf-8') ); program .name('automixer') .description('Automatic DJ-style audio mixer with BPM detection and beat synchronization') .version(pkg.version); /** * Mix command - Main mixing functionality */ program .command('mix') .description('Mix multiple audio files with automatic beat matching') .argument('', 'Audio files to mix (in order)') .option('-o, --output ', 'Output file path', 'mix_output.mp3') .option('-c, --crossfade ', 'Crossfade duration in seconds', '32') .option('-b, --bpm ', 'Target BPM (auto-detect if not specified)') .option('--max-bpm-change ', 'Maximum BPM change allowed', '8') .option('--no-preserve-pitch', 'Allow pitch to change with tempo') .option('-q, --quiet', 'Suppress progress output') .action(async (files, options) => { const spinner = ora(); try { // Validate input files console.log(chalk.cyan('\nšŸŽµ AutoMixer - Automatic DJ Mixer\n')); if (files.length < 2) { console.log(chalk.yellow('āš ļø At least 2 files are required for mixing.')); console.log(chalk.gray(' Use "automixer analyze" to analyze a single file.\n')); process.exit(1); } // Check files exist spinner.start('Validating input files...'); const validatedFiles = []; for (const file of files) { const fullPath = path.resolve(file); try { await fs.access(fullPath); validatedFiles.push(fullPath); } catch { spinner.fail(`File not found: ${file}`); process.exit(1); } } spinner.succeed(`Found ${validatedFiles.length} audio files`); // Create mixer instance const mixer = new AutoMixer({ crossfadeDuration: parseInt(options.crossfade, 10), targetBPM: options.bpm ? parseFloat(options.bpm) : null, maxBPMChange: parseFloat(options.maxBpmChange), preservePitch: options.preservePitch }); // Progress bar for analysis const analysisBar = new cliProgress.SingleBar({ format: chalk.cyan('Analyzing') + ' |{bar}| {percentage}% | {track}', hideCursor: true }, cliProgress.Presets.shades_classic); if (!options.quiet) { console.log(chalk.gray('\nšŸ“Š Analyzing tracks...\n')); analysisBar.start(validatedFiles.length, 0, { track: '' }); } // Set up event listeners let currentTrackIndex = 0; mixer.on('analysis:track:start', ({ index, filepath }) => { if (!options.quiet) { analysisBar.update(index, { track: path.basename(filepath) }); } }); mixer.on('analysis:track:complete', ({ index }) => { currentTrackIndex = index + 1; if (!options.quiet) { analysisBar.update(currentTrackIndex); } }); // Analyze tracks mixer.addTracks(validatedFiles); const analyzedTracks = await mixer.analyzeTracks(); if (!options.quiet) { analysisBar.stop(); console.log(); } // Display track info if (!options.quiet) { console.log(chalk.cyan('šŸ“‹ Track Information:\n')); for (const track of analyzedTracks) { console.log(chalk.white(` ${track.filename}`)); console.log(chalk.gray(` BPM: ${chalk.yellow(track.bpm)} | Duration: ${formatDuration(track.duration)}`)); } console.log(); } // Calculate target BPM const targetBPM = mixer.calculateOptimalBPM(); if (!options.quiet) { console.log(chalk.cyan(`šŸŽÆ Target BPM: ${chalk.yellow(targetBPM)}\n`)); } // Create the mix const outputPath = path.resolve(options.output); if (!options.quiet) { spinner.start('Creating mix...'); } mixer.on('mix:prepare:complete', ({ index, tempoRatio }) => { if (!options.quiet && Math.abs(tempoRatio - 1) > 0.001) { const change = ((tempoRatio - 1) * 100).toFixed(1); const sign = change > 0 ? '+' : ''; spinner.text = `Adjusting tempo for track ${index + 1} (${sign}${change}%)`; } }); mixer.on('mix:render:progress', ({ current, total, message }) => { if (!options.quiet) { spinner.text = message || `Mixing ${current}/${total}...`; } }); await mixer.createMix(outputPath); if (!options.quiet) { spinner.succeed('Mix created successfully!'); console.log(chalk.green(`\nāœ… Output saved to: ${chalk.white(outputPath)}\n`)); } } catch (error) { spinner.fail(chalk.red(`Error: ${error.message}`)); if (process.env.DEBUG) { console.error(error); } process.exit(1); } }); /** * Analyze command - Analyze a single track */ program .command('analyze') .description('Analyze audio file(s) and display BPM and other information') .argument('', 'Audio files to analyze') .option('-j, --json', 'Output as JSON') .action(async (files, options) => { const spinner = ora(); const results = []; try { for (const file of files) { const fullPath = path.resolve(file); if (!options.json) { spinner.start(`Analyzing ${path.basename(file)}...`); } try { await fs.access(fullPath); } catch { if (options.json) { results.push({ file, error: 'File not found' }); } else { spinner.fail(`File not found: ${file}`); } continue; } const bpmDetector = new BPMDetector(); const audioAnalyzer = new AudioAnalyzer(); const [bpmResult, metadata] = await Promise.all([ bpmDetector.detect(fullPath), audioAnalyzer.getMetadata(fullPath) ]); const result = { file: path.basename(file), path: fullPath, bpm: bpmResult.bpm, confidence: bpmResult.confidence, duration: metadata.duration, durationFormatted: formatDuration(metadata.duration), sampleRate: metadata.sampleRate, channels: metadata.channels, bitrate: metadata.bitrate, format: metadata.format, codec: metadata.codec }; results.push(result); if (!options.json) { spinner.succeed(`${chalk.white(result.file)}`); console.log(chalk.gray(` BPM: ${chalk.yellow(result.bpm)} (confidence: ${(result.confidence * 100).toFixed(0)}%)`)); console.log(chalk.gray(` Duration: ${result.durationFormatted}`)); console.log(chalk.gray(` Format: ${result.codec} ${result.sampleRate}Hz ${result.channels}ch ${Math.round(result.bitrate / 1000)}kbps`)); console.log(); } } if (options.json) { console.log(JSON.stringify(results, null, 2)); } } catch (error) { if (options.json) { console.log(JSON.stringify({ error: error.message })); } else { spinner.fail(chalk.red(`Error: ${error.message}`)); } process.exit(1); } }); /** * Check command - Verify FFmpeg installation */ program .command('check') .description('Check system requirements (FFmpeg installation)') .action(async () => { const spinner = ora(); console.log(chalk.cyan('\nšŸ” Checking system requirements...\n')); // Check FFmpeg spinner.start('Checking FFmpeg...'); try { const { spawn } = await import('child_process'); await new Promise((resolve, reject) => { const ffmpeg = spawn('ffmpeg', ['-version']); let output = ''; ffmpeg.stdout.on('data', (data) => { output += data.toString(); }); ffmpeg.on('close', (code) => { if (code === 0) { const version = output.match(/ffmpeg version (\S+)/)?.[1] || 'unknown'; spinner.succeed(`FFmpeg installed (version: ${version})`); resolve(); } else { reject(new Error('FFmpeg not working')); } }); ffmpeg.on('error', () => { reject(new Error('FFmpeg not found')); }); }); } catch { spinner.fail('FFmpeg not found'); console.log(chalk.yellow('\n Please install FFmpeg:')); console.log(chalk.gray(' - macOS: brew install ffmpeg')); console.log(chalk.gray(' - Ubuntu: sudo apt install ffmpeg')); console.log(chalk.gray(' - Windows: choco install ffmpeg')); console.log(); } // Check FFprobe spinner.start('Checking FFprobe...'); try { const { spawn } = await import('child_process'); await new Promise((resolve, reject) => { const ffprobe = spawn('ffprobe', ['-version']); ffprobe.on('close', (code) => { if (code === 0) { spinner.succeed('FFprobe installed'); resolve(); } else { reject(new Error('FFprobe not working')); } }); ffprobe.on('error', () => { reject(new Error('FFprobe not found')); }); }); } catch { spinner.fail('FFprobe not found (usually included with FFmpeg)'); } console.log(); }); /** * Format duration in mm:ss format */ function formatDuration(seconds) { const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${mins}:${secs.toString().padStart(2, '0')}`; } // Parse and run program.parse();