339 líneas
10 KiB
JavaScript
339 líneas
10 KiB
JavaScript
#!/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();
|