338
bin/cli.js
Archivo normal
338
bin/cli.js
Archivo normal
@@ -0,0 +1,338 @@
|
||||
#!/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('<files...>', 'Audio files to mix (in order)')
|
||||
.option('-o, --output <file>', 'Output file path', 'mix_output.mp3')
|
||||
.option('-c, --crossfade <seconds>', 'Crossfade duration in seconds', '32')
|
||||
.option('-b, --bpm <number>', 'Target BPM (auto-detect if not specified)')
|
||||
.option('--max-bpm-change <percent>', '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('<files...>', '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();
|
||||
Referencia en una nueva incidencia
Block a user