56
.gitignore
vendido
Archivo normal
56
.gitignore
vendido
Archivo normal
@@ -0,0 +1,56 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
.tmp/
|
||||||
|
tmp/
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Test coverage
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Audio test files (to avoid large files in repo)
|
||||||
|
*.mp3
|
||||||
|
*.wav
|
||||||
|
*.flac
|
||||||
|
*.ogg
|
||||||
|
*.m4a
|
||||||
|
!tests/fixtures/*.mp3
|
||||||
|
|
||||||
|
# Package lock files (optional - uncomment if you want to ignore)
|
||||||
|
# package-lock.json
|
||||||
|
# yarn.lock
|
||||||
43
CHANGELOG.md
Archivo normal
43
CHANGELOG.md
Archivo normal
@@ -0,0 +1,43 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to AutoMixer will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.0.0] - 2024-12-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Initial release of AutoMixer
|
||||||
|
- **BPM Detection**: Automatic tempo detection using music-tempo algorithm
|
||||||
|
- **Beat Synchronization**: Alignment of beats between consecutive tracks
|
||||||
|
- **Pitch-Preserving Tempo Adjustment**: Time-stretching using FFmpeg rubberband filter
|
||||||
|
- **Smooth Crossfades**: Equal-power crossfade mixing between tracks
|
||||||
|
- **CLI Interface**: Command-line tool for easy mixing
|
||||||
|
- `mix` command for mixing multiple tracks
|
||||||
|
- `analyze` command for track analysis
|
||||||
|
- `check` command for system requirements verification
|
||||||
|
- **Programmatic API**: Full Node.js library for integration
|
||||||
|
- AutoMixer class for complete mixing workflow
|
||||||
|
- BPMDetector for tempo analysis
|
||||||
|
- AudioAnalyzer for metadata extraction
|
||||||
|
- PitchShifter for tempo/pitch adjustments
|
||||||
|
- TrackMixer for crossfade operations
|
||||||
|
- **Event System**: Progress tracking through EventEmitter
|
||||||
|
- **Multiple Crossfade Curves**: linear, log, sqrt, sine, exponential
|
||||||
|
|
||||||
|
### Technical Details
|
||||||
|
- Uses FFmpeg for audio processing
|
||||||
|
- Supports MP3, WAV, FLAC, and other common formats
|
||||||
|
- Node.js 18+ required
|
||||||
|
- ES Modules support
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Planned
|
||||||
|
- Key detection and harmonic mixing
|
||||||
|
- Automatic intro/outro detection
|
||||||
|
- Energy-based transition point selection
|
||||||
|
- Web interface
|
||||||
|
- Real-time preview
|
||||||
|
- Batch processing improvements
|
||||||
114
CONTRIBUTING.md
Archivo normal
114
CONTRIBUTING.md
Archivo normal
@@ -0,0 +1,114 @@
|
|||||||
|
# Contributing to AutoMixer
|
||||||
|
|
||||||
|
First off, thank you for considering contributing to AutoMixer! It's people like you that make AutoMixer such a great tool.
|
||||||
|
|
||||||
|
## Code of Conduct
|
||||||
|
|
||||||
|
This project and everyone participating in it is governed by our Code of Conduct. By participating, you are expected to uphold this code.
|
||||||
|
|
||||||
|
## How Can I Contribute?
|
||||||
|
|
||||||
|
### Reporting Bugs
|
||||||
|
|
||||||
|
Before creating bug reports, please check existing issues as you might find out that you don't need to create one. When you are creating a bug report, please include as many details as possible:
|
||||||
|
|
||||||
|
- **Use a clear and descriptive title**
|
||||||
|
- **Describe the exact steps to reproduce the problem**
|
||||||
|
- **Provide specific examples** (including sample audio files if possible)
|
||||||
|
- **Describe the behavior you observed and what you expected**
|
||||||
|
- **Include your environment details** (OS, Node.js version, FFmpeg version)
|
||||||
|
|
||||||
|
### Suggesting Enhancements
|
||||||
|
|
||||||
|
Enhancement suggestions are tracked as GitHub issues. When creating an enhancement suggestion, please include:
|
||||||
|
|
||||||
|
- **Use a clear and descriptive title**
|
||||||
|
- **Provide a detailed description of the suggested enhancement**
|
||||||
|
- **Explain why this enhancement would be useful**
|
||||||
|
- **List any alternative solutions you've considered**
|
||||||
|
|
||||||
|
### Pull Requests
|
||||||
|
|
||||||
|
1. Fork the repo and create your branch from `main`
|
||||||
|
2. If you've added code that should be tested, add tests
|
||||||
|
3. If you've changed APIs, update the documentation
|
||||||
|
4. Ensure the test suite passes
|
||||||
|
5. Make sure your code follows the existing style
|
||||||
|
6. Issue that pull request!
|
||||||
|
|
||||||
|
## Development Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone your fork
|
||||||
|
git clone https://github.com/your-username/automixer.git
|
||||||
|
cd automixer
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
npm test
|
||||||
|
|
||||||
|
# Run linter
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
automixer/
|
||||||
|
├── bin/
|
||||||
|
│ └── cli.js # CLI entry point
|
||||||
|
├── src/
|
||||||
|
│ ├── index.js # Main exports
|
||||||
|
│ ├── core/
|
||||||
|
│ │ └── AutoMixer.js # Main orchestrator
|
||||||
|
│ ├── audio/
|
||||||
|
│ │ ├── BPMDetector.js # BPM detection
|
||||||
|
│ │ ├── AudioAnalyzer.js # Metadata extraction
|
||||||
|
│ │ ├── TrackMixer.js # Crossfade mixing
|
||||||
|
│ │ └── PitchShifter.js # Tempo/pitch adjustment
|
||||||
|
│ └── utils/
|
||||||
|
│ └── index.js # Utility functions
|
||||||
|
├── tests/
|
||||||
|
│ └── ... # Test files
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Coding Guidelines
|
||||||
|
|
||||||
|
### JavaScript Style
|
||||||
|
|
||||||
|
- Use ES modules (`import`/`export`)
|
||||||
|
- Use `async`/`await` for asynchronous code
|
||||||
|
- Document functions with JSDoc comments
|
||||||
|
- Use meaningful variable and function names
|
||||||
|
|
||||||
|
### Commit Messages
|
||||||
|
|
||||||
|
- Use the present tense ("Add feature" not "Added feature")
|
||||||
|
- Use the imperative mood ("Move cursor to..." not "Moves cursor to...")
|
||||||
|
- Limit the first line to 72 characters
|
||||||
|
- Reference issues and pull requests when relevant
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- Update README.md for any user-facing changes
|
||||||
|
- Update JSDoc comments for API changes
|
||||||
|
- Add inline comments for complex logic
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
npm test
|
||||||
|
|
||||||
|
# Run with coverage
|
||||||
|
npm run test:coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
## Questions?
|
||||||
|
|
||||||
|
Feel free to open an issue with your question or reach out to the maintainers.
|
||||||
|
|
||||||
|
Thank you for contributing! 🎵
|
||||||
21
LICENSE
Archivo normal
21
LICENSE
Archivo normal
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 ale
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
370
README.md
Archivo normal
370
README.md
Archivo normal
@@ -0,0 +1,370 @@
|
|||||||
|
# AutoMixer 🎵
|
||||||
|
|
||||||
|
Automatic DJ-style audio mixer that sequentially blends MP3 files with BPM detection, pitch adjustment, and beat synchronization.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 🎯 **Automatic BPM Detection** - Analyzes audio files to detect tempo using beat detection algorithms
|
||||||
|
- 🔄 **Beat Synchronization** - Aligns beats between tracks for seamless transitions
|
||||||
|
- 🎚️ **Pitch-Preserving Tempo Adjustment** - Adjusts tempo while maintaining original pitch
|
||||||
|
- 🌊 **Smooth Crossfades** - Creates professional equal-power crossfades between tracks
|
||||||
|
- 📊 **Audio Analysis** - Extracts metadata, duration, and audio characteristics
|
||||||
|
- 🖥️ **CLI & API** - Use from command line or integrate into your Node.js projects
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **Node.js** >= 18.0.0
|
||||||
|
- **FFmpeg** with FFprobe (required for audio processing)
|
||||||
|
|
||||||
|
### Installing FFmpeg
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# macOS
|
||||||
|
brew install ffmpeg
|
||||||
|
|
||||||
|
# Ubuntu/Debian
|
||||||
|
sudo apt install ffmpeg
|
||||||
|
|
||||||
|
# Windows (with Chocolatey)
|
||||||
|
choco install ffmpeg
|
||||||
|
|
||||||
|
# Windows (with winget)
|
||||||
|
winget install FFmpeg
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Global Installation (CLI)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -g automixer
|
||||||
|
```
|
||||||
|
|
||||||
|
### Local Installation (Library)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install automixer
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLI Usage
|
||||||
|
|
||||||
|
### Mix Multiple Tracks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Basic usage - mix tracks in order
|
||||||
|
automixer mix track1.mp3 track2.mp3 track3.mp3 -o my_mix.mp3
|
||||||
|
|
||||||
|
# Specify crossfade duration (default: 8 seconds)
|
||||||
|
automixer mix track1.mp3 track2.mp3 -c 12 -o output.mp3
|
||||||
|
|
||||||
|
# Set a specific target BPM
|
||||||
|
automixer mix track1.mp3 track2.mp3 -b 128 -o output.mp3
|
||||||
|
|
||||||
|
# Allow pitch to change with tempo (faster processing)
|
||||||
|
automixer mix track1.mp3 track2.mp3 --no-preserve-pitch -o output.mp3
|
||||||
|
```
|
||||||
|
|
||||||
|
### Analyze Tracks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Analyze a single track
|
||||||
|
automixer analyze track.mp3
|
||||||
|
|
||||||
|
# Analyze multiple tracks
|
||||||
|
automixer analyze track1.mp3 track2.mp3
|
||||||
|
|
||||||
|
# Output as JSON
|
||||||
|
automixer analyze track.mp3 --json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check System Requirements
|
||||||
|
|
||||||
|
```bash
|
||||||
|
automixer check
|
||||||
|
```
|
||||||
|
|
||||||
|
### CLI Options
|
||||||
|
|
||||||
|
| Option | Description | Default |
|
||||||
|
|--------|-------------|---------|
|
||||||
|
| `-o, --output <file>` | Output file path | `mix_output.mp3` |
|
||||||
|
| `-c, --crossfade <seconds>` | Crossfade duration | `8` |
|
||||||
|
| `-b, --bpm <number>` | Target BPM | Auto-detect |
|
||||||
|
| `--max-bpm-change <percent>` | Maximum BPM change allowed | `8` |
|
||||||
|
| `--no-preserve-pitch` | Allow pitch to change with tempo | Pitch preserved |
|
||||||
|
| `-q, --quiet` | Suppress progress output | Show progress |
|
||||||
|
|
||||||
|
## API Usage
|
||||||
|
|
||||||
|
### Basic Example
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import AutoMixer from 'automixer';
|
||||||
|
|
||||||
|
const mixer = new AutoMixer({
|
||||||
|
crossfadeDuration: 8, // seconds
|
||||||
|
preservePitch: true,
|
||||||
|
maxBPMChange: 8 // percent
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mix multiple tracks
|
||||||
|
await mixer.mix(
|
||||||
|
['track1.mp3', 'track2.mp3', 'track3.mp3'],
|
||||||
|
'output.mp3'
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Advanced Usage with Events
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { AutoMixer } from 'automixer';
|
||||||
|
|
||||||
|
const mixer = new AutoMixer({
|
||||||
|
crossfadeDuration: 10,
|
||||||
|
targetBPM: 128,
|
||||||
|
preservePitch: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen to events
|
||||||
|
mixer.on('analysis:track:start', ({ index, filepath }) => {
|
||||||
|
console.log(`Analyzing track ${index + 1}: ${filepath}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
mixer.on('analysis:track:complete', ({ index, trackInfo }) => {
|
||||||
|
console.log(`Track ${index + 1}: ${trackInfo.bpm} BPM`);
|
||||||
|
});
|
||||||
|
|
||||||
|
mixer.on('mix:bpm', ({ targetBPM }) => {
|
||||||
|
console.log(`Target BPM: ${targetBPM}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
mixer.on('mix:render:progress', ({ current, total, message }) => {
|
||||||
|
console.log(`Progress: ${current}/${total}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
mixer.on('mix:complete', ({ outputPath }) => {
|
||||||
|
console.log(`Mix saved to: ${outputPath}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Run the mix
|
||||||
|
await mixer.mix(['track1.mp3', 'track2.mp3'], 'output.mp3');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step-by-Step Processing
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { AutoMixer } from 'automixer';
|
||||||
|
|
||||||
|
const mixer = new AutoMixer();
|
||||||
|
|
||||||
|
// Add tracks
|
||||||
|
mixer.addTracks(['track1.mp3', 'track2.mp3', 'track3.mp3']);
|
||||||
|
|
||||||
|
// Analyze tracks
|
||||||
|
const analyzedTracks = await mixer.analyzeTracks();
|
||||||
|
|
||||||
|
// Log track information
|
||||||
|
for (const track of analyzedTracks) {
|
||||||
|
console.log(`${track.filename}: ${track.bpm} BPM, ${track.duration}s`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get optimal BPM
|
||||||
|
const targetBPM = mixer.calculateOptimalBPM();
|
||||||
|
console.log(`Optimal BPM: ${targetBPM}`);
|
||||||
|
|
||||||
|
// Create the mix
|
||||||
|
await mixer.createMix('output.mp3');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Individual Components
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { BPMDetector, AudioAnalyzer, PitchShifter } from 'automixer';
|
||||||
|
|
||||||
|
// Detect BPM
|
||||||
|
const detector = new BPMDetector();
|
||||||
|
const { bpm, beats, confidence } = await detector.detect('track.mp3');
|
||||||
|
console.log(`BPM: ${bpm} (confidence: ${confidence})`);
|
||||||
|
|
||||||
|
// Get audio metadata
|
||||||
|
const analyzer = new AudioAnalyzer();
|
||||||
|
const metadata = await analyzer.getMetadata('track.mp3');
|
||||||
|
console.log(`Duration: ${metadata.duration}s`);
|
||||||
|
|
||||||
|
// Adjust tempo
|
||||||
|
const shifter = new PitchShifter();
|
||||||
|
const adjustedPath = await shifter.adjustTempo('track.mp3', 1.1, true);
|
||||||
|
console.log(`Tempo-adjusted file: ${adjustedPath}`);
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### AutoMixer
|
||||||
|
|
||||||
|
Main class that orchestrates the mixing process.
|
||||||
|
|
||||||
|
#### Constructor Options
|
||||||
|
|
||||||
|
| Option | Type | Default | Description |
|
||||||
|
|--------|------|---------|-------------|
|
||||||
|
| `crossfadeDuration` | number | `8` | Crossfade duration in seconds |
|
||||||
|
| `targetBPM` | number | `null` | Target BPM (auto-detect if null) |
|
||||||
|
| `preservePitch` | boolean | `true` | Preserve pitch when changing tempo |
|
||||||
|
| `maxBPMChange` | number | `8` | Maximum BPM change percentage |
|
||||||
|
| `outputFormat` | string | `'mp3'` | Output format |
|
||||||
|
| `outputBitrate` | number | `320` | Output bitrate in kbps |
|
||||||
|
|
||||||
|
#### Methods
|
||||||
|
|
||||||
|
- `addTracks(filepaths)` - Add tracks to the queue
|
||||||
|
- `clearTracks()` - Clear all tracks
|
||||||
|
- `analyzeTracks()` - Analyze all tracks (returns track info)
|
||||||
|
- `calculateOptimalBPM()` - Calculate the optimal target BPM
|
||||||
|
- `createMix(outputPath)` - Create the final mix
|
||||||
|
- `mix(inputFiles, outputPath)` - Full process in one call
|
||||||
|
|
||||||
|
#### Events
|
||||||
|
|
||||||
|
- `analysis:start` - Analysis started
|
||||||
|
- `analysis:track:start` - Individual track analysis started
|
||||||
|
- `analysis:track:complete` - Individual track analysis completed
|
||||||
|
- `analysis:complete` - All analysis completed
|
||||||
|
- `mix:start` - Mixing started
|
||||||
|
- `mix:bpm` - Target BPM calculated
|
||||||
|
- `mix:prepare:start` - Track preparation started
|
||||||
|
- `mix:prepare:complete` - Track preparation completed
|
||||||
|
- `mix:render:start` - Rendering started
|
||||||
|
- `mix:render:progress` - Rendering progress update
|
||||||
|
- `mix:complete` - Mixing completed
|
||||||
|
|
||||||
|
### BPMDetector
|
||||||
|
|
||||||
|
Detects BPM and beat positions in audio files.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const detector = new BPMDetector({ minBPM: 60, maxBPM: 200 });
|
||||||
|
const { bpm, beats, confidence } = await detector.detect('track.mp3');
|
||||||
|
```
|
||||||
|
|
||||||
|
### AudioAnalyzer
|
||||||
|
|
||||||
|
Extracts metadata and analyzes audio characteristics.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const analyzer = new AudioAnalyzer();
|
||||||
|
const metadata = await analyzer.getMetadata('track.mp3');
|
||||||
|
// { duration, sampleRate, channels, bitrate, codec, format, tags }
|
||||||
|
```
|
||||||
|
|
||||||
|
### PitchShifter
|
||||||
|
|
||||||
|
Adjusts tempo and pitch of audio files.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const shifter = new PitchShifter();
|
||||||
|
|
||||||
|
// Adjust tempo (preserving pitch)
|
||||||
|
await shifter.adjustTempo('input.mp3', 1.1, true);
|
||||||
|
|
||||||
|
// Shift pitch (in semitones)
|
||||||
|
await shifter.shiftPitch('input.mp3', 2);
|
||||||
|
|
||||||
|
// Adjust both
|
||||||
|
await shifter.adjustTempoAndPitch('input.mp3', 1.1, 2);
|
||||||
|
```
|
||||||
|
|
||||||
|
### TrackMixer
|
||||||
|
|
||||||
|
Handles crossfading and track mixing.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const mixer = new TrackMixer({
|
||||||
|
crossfadeDuration: 8,
|
||||||
|
crossfadeCurve: 'log' // 'linear', 'log', 'sqrt', 'sine', 'exponential'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### BPM Detection
|
||||||
|
|
||||||
|
AutoMixer uses the `music-tempo` library combined with FFmpeg for BPM detection:
|
||||||
|
|
||||||
|
1. Audio is decoded to raw PCM samples using FFmpeg
|
||||||
|
2. A 30-second analysis window is extracted (skipping intro)
|
||||||
|
3. Beat detection algorithm identifies tempo and beat positions
|
||||||
|
4. BPM is normalized to a standard range (60-200)
|
||||||
|
5. Beats are extrapolated across the full track
|
||||||
|
|
||||||
|
### Beat Matching
|
||||||
|
|
||||||
|
The mixing algorithm:
|
||||||
|
|
||||||
|
1. Analyzes all input tracks to detect BPM
|
||||||
|
2. Calculates optimal target BPM (median of all tracks)
|
||||||
|
3. Adjusts each track's tempo to match target BPM
|
||||||
|
4. Finds beat-aligned transition points
|
||||||
|
5. Creates equal-power crossfades at transition points
|
||||||
|
|
||||||
|
### Tempo Adjustment
|
||||||
|
|
||||||
|
Tempo adjustment uses FFmpeg's audio filters:
|
||||||
|
|
||||||
|
- **With pitch preservation**: Uses the rubberband filter for high-quality time-stretching
|
||||||
|
- **Without pitch preservation**: Uses the atempo filter for simple speed changes
|
||||||
|
|
||||||
|
## Performance Tips
|
||||||
|
|
||||||
|
- **Use consistent BPM tracks**: Mixing tracks with similar BPMs produces better results
|
||||||
|
- **Allow larger BPM changes**: Increase `maxBPMChange` if tracks have very different tempos
|
||||||
|
- **Longer crossfades**: Use longer crossfades (10-15s) for smoother transitions
|
||||||
|
- **Skip pitch preservation**: Use `--no-preserve-pitch` for faster processing when pitch shift is acceptable
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### FFmpeg Not Found
|
||||||
|
|
||||||
|
Make sure FFmpeg is installed and available in your PATH:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ffmpeg -version
|
||||||
|
```
|
||||||
|
|
||||||
|
### Poor BPM Detection
|
||||||
|
|
||||||
|
Some tracks may have ambiguous tempos. You can:
|
||||||
|
- Set a specific target BPM with `-b` option
|
||||||
|
- Increase `maxBPMChange` to allow larger adjustments
|
||||||
|
|
||||||
|
### Audio Quality Issues
|
||||||
|
|
||||||
|
- Ensure source files are high quality
|
||||||
|
- Use higher bitrate: `outputBitrate: 320`
|
||||||
|
- Use pitch-preserved tempo adjustment
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License - see [LICENSE](LICENSE) file for details.
|
||||||
|
|
||||||
|
## Repository
|
||||||
|
|
||||||
|
Project repository: [https://github.com/manalejandro/automixer](https://github.com/manalejandro/automixer)
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
|
||||||
|
3. Commit your changes (`git commit -m 'Add amazing feature'`)
|
||||||
|
4. Push to the branch (`git push origin feature/amazing-feature`)
|
||||||
|
5. Open a Pull Request
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
### v1.0.0
|
||||||
|
- Initial release
|
||||||
|
- BPM detection and beat synchronization
|
||||||
|
- Pitch-preserving tempo adjustment
|
||||||
|
- Smooth crossfade mixing
|
||||||
|
- CLI and API interfaces
|
||||||
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();
|
||||||
32
docs/QUICK_START.md
Archivo normal
32
docs/QUICK_START.md
Archivo normal
@@ -0,0 +1,32 @@
|
|||||||
|
# AutoMixer
|
||||||
|
|
||||||
|
> Automatic DJ-style audio mixer with BPM detection and beat synchronization
|
||||||
|
|
||||||
|
[](https://www.npmjs.com/package/automixer)
|
||||||
|
[](https://opensource.org/licenses/MIT)
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install globally
|
||||||
|
npm install -g automixer
|
||||||
|
|
||||||
|
# Mix tracks
|
||||||
|
automixer mix track1.mp3 track2.mp3 track3.mp3 -o my_mix.mp3
|
||||||
|
|
||||||
|
# Analyze a track
|
||||||
|
automixer analyze track.mp3
|
||||||
|
```
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Node.js >= 18.0.0
|
||||||
|
- FFmpeg installed on your system
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
See [README.md](README.md) for full documentation.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
47
eslint.config.js
Archivo normal
47
eslint.config.js
Archivo normal
@@ -0,0 +1,47 @@
|
|||||||
|
import js from '@eslint/js';
|
||||||
|
|
||||||
|
export default [
|
||||||
|
js.configs.recommended,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2022,
|
||||||
|
sourceType: 'module',
|
||||||
|
globals: {
|
||||||
|
console: 'readonly',
|
||||||
|
process: 'readonly',
|
||||||
|
Buffer: 'readonly',
|
||||||
|
URL: 'readonly',
|
||||||
|
setTimeout: 'readonly',
|
||||||
|
clearTimeout: 'readonly',
|
||||||
|
setInterval: 'readonly',
|
||||||
|
clearInterval: 'readonly'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||||
|
'no-console': 'off',
|
||||||
|
'prefer-const': 'error',
|
||||||
|
'no-var': 'error',
|
||||||
|
'eqeqeq': ['error', 'always'],
|
||||||
|
'curly': ['error', 'all'],
|
||||||
|
'brace-style': ['error', '1tbs'],
|
||||||
|
'indent': ['error', 2],
|
||||||
|
'quotes': ['error', 'single', { avoidEscape: true }],
|
||||||
|
'semi': ['error', 'always'],
|
||||||
|
'comma-dangle': ['error', 'never'],
|
||||||
|
'arrow-spacing': 'error',
|
||||||
|
'keyword-spacing': 'error',
|
||||||
|
'space-before-blocks': 'error',
|
||||||
|
'space-infix-ops': 'error',
|
||||||
|
'object-curly-spacing': ['error', 'always'],
|
||||||
|
'array-bracket-spacing': ['error', 'never']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ignores: [
|
||||||
|
'node_modules/**',
|
||||||
|
'coverage/**',
|
||||||
|
'dist/**'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
29
jsconfig.json
Archivo normal
29
jsconfig.json
Archivo normal
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"target": "ES2022",
|
||||||
|
"checkJs": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"automixer": ["./src/index.js"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*",
|
||||||
|
"bin/**/*"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"tests"
|
||||||
|
]
|
||||||
|
}
|
||||||
63
package.json
Archivo normal
63
package.json
Archivo normal
@@ -0,0 +1,63 @@
|
|||||||
|
{
|
||||||
|
"name": "automixer",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Automatic DJ-style audio mixer that sequentially blends MP3 files with BPM detection, pitch adjustment, and beat synchronization",
|
||||||
|
"main": "src/index.js",
|
||||||
|
"bin": {
|
||||||
|
"automixer": "./bin/cli.js"
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/index.js",
|
||||||
|
"cli": "node bin/cli.js",
|
||||||
|
"test": "node --test tests/*.test.js",
|
||||||
|
"lint": "eslint src/ bin/",
|
||||||
|
"prepublishOnly": "npm test"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"audio",
|
||||||
|
"mixer",
|
||||||
|
"dj",
|
||||||
|
"bpm",
|
||||||
|
"beat-detection",
|
||||||
|
"mp3",
|
||||||
|
"music",
|
||||||
|
"crossfade",
|
||||||
|
"pitch-shift",
|
||||||
|
"tempo",
|
||||||
|
"beat-matching"
|
||||||
|
],
|
||||||
|
"author": "ale",
|
||||||
|
"license": "MIT",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/manalejandro/automixer.git"
|
||||||
|
},
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/manalejandro/automixer/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/manalejandro/automixer#readme",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"src/",
|
||||||
|
"bin/",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"commander": "^12.1.0",
|
||||||
|
"fluent-ffmpeg": "^2.1.3",
|
||||||
|
"music-tempo": "^1.0.3",
|
||||||
|
"ora": "^8.1.1",
|
||||||
|
"chalk": "^5.3.0",
|
||||||
|
"cli-progress": "^3.12.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"eslint": "^9.16.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"ffmpeg": "*"
|
||||||
|
}
|
||||||
|
}
|
||||||
248
src/audio/AudioAnalyzer.js
Archivo normal
248
src/audio/AudioAnalyzer.js
Archivo normal
@@ -0,0 +1,248 @@
|
|||||||
|
/**
|
||||||
|
* AudioAnalyzer - Audio file metadata and analysis utilities
|
||||||
|
*
|
||||||
|
* Provides methods for extracting audio metadata,
|
||||||
|
* duration, sample rate, and other technical information.
|
||||||
|
*
|
||||||
|
* @class AudioAnalyzer
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
|
||||||
|
export class AudioAnalyzer {
|
||||||
|
/**
|
||||||
|
* Create an AudioAnalyzer instance
|
||||||
|
*/
|
||||||
|
constructor() {
|
||||||
|
this.ffprobeCache = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get audio file metadata using FFprobe
|
||||||
|
* @param {string} filepath - Path to the audio file
|
||||||
|
* @returns {Promise<Object>} - Audio metadata
|
||||||
|
*/
|
||||||
|
async getMetadata(filepath) {
|
||||||
|
// Check cache first
|
||||||
|
if (this.ffprobeCache.has(filepath)) {
|
||||||
|
return this.ffprobeCache.get(filepath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const ffprobe = spawn('ffprobe', [
|
||||||
|
'-v', 'quiet',
|
||||||
|
'-print_format', 'json',
|
||||||
|
'-show_format',
|
||||||
|
'-show_streams',
|
||||||
|
filepath
|
||||||
|
]);
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
|
||||||
|
ffprobe.stdout.on('data', (data) => {
|
||||||
|
stdout += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
ffprobe.stderr.on('data', (data) => {
|
||||||
|
stderr += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
ffprobe.on('close', (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
reject(new Error(`FFprobe failed: ${stderr}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info = JSON.parse(stdout);
|
||||||
|
const audioStream = info.streams?.find(s => s.codec_type === 'audio');
|
||||||
|
|
||||||
|
if (!audioStream) {
|
||||||
|
reject(new Error('No audio stream found in file'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = {
|
||||||
|
duration: parseFloat(info.format?.duration || audioStream.duration || 0),
|
||||||
|
sampleRate: parseInt(audioStream.sample_rate, 10),
|
||||||
|
channels: audioStream.channels,
|
||||||
|
bitrate: parseInt(info.format?.bit_rate || audioStream.bit_rate || 0, 10),
|
||||||
|
codec: audioStream.codec_name,
|
||||||
|
format: info.format?.format_name,
|
||||||
|
tags: info.format?.tags || {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
this.ffprobeCache.set(filepath, metadata);
|
||||||
|
|
||||||
|
resolve(metadata);
|
||||||
|
} catch (error) {
|
||||||
|
reject(new Error(`Failed to parse FFprobe output: ${error.message}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ffprobe.on('error', (error) => {
|
||||||
|
reject(new Error(`Failed to spawn FFprobe: ${error.message}. Make sure FFmpeg is installed.`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get audio duration in seconds
|
||||||
|
* @param {string} filepath - Path to the audio file
|
||||||
|
* @returns {Promise<number>} - Duration in seconds
|
||||||
|
*/
|
||||||
|
async getDuration(filepath) {
|
||||||
|
const metadata = await this.getMetadata(filepath);
|
||||||
|
return metadata.duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyze audio energy/loudness at specific points
|
||||||
|
* @param {string} filepath - Path to the audio file
|
||||||
|
* @param {number} startTime - Start time in seconds
|
||||||
|
* @param {number} duration - Duration to analyze in seconds
|
||||||
|
* @returns {Promise<Object>} - Energy analysis
|
||||||
|
*/
|
||||||
|
async analyzeEnergy(filepath, startTime, duration) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const ffmpeg = spawn('ffmpeg', [
|
||||||
|
'-i', filepath,
|
||||||
|
'-ss', startTime.toString(),
|
||||||
|
'-t', duration.toString(),
|
||||||
|
'-af', 'volumedetect',
|
||||||
|
'-f', 'null',
|
||||||
|
'-'
|
||||||
|
]);
|
||||||
|
|
||||||
|
let stderr = '';
|
||||||
|
|
||||||
|
ffmpeg.stderr.on('data', (data) => {
|
||||||
|
stderr += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
ffmpeg.on('close', () => {
|
||||||
|
// FFmpeg outputs to stderr even on success
|
||||||
|
const meanMatch = stderr.match(/mean_volume:\s*(-?\d+\.?\d*)\s*dB/);
|
||||||
|
const maxMatch = stderr.match(/max_volume:\s*(-?\d+\.?\d*)\s*dB/);
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
meanVolume: meanMatch ? parseFloat(meanMatch[1]) : null,
|
||||||
|
maxVolume: maxMatch ? parseFloat(maxMatch[1]) : null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
ffmpeg.on('error', (error) => {
|
||||||
|
reject(new Error(`Failed to analyze energy: ${error.message}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect silence at the beginning and end of a track
|
||||||
|
* @param {string} filepath - Path to the audio file
|
||||||
|
* @param {number} [threshold=-50] - Silence threshold in dB
|
||||||
|
* @returns {Promise<Object>} - Silence detection results
|
||||||
|
*/
|
||||||
|
async detectSilence(filepath, threshold = -50) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const ffmpeg = spawn('ffmpeg', [
|
||||||
|
'-i', filepath,
|
||||||
|
'-af', `silencedetect=noise=${threshold}dB:d=0.5`,
|
||||||
|
'-f', 'null',
|
||||||
|
'-'
|
||||||
|
]);
|
||||||
|
|
||||||
|
let stderr = '';
|
||||||
|
|
||||||
|
ffmpeg.stderr.on('data', (data) => {
|
||||||
|
stderr += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
ffmpeg.on('close', () => {
|
||||||
|
const silenceStarts = [];
|
||||||
|
const silenceEnds = [];
|
||||||
|
|
||||||
|
const startMatches = stderr.matchAll(/silence_start:\s*(\d+\.?\d*)/g);
|
||||||
|
const endMatches = stderr.matchAll(/silence_end:\s*(\d+\.?\d*)/g);
|
||||||
|
|
||||||
|
for (const match of startMatches) {
|
||||||
|
silenceStarts.push(parseFloat(match[1]));
|
||||||
|
}
|
||||||
|
for (const match of endMatches) {
|
||||||
|
silenceEnds.push(parseFloat(match[1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
silenceStarts,
|
||||||
|
silenceEnds,
|
||||||
|
hasLeadingSilence: silenceStarts.length > 0 && silenceStarts[0] < 0.1,
|
||||||
|
leadingSilenceEnd: silenceEnds.length > 0 ? silenceEnds[0] : 0
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
ffmpeg.on('error', (error) => {
|
||||||
|
reject(new Error(`Failed to detect silence: ${error.message}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the waveform peaks for visualization
|
||||||
|
* @param {string} filepath - Path to the audio file
|
||||||
|
* @param {number} [samples=100] - Number of samples to return
|
||||||
|
* @returns {Promise<number[]>} - Array of peak values (0-1)
|
||||||
|
*/
|
||||||
|
async getWaveformPeaks(filepath, samples = 100) {
|
||||||
|
const metadata = await this.getMetadata(filepath);
|
||||||
|
const duration = metadata.duration;
|
||||||
|
const interval = duration / samples;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const ffmpeg = spawn('ffmpeg', [
|
||||||
|
'-i', filepath,
|
||||||
|
'-af', `asetnsamples=${Math.floor(metadata.sampleRate * interval)},astats=metadata=1:reset=1`,
|
||||||
|
'-f', 'null',
|
||||||
|
'-'
|
||||||
|
]);
|
||||||
|
|
||||||
|
let stderr = '';
|
||||||
|
const peaks = [];
|
||||||
|
|
||||||
|
ffmpeg.stderr.on('data', (data) => {
|
||||||
|
stderr += data.toString();
|
||||||
|
|
||||||
|
// Extract peak values from metadata
|
||||||
|
const matches = stderr.matchAll(/Peak level dB:\s*(-?\d+\.?\d*)/g);
|
||||||
|
for (const match of matches) {
|
||||||
|
const db = parseFloat(match[1]);
|
||||||
|
// Convert dB to linear (0-1 scale)
|
||||||
|
const linear = Math.pow(10, db / 20);
|
||||||
|
peaks.push(Math.min(1, Math.max(0, linear)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ffmpeg.on('close', () => {
|
||||||
|
// If we didn't get enough peaks, pad with zeros
|
||||||
|
while (peaks.length < samples) {
|
||||||
|
peaks.push(0);
|
||||||
|
}
|
||||||
|
resolve(peaks.slice(0, samples));
|
||||||
|
});
|
||||||
|
|
||||||
|
ffmpeg.on('error', (error) => {
|
||||||
|
reject(new Error(`Failed to get waveform: ${error.message}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the metadata cache
|
||||||
|
*/
|
||||||
|
clearCache() {
|
||||||
|
this.ffprobeCache.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AudioAnalyzer;
|
||||||
403
src/audio/BPMDetector.js
Archivo normal
403
src/audio/BPMDetector.js
Archivo normal
@@ -0,0 +1,403 @@
|
|||||||
|
/**
|
||||||
|
* BPMDetector - Tempo and beat detection for audio files
|
||||||
|
*
|
||||||
|
* Uses multiple detection methods for improved accuracy:
|
||||||
|
* 1. music-tempo for BPM estimation
|
||||||
|
* 2. FFmpeg's ebur128 for onset detection
|
||||||
|
* 3. Autocorrelation for beat phase alignment
|
||||||
|
*
|
||||||
|
* @class BPMDetector
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
import os from 'os';
|
||||||
|
import MusicTempo from 'music-tempo';
|
||||||
|
|
||||||
|
export class BPMDetector {
|
||||||
|
/**
|
||||||
|
* Create a BPMDetector instance
|
||||||
|
* @param {Object} options - Detection options
|
||||||
|
* @param {number} [options.minBPM=60] - Minimum expected BPM
|
||||||
|
* @param {number} [options.maxBPM=200] - Maximum expected BPM
|
||||||
|
*/
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.options = {
|
||||||
|
minBPM: 60,
|
||||||
|
maxBPM: 200,
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract raw PCM audio data from a file using FFmpeg
|
||||||
|
* @param {string} filepath - Path to the audio file
|
||||||
|
* @returns {Promise<Float32Array>} - Mono audio samples
|
||||||
|
*/
|
||||||
|
async extractAudioData(filepath) {
|
||||||
|
const tempFile = path.join(os.tmpdir(), `automixer_${Date.now()}.raw`);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// Use FFmpeg to convert to raw PCM mono audio
|
||||||
|
const ffmpeg = spawn('ffmpeg', [
|
||||||
|
'-i', filepath,
|
||||||
|
'-ac', '1', // Mono
|
||||||
|
'-ar', '44100', // 44.1kHz sample rate
|
||||||
|
'-f', 'f32le', // 32-bit float little-endian
|
||||||
|
'-y', // Overwrite output
|
||||||
|
tempFile
|
||||||
|
]);
|
||||||
|
|
||||||
|
let stderr = '';
|
||||||
|
|
||||||
|
ffmpeg.stderr.on('data', (data) => {
|
||||||
|
stderr += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
ffmpeg.on('close', async (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
reject(new Error(`FFmpeg failed: ${stderr}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const buffer = await fs.readFile(tempFile);
|
||||||
|
await fs.unlink(tempFile);
|
||||||
|
|
||||||
|
// Convert buffer to Float32Array
|
||||||
|
const samples = new Float32Array(buffer.length / 4);
|
||||||
|
for (let i = 0; i < samples.length; i++) {
|
||||||
|
samples[i] = buffer.readFloatLE(i * 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(samples);
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ffmpeg.on('error', (error) => {
|
||||||
|
reject(new Error(`Failed to spawn FFmpeg: ${error.message}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect onsets (transients/kicks) using full-spectrum energy analysis
|
||||||
|
* Detects attack transients across all frequencies for better precision
|
||||||
|
* @param {Float32Array} samples - Audio samples
|
||||||
|
* @param {number} sampleRate - Sample rate
|
||||||
|
* @returns {number[]} - Array of onset timestamps in seconds
|
||||||
|
*/
|
||||||
|
detectOnsets(samples, sampleRate = 44100) {
|
||||||
|
const hopSize = Math.floor(sampleRate * 0.005); // 5ms windows
|
||||||
|
const frameSize = Math.floor(sampleRate * 0.020); // 20ms frame
|
||||||
|
const onsets = [];
|
||||||
|
|
||||||
|
// NO FILTRAR - usar todas las frecuencias
|
||||||
|
// El ataque del kick tiene energía transiente en todo el espectro
|
||||||
|
// especialmente en medios (200-800Hz) donde el "click" del beater es más fuerte
|
||||||
|
|
||||||
|
// Calculate spectral flux (cambio en el espectro = onset)
|
||||||
|
// Usando energía RMS simple pero efectiva
|
||||||
|
const energies = [];
|
||||||
|
for (let i = 0; i < samples.length - frameSize; i += hopSize) {
|
||||||
|
let energy = 0;
|
||||||
|
for (let j = 0; j < frameSize; j++) {
|
||||||
|
energy += samples[i + j] * samples[i + j];
|
||||||
|
}
|
||||||
|
energies.push(Math.sqrt(energy / frameSize)); // RMS
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate spectral flux (onset strength function)
|
||||||
|
// Detecta cambios súbitos en la energía = transientes
|
||||||
|
const onsetStrength = [];
|
||||||
|
for (let i = 1; i < energies.length; i++) {
|
||||||
|
// Solo cambios positivos (aumentos de energía)
|
||||||
|
const diff = energies[i] - energies[i - 1];
|
||||||
|
onsetStrength.push(Math.max(0, diff));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adaptive thresholding usando media móvil
|
||||||
|
const windowSize = 40; // 200ms window
|
||||||
|
const threshold = 3.0; // onset debe ser 3x sobre el promedio local
|
||||||
|
|
||||||
|
for (let i = windowSize; i < onsetStrength.length - windowSize; i++) {
|
||||||
|
// Local mean
|
||||||
|
let localMean = 0;
|
||||||
|
for (let j = i - windowSize; j < i + windowSize; j++) {
|
||||||
|
localMean += onsetStrength[j];
|
||||||
|
}
|
||||||
|
localMean /= (windowSize * 2);
|
||||||
|
|
||||||
|
// Peak detection: debe ser máximo local Y superar threshold
|
||||||
|
const isPeak = onsetStrength[i] > onsetStrength[i - 1] &&
|
||||||
|
onsetStrength[i] > onsetStrength[i + 1] &&
|
||||||
|
onsetStrength[i] > onsetStrength[i - 2] &&
|
||||||
|
onsetStrength[i] > onsetStrength[i + 2];
|
||||||
|
|
||||||
|
if (isPeak && onsetStrength[i] > localMean * threshold && localMean > 0) {
|
||||||
|
const time = ((i + 1) * hopSize) / sampleRate;
|
||||||
|
|
||||||
|
// Evitar detecciones muy cercanas (mínimo 250ms = ~130 BPM en quarter notes)
|
||||||
|
if (onsets.length === 0 || time - onsets[onsets.length - 1] > 0.25) {
|
||||||
|
onsets.push(time);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return onsets;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the phase offset (first beat position) from detected onsets
|
||||||
|
* @param {number[]} onsets - Detected onset times
|
||||||
|
* @param {number} bpm - Detected BPM
|
||||||
|
* @returns {number} - Phase offset in seconds
|
||||||
|
*/
|
||||||
|
findBeatPhase(onsets, bpm) {
|
||||||
|
if (onsets.length < 4) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const beatInterval = 60 / bpm;
|
||||||
|
|
||||||
|
// Count how many onsets align with each possible phase
|
||||||
|
// Use higher resolution (5ms) for more precision
|
||||||
|
const phaseResolution = 0.005; // 5ms resolution for better precision
|
||||||
|
const phaseCounts = {};
|
||||||
|
|
||||||
|
for (const onset of onsets) {
|
||||||
|
// Calculate the phase of this onset
|
||||||
|
const phase = onset % beatInterval;
|
||||||
|
const quantizedPhase = Math.round(phase / phaseResolution) * phaseResolution;
|
||||||
|
|
||||||
|
phaseCounts[quantizedPhase] = (phaseCounts[quantizedPhase] || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the phase with most onsets
|
||||||
|
let bestPhase = 0;
|
||||||
|
let maxCount = 0;
|
||||||
|
|
||||||
|
for (const [phase, count] of Object.entries(phaseCounts)) {
|
||||||
|
if (count > maxCount) {
|
||||||
|
maxCount = count;
|
||||||
|
bestPhase = parseFloat(phase);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestPhase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect BPM and beat positions in an audio file
|
||||||
|
* @param {string} filepath - Path to the audio file
|
||||||
|
* @returns {Promise<{bpm: number, beats: number[]}>} - BPM and beat timestamps
|
||||||
|
*/
|
||||||
|
async detect(filepath) {
|
||||||
|
// Extract audio samples
|
||||||
|
const audioData = await this.extractAudioData(filepath);
|
||||||
|
const sampleRate = 44100;
|
||||||
|
const trackDuration = audioData.length / sampleRate;
|
||||||
|
|
||||||
|
// Use a single, larger section from the middle of the track (most stable rhythm)
|
||||||
|
// Skip first 15 seconds (intro) and take 45 seconds from there
|
||||||
|
const analysisStart = Math.min(15 * sampleRate, Math.floor(audioData.length * 0.1));
|
||||||
|
const analysisLength = Math.min(45 * sampleRate, Math.floor(audioData.length * 0.5));
|
||||||
|
|
||||||
|
const analysisSamples = audioData.slice(analysisStart, analysisStart + analysisLength);
|
||||||
|
|
||||||
|
let bpm = null;
|
||||||
|
|
||||||
|
// Primary detection with analysis section
|
||||||
|
try {
|
||||||
|
const tempo = new MusicTempo(analysisSamples);
|
||||||
|
if (tempo.tempo && !isNaN(tempo.tempo) && tempo.tempo > 0) {
|
||||||
|
bpm = this.normalizeBPM(tempo.tempo);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Primary analysis failed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: try full track if section analysis failed
|
||||||
|
if (bpm === null) {
|
||||||
|
try {
|
||||||
|
const fullTempo = new MusicTempo(audioData);
|
||||||
|
if (fullTempo.tempo && !isNaN(fullTempo.tempo) && fullTempo.tempo > 0) {
|
||||||
|
bpm = this.normalizeBPM(fullTempo.tempo);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Full track analysis also failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final fallback
|
||||||
|
if (bpm === null || isNaN(bpm) || bpm <= 0) {
|
||||||
|
bpm = 128;
|
||||||
|
}
|
||||||
|
|
||||||
|
bpm = Math.round(bpm * 10) / 10;
|
||||||
|
|
||||||
|
// Detect onsets (kicks) across the first 60 seconds for phase detection
|
||||||
|
const onsetSectionLength = Math.min(60 * sampleRate, audioData.length);
|
||||||
|
const onsetSection = audioData.slice(0, onsetSectionLength);
|
||||||
|
const rawOnsets = this.detectOnsets(onsetSection, sampleRate);
|
||||||
|
|
||||||
|
// Find the phase using the FIRST onset
|
||||||
|
// Este es el enfoque más simple y consistente
|
||||||
|
const beatInterval = 60 / bpm;
|
||||||
|
let phase = 0;
|
||||||
|
|
||||||
|
if (rawOnsets.length > 0) {
|
||||||
|
// Normalizar fase al rango [0, beatInterval)
|
||||||
|
phase = rawOnsets[0] % beatInterval;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a perfect beat grid aligned to the detected phase
|
||||||
|
const beats = this.generateAlignedBeats(rawOnsets[0] || 0, bpm, trackDuration);
|
||||||
|
|
||||||
|
return {
|
||||||
|
bpm,
|
||||||
|
beats,
|
||||||
|
confidence: rawOnsets.length > 10 ? 0.9 : 0.7,
|
||||||
|
phase,
|
||||||
|
onsets: rawOnsets
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter detected onsets to match expected BPM
|
||||||
|
* Removes onsets that don't align with the beat grid
|
||||||
|
* @param {number[]} onsets - Raw detected onsets
|
||||||
|
* @param {number} beatInterval - Expected beat interval in seconds
|
||||||
|
* @returns {number[]} - Filtered onsets aligned to beat grid
|
||||||
|
*/
|
||||||
|
filterOnsetsToBPM(onsets, beatInterval) {
|
||||||
|
if (onsets.length < 2) return onsets;
|
||||||
|
|
||||||
|
const filtered = [];
|
||||||
|
const tolerance = beatInterval * 0.20; // 20% tolerance
|
||||||
|
|
||||||
|
// Start with the first onset
|
||||||
|
filtered.push(onsets[0]);
|
||||||
|
let lastFilteredOnset = onsets[0];
|
||||||
|
|
||||||
|
for (let i = 1; i < onsets.length; i++) {
|
||||||
|
const timeSinceLast = onsets[i] - lastFilteredOnset;
|
||||||
|
|
||||||
|
// Check if this onset is roughly on a beat (1, 2, 3, or 4 beats away)
|
||||||
|
let isOnBeat = false;
|
||||||
|
for (let beats = 1; beats <= 8; beats++) { // Allow up to 8 beats gap
|
||||||
|
const expectedTime = beatInterval * beats;
|
||||||
|
if (Math.abs(timeSinceLast - expectedTime) < tolerance) {
|
||||||
|
isOnBeat = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOnBeat) {
|
||||||
|
filtered.push(onsets[i]);
|
||||||
|
lastFilteredOnset = onsets[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate beat grid aligned with detected phase
|
||||||
|
* @param {number} phase - Phase offset in seconds
|
||||||
|
* @param {number} bpm - BPM
|
||||||
|
* @param {number} trackDuration - Track duration in seconds
|
||||||
|
* @returns {number[]} - Array of beat timestamps
|
||||||
|
*/
|
||||||
|
generateAlignedBeats(phase, bpm, trackDuration) {
|
||||||
|
const beatInterval = 60 / bpm;
|
||||||
|
const beats = [];
|
||||||
|
|
||||||
|
// Start from the phase offset
|
||||||
|
let time = phase;
|
||||||
|
|
||||||
|
// If phase is too large, find the first beat
|
||||||
|
while (time > beatInterval) {
|
||||||
|
time -= beatInterval;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate all beats
|
||||||
|
while (time < trackDuration) {
|
||||||
|
if (time >= 0) {
|
||||||
|
beats.push(Math.round(time * 1000) / 1000);
|
||||||
|
}
|
||||||
|
time += beatInterval;
|
||||||
|
}
|
||||||
|
|
||||||
|
return beats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize BPM to a standard range
|
||||||
|
* Handles cases where detected BPM is half or double the actual tempo
|
||||||
|
* @param {number} bpm - Detected BPM
|
||||||
|
* @returns {number} - Normalized BPM
|
||||||
|
*/
|
||||||
|
normalizeBPM(bpm) {
|
||||||
|
// Normalize to our expected range
|
||||||
|
while (bpm < this.options.minBPM && bpm > 0) {
|
||||||
|
bpm *= 2;
|
||||||
|
}
|
||||||
|
while (bpm > this.options.maxBPM) {
|
||||||
|
bpm /= 2;
|
||||||
|
}
|
||||||
|
return bpm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrapolate beats across the full track
|
||||||
|
* @param {number[]} detectedBeats - Beats detected in the analysis section
|
||||||
|
* @param {number} bpm - Detected BPM
|
||||||
|
* @param {number} trackDuration - Total track duration in seconds
|
||||||
|
* @returns {number[]} - Full array of beat timestamps
|
||||||
|
*/
|
||||||
|
extrapolateBeats(detectedBeats, bpm, trackDuration) {
|
||||||
|
if (detectedBeats.length < 2) {
|
||||||
|
// Generate beats from scratch based on BPM
|
||||||
|
const beatInterval = 60 / bpm;
|
||||||
|
const beats = [];
|
||||||
|
let time = 0;
|
||||||
|
while (time < trackDuration) {
|
||||||
|
beats.push(time);
|
||||||
|
time += beatInterval;
|
||||||
|
}
|
||||||
|
return beats;
|
||||||
|
}
|
||||||
|
|
||||||
|
const beatInterval = 60 / bpm;
|
||||||
|
const beats = [];
|
||||||
|
|
||||||
|
// Find the first beat position
|
||||||
|
const firstBeat = detectedBeats[0] % beatInterval;
|
||||||
|
|
||||||
|
// Generate beats for the full track
|
||||||
|
let time = firstBeat;
|
||||||
|
while (time < trackDuration) {
|
||||||
|
beats.push(Math.round(time * 1000) / 1000); // Round to milliseconds
|
||||||
|
time += beatInterval;
|
||||||
|
}
|
||||||
|
|
||||||
|
return beats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quick BPM detection using a smaller sample
|
||||||
|
* Useful for getting a rough estimate quickly
|
||||||
|
* @param {string} filepath - Path to the audio file
|
||||||
|
* @returns {Promise<number>} - Estimated BPM
|
||||||
|
*/
|
||||||
|
async quickDetect(filepath) {
|
||||||
|
const { bpm } = await this.detect(filepath);
|
||||||
|
return bpm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BPMDetector;
|
||||||
332
src/audio/PitchShifter.js
Archivo normal
332
src/audio/PitchShifter.js
Archivo normal
@@ -0,0 +1,332 @@
|
|||||||
|
/**
|
||||||
|
* PitchShifter - Tempo and pitch adjustment for audio files
|
||||||
|
*
|
||||||
|
* Uses FFmpeg's audio filters to adjust tempo while optionally
|
||||||
|
* preserving pitch, enabling beat-matched mixing.
|
||||||
|
*
|
||||||
|
* @class PitchShifter
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
import path from 'path';
|
||||||
|
import os from 'os';
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
|
||||||
|
export class PitchShifter {
|
||||||
|
/**
|
||||||
|
* Create a PitchShifter instance
|
||||||
|
* @param {Object} options - Shifter options
|
||||||
|
*/
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.options = {
|
||||||
|
tempDir: os.tmpdir(),
|
||||||
|
outputFormat: 'mp3',
|
||||||
|
outputBitrate: 320,
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adjust tempo of an audio file
|
||||||
|
* @param {string} inputPath - Input file path
|
||||||
|
* @param {number} tempoRatio - Tempo multiplier (1.0 = no change, 1.1 = 10% faster)
|
||||||
|
* @param {boolean} preservePitch - Whether to preserve pitch
|
||||||
|
* @returns {Promise<string>} - Path to the processed file
|
||||||
|
*/
|
||||||
|
async adjustTempo(inputPath, tempoRatio, preservePitch = true) {
|
||||||
|
// Usar WAV sin compresión para archivos intermedios (mejor sincronización de beats)
|
||||||
|
const outputPath = path.join(
|
||||||
|
this.options.tempDir,
|
||||||
|
`automixer_tempo_${Date.now()}_${Math.random().toString(36).substr(2, 9)}.wav`
|
||||||
|
);
|
||||||
|
|
||||||
|
// FFmpeg's atempo filter only accepts values between 0.5 and 2.0
|
||||||
|
// For larger changes, we need to chain multiple atempo filters
|
||||||
|
const atempoFilters = this.buildAtempoChain(tempoRatio);
|
||||||
|
|
||||||
|
let filterComplex;
|
||||||
|
|
||||||
|
if (preservePitch) {
|
||||||
|
// Use rubberband for high-quality pitch-preserved tempo change
|
||||||
|
// Fall back to atempo if rubberband is not available
|
||||||
|
filterComplex = `rubberband=tempo=${tempoRatio}:pitch=1`;
|
||||||
|
} else {
|
||||||
|
// Simple atempo changes pitch along with tempo
|
||||||
|
filterComplex = atempoFilters;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// Usar PCM sin compresión para máxima precisión temporal
|
||||||
|
const args = [
|
||||||
|
'-i', inputPath,
|
||||||
|
'-af', filterComplex,
|
||||||
|
'-c:a', 'pcm_s24le',
|
||||||
|
'-ar', '48000',
|
||||||
|
'-y',
|
||||||
|
outputPath
|
||||||
|
];
|
||||||
|
|
||||||
|
const ffmpeg = spawn('ffmpeg', args);
|
||||||
|
|
||||||
|
let stderr = '';
|
||||||
|
|
||||||
|
ffmpeg.stderr.on('data', (data) => {
|
||||||
|
stderr += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
ffmpeg.on('close', async (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
// If rubberband failed, try with atempo
|
||||||
|
if (preservePitch && stderr.includes('rubberband')) {
|
||||||
|
try {
|
||||||
|
const result = await this.adjustTempoWithAtempo(inputPath, tempoRatio, outputPath);
|
||||||
|
resolve(result);
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
reject(new Error(`Tempo adjustment failed: ${e.message}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reject(new Error(`Tempo adjustment failed: ${stderr}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(outputPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
ffmpeg.on('error', (error) => {
|
||||||
|
reject(new Error(`Failed to spawn FFmpeg: ${error.message}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adjust tempo using atempo filter (fallback)
|
||||||
|
* @param {string} inputPath - Input file path
|
||||||
|
* @param {number} tempoRatio - Tempo multiplier
|
||||||
|
* @param {string} outputPath - Output file path
|
||||||
|
* @returns {Promise<string>} - Path to the processed file
|
||||||
|
*/
|
||||||
|
async adjustTempoWithAtempo(inputPath, tempoRatio, outputPath) {
|
||||||
|
const atempoFilters = this.buildAtempoChain(tempoRatio);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// Usar PCM sin compresión para máxima precisión temporal
|
||||||
|
const args = [
|
||||||
|
'-i', inputPath,
|
||||||
|
'-af', atempoFilters,
|
||||||
|
'-c:a', 'pcm_s24le',
|
||||||
|
'-ar', '48000',
|
||||||
|
'-y',
|
||||||
|
outputPath
|
||||||
|
];
|
||||||
|
|
||||||
|
const ffmpeg = spawn('ffmpeg', args);
|
||||||
|
|
||||||
|
let stderr = '';
|
||||||
|
|
||||||
|
ffmpeg.stderr.on('data', (data) => {
|
||||||
|
stderr += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
ffmpeg.on('close', (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
reject(new Error(`Atempo adjustment failed: ${stderr}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(outputPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
ffmpeg.on('error', (error) => {
|
||||||
|
reject(new Error(`Failed to spawn FFmpeg: ${error.message}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a chain of atempo filters for the given ratio
|
||||||
|
* Each atempo filter can only handle 0.5-2.0 range
|
||||||
|
* @param {number} ratio - Target tempo ratio
|
||||||
|
* @returns {string} - FFmpeg atempo filter chain
|
||||||
|
*/
|
||||||
|
buildAtempoChain(ratio) {
|
||||||
|
const filters = [];
|
||||||
|
let remaining = ratio;
|
||||||
|
|
||||||
|
while (remaining > 2.0 || remaining < 0.5) {
|
||||||
|
if (remaining > 2.0) {
|
||||||
|
filters.push('atempo=2.0');
|
||||||
|
remaining /= 2.0;
|
||||||
|
} else if (remaining < 0.5) {
|
||||||
|
filters.push('atempo=0.5');
|
||||||
|
remaining /= 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filters.push(`atempo=${remaining}`);
|
||||||
|
return filters.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shift pitch without changing tempo
|
||||||
|
* @param {string} inputPath - Input file path
|
||||||
|
* @param {number} semitones - Pitch shift in semitones
|
||||||
|
* @returns {Promise<string>} - Path to the processed file
|
||||||
|
*/
|
||||||
|
async shiftPitch(inputPath, semitones) {
|
||||||
|
// Usar WAV sin compresión para archivos intermedios
|
||||||
|
const outputPath = path.join(
|
||||||
|
this.options.tempDir,
|
||||||
|
`automixer_pitch_${Date.now()}_${Math.random().toString(36).substr(2, 9)}.wav`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate pitch ratio from semitones
|
||||||
|
const pitchRatio = Math.pow(2, semitones / 12);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// Use rubberband for pitch shifting con PCM sin compresión
|
||||||
|
const args = [
|
||||||
|
'-i', inputPath,
|
||||||
|
'-af', `rubberband=pitch=${pitchRatio}:tempo=1`,
|
||||||
|
'-c:a', 'pcm_s24le',
|
||||||
|
'-ar', '48000',
|
||||||
|
'-y',
|
||||||
|
outputPath
|
||||||
|
];
|
||||||
|
|
||||||
|
const ffmpeg = spawn('ffmpeg', args);
|
||||||
|
|
||||||
|
// Capture stderr for debugging (used implicitly in error fallback)
|
||||||
|
ffmpeg.stderr.on('data', () => {
|
||||||
|
// Stderr captured but not logged - used only for debugging
|
||||||
|
});
|
||||||
|
|
||||||
|
ffmpeg.on('close', async (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
// Fallback: use asetrate + atempo combination
|
||||||
|
try {
|
||||||
|
const result = await this.shiftPitchFallback(inputPath, semitones, outputPath);
|
||||||
|
resolve(result);
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
reject(new Error(`Pitch shift failed: ${err.message}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolve(outputPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
ffmpeg.on('error', (error) => {
|
||||||
|
reject(new Error(`Failed to spawn FFmpeg: ${error.message}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback pitch shifting using asetrate + atempo
|
||||||
|
* @param {string} inputPath - Input file path
|
||||||
|
* @param {number} semitones - Pitch shift in semitones
|
||||||
|
* @param {string} outputPath - Output file path
|
||||||
|
* @returns {Promise<string>} - Path to the processed file
|
||||||
|
*/
|
||||||
|
async shiftPitchFallback(inputPath, semitones, outputPath) {
|
||||||
|
const pitchRatio = Math.pow(2, semitones / 12);
|
||||||
|
const atempoFilters = this.buildAtempoChain(1 / pitchRatio);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// asetrate changes pitch, then atempo corrects the tempo
|
||||||
|
// Usar PCM sin compresión para máxima precisión
|
||||||
|
const args = [
|
||||||
|
'-i', inputPath,
|
||||||
|
'-af', `asetrate=44100*${pitchRatio},${atempoFilters},aresample=48000`,
|
||||||
|
'-c:a', 'pcm_s24le',
|
||||||
|
'-y',
|
||||||
|
outputPath
|
||||||
|
];
|
||||||
|
|
||||||
|
const ffmpeg = spawn('ffmpeg', args);
|
||||||
|
|
||||||
|
let stderr = '';
|
||||||
|
|
||||||
|
ffmpeg.stderr.on('data', (data) => {
|
||||||
|
stderr += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
ffmpeg.on('close', (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
reject(new Error(`Pitch shift fallback failed: ${stderr}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(outputPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
ffmpeg.on('error', (error) => {
|
||||||
|
reject(new Error(`Failed to spawn FFmpeg: ${error.message}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adjust both tempo and pitch simultaneously
|
||||||
|
* @param {string} inputPath - Input file path
|
||||||
|
* @param {number} tempoRatio - Tempo multiplier
|
||||||
|
* @param {number} pitchSemitones - Pitch shift in semitones
|
||||||
|
* @returns {Promise<string>} - Path to the processed file
|
||||||
|
*/
|
||||||
|
async adjustTempoAndPitch(inputPath, tempoRatio, pitchSemitones) {
|
||||||
|
// Usar WAV sin compresión para archivos intermedios
|
||||||
|
const outputPath = path.join(
|
||||||
|
this.options.tempDir,
|
||||||
|
`automixer_both_${Date.now()}_${Math.random().toString(36).substr(2, 9)}.wav`
|
||||||
|
);
|
||||||
|
|
||||||
|
const pitchRatio = Math.pow(2, pitchSemitones / 12);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// Usar PCM sin compresión para máxima precisión
|
||||||
|
const args = [
|
||||||
|
'-i', inputPath,
|
||||||
|
'-af', `rubberband=tempo=${tempoRatio}:pitch=${pitchRatio}`,
|
||||||
|
'-c:a', 'pcm_s24le',
|
||||||
|
'-ar', '48000',
|
||||||
|
'-y',
|
||||||
|
outputPath
|
||||||
|
];
|
||||||
|
|
||||||
|
const ffmpeg = spawn('ffmpeg', args);
|
||||||
|
|
||||||
|
let stderr = '';
|
||||||
|
|
||||||
|
ffmpeg.stderr.on('data', (data) => {
|
||||||
|
stderr += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
ffmpeg.on('close', (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
reject(new Error(`Tempo and pitch adjustment failed: ${stderr}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(outputPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
ffmpeg.on('error', (error) => {
|
||||||
|
reject(new Error(`Failed to spawn FFmpeg: ${error.message}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up temporary files
|
||||||
|
* @param {string[]} files - Array of file paths to delete
|
||||||
|
*/
|
||||||
|
async cleanup(files) {
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
await fs.unlink(file);
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PitchShifter;
|
||||||
507
src/audio/TrackMixer.js
Archivo normal
507
src/audio/TrackMixer.js
Archivo normal
@@ -0,0 +1,507 @@
|
|||||||
|
/**
|
||||||
|
* TrackMixer - Audio track mixing and crossfading
|
||||||
|
*
|
||||||
|
* Handles the actual audio mixing process using FFmpeg,
|
||||||
|
* including crossfades, volume adjustments, and track concatenation.
|
||||||
|
*
|
||||||
|
* @class TrackMixer
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
import path from 'path';
|
||||||
|
import os from 'os';
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
|
||||||
|
export class TrackMixer {
|
||||||
|
/**
|
||||||
|
* Create a TrackMixer instance
|
||||||
|
* @param {Object} options - Mixer options
|
||||||
|
*/
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.options = {
|
||||||
|
crossfadeDuration: 8, // Shorter crossfade for faster transitions
|
||||||
|
outputFormat: 'mp3',
|
||||||
|
outputBitrate: 320,
|
||||||
|
crossfadeCurve: 'log', // 'linear', 'log', 'sqrt'
|
||||||
|
introFadeDuration: 3, // Fade in at the start
|
||||||
|
outroFadeDuration: 3, // Longer fade out at the end
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mix multiple tracks with crossfades
|
||||||
|
* @param {Object[]} tracks - Array of track objects with processedPath
|
||||||
|
* @param {Object[]} transitions - Array of transition points
|
||||||
|
* @param {string} outputPath - Output file path
|
||||||
|
* @param {Function} progressCallback - Progress update callback
|
||||||
|
* @returns {Promise<string>} - Path to output file
|
||||||
|
*/
|
||||||
|
async mixTracks(tracks, transitions, outputPath, progressCallback = () => {}) {
|
||||||
|
if (tracks.length === 0) {
|
||||||
|
throw new Error('No tracks to mix');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tracks.length === 1) {
|
||||||
|
// Single track - just add fades
|
||||||
|
await this.addFades(tracks[0].processedPath, outputPath);
|
||||||
|
return outputPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For complex mixing with multiple tracks, we'll chain pairs
|
||||||
|
let currentMix = tracks[0].processedPath;
|
||||||
|
let currentDuration = tracks[0].adjustedDuration || tracks[0].duration;
|
||||||
|
const tempFiles = [];
|
||||||
|
const crossfadeDuration = this.options.crossfadeDuration;
|
||||||
|
|
||||||
|
for (let i = 0; i < tracks.length - 1; i++) {
|
||||||
|
const trackB = tracks[i + 1];
|
||||||
|
const transition = transitions[i];
|
||||||
|
|
||||||
|
// Calculate effective transition points
|
||||||
|
let effectiveTransition;
|
||||||
|
|
||||||
|
if (i === 0) {
|
||||||
|
// First mix: use calculated transition points directly (beat-aligned)
|
||||||
|
effectiveTransition = {
|
||||||
|
outPoint: transition.outPoint,
|
||||||
|
inPoint: transition.inPoint,
|
||||||
|
beatIntervalA: transition.beatIntervalA,
|
||||||
|
beatIntervalB: transition.beatIntervalB
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Subsequent mixes: outPoint is near the end of current combined mix
|
||||||
|
// The current mix ends at a beat-aligned position from previous iteration
|
||||||
|
const mixZoneStart = currentDuration * 0.65;
|
||||||
|
const mixZoneEnd = currentDuration * 0.80;
|
||||||
|
|
||||||
|
// Use the target BPM's beat interval for alignment
|
||||||
|
const beatInterval = transition.beatIntervalB || (60 / 130);
|
||||||
|
const targetPoint = (mixZoneStart + mixZoneEnd) / 2;
|
||||||
|
const alignedOutPoint = Math.round(targetPoint / beatInterval) * beatInterval;
|
||||||
|
|
||||||
|
// For inPoint, use the same phase offset calculated for this track
|
||||||
|
// This ensures beats align even when chaining
|
||||||
|
effectiveTransition = {
|
||||||
|
outPoint: Math.max(mixZoneStart, Math.min(mixZoneEnd, alignedOutPoint)),
|
||||||
|
inPoint: transition.inPoint,
|
||||||
|
beatIntervalA: beatInterval,
|
||||||
|
beatIntervalB: transition.beatIntervalB
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFirst = i === 0;
|
||||||
|
const isLast = i === tracks.length - 2;
|
||||||
|
// Usar WAV sin compresión para archivos intermedios (mejor sincronización)
|
||||||
|
// Solo el archivo final será MP3
|
||||||
|
const tempOutput = isLast
|
||||||
|
? outputPath
|
||||||
|
: path.join(os.tmpdir(), `automixer_mix_${Date.now()}_${i}.wav`);
|
||||||
|
|
||||||
|
if (!isLast) {
|
||||||
|
tempFiles.push(tempOutput);
|
||||||
|
}
|
||||||
|
|
||||||
|
progressCallback({
|
||||||
|
stage: 'mixing',
|
||||||
|
current: i + 1,
|
||||||
|
total: tracks.length - 1,
|
||||||
|
message: `Mixing track ${i + 1} with track ${i + 2}`
|
||||||
|
});
|
||||||
|
|
||||||
|
const trackBDuration = trackB.adjustedDuration || trackB.duration;
|
||||||
|
|
||||||
|
await this.crossfadeTracks(
|
||||||
|
currentMix,
|
||||||
|
trackB.processedPath,
|
||||||
|
tempOutput,
|
||||||
|
effectiveTransition,
|
||||||
|
{
|
||||||
|
trackADuration: currentDuration,
|
||||||
|
trackBDuration: trackBDuration,
|
||||||
|
isFirst,
|
||||||
|
isLast
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update current mix path and calculate new combined duration
|
||||||
|
// Duración = (track A hasta outPoint) + crossfade + (track B desde inPoint)
|
||||||
|
const trackBRemainder = trackBDuration - effectiveTransition.inPoint;
|
||||||
|
currentDuration = effectiveTransition.outPoint + crossfadeDuration + trackBRemainder;
|
||||||
|
currentMix = tempOutput;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup temp files
|
||||||
|
for (const tempFile of tempFiles) {
|
||||||
|
try {
|
||||||
|
await fs.unlink(tempFile);
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return outputPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add fade in/out to a single track
|
||||||
|
* @param {string} inputPath - Input file path
|
||||||
|
* @param {string} outputPath - Output file path
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async addFades(inputPath, outputPath) {
|
||||||
|
const introFade = this.options.introFadeDuration || 3;
|
||||||
|
const outroFade = this.options.outroFadeDuration || 5;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const args = [
|
||||||
|
'-i', inputPath,
|
||||||
|
'-af', `afade=t=in:st=0:d=${introFade},areverse,afade=t=in:st=0:d=${outroFade},areverse`,
|
||||||
|
'-c:a', 'libmp3lame',
|
||||||
|
'-b:a', `${this.options.outputBitrate}k`,
|
||||||
|
'-y',
|
||||||
|
outputPath
|
||||||
|
];
|
||||||
|
|
||||||
|
const ffmpeg = spawn('ffmpeg', args);
|
||||||
|
let stderr = '';
|
||||||
|
|
||||||
|
ffmpeg.stderr.on('data', (data) => {
|
||||||
|
stderr += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
ffmpeg.on('close', (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
reject(new Error(`FFmpeg fade failed: ${stderr}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
ffmpeg.on('error', (error) => {
|
||||||
|
reject(new Error(`Failed to spawn FFmpeg: ${error.message}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crossfade two tracks together with beat synchronization
|
||||||
|
* @param {string} trackAPath - Path to first track
|
||||||
|
* @param {string} trackBPath - Path to second track
|
||||||
|
* @param {string} outputPath - Path for output
|
||||||
|
* @param {Object} transition - Transition points { outPoint, inPoint, beatOffset }
|
||||||
|
* @param {Object} options - Additional options (isFirst, isLast)
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async crossfadeTracks(trackAPath, trackBPath, outputPath, transition, options = {}) {
|
||||||
|
const { outPoint, inPoint } = transition;
|
||||||
|
const { isLast = true } = options;
|
||||||
|
const crossfadeDuration = this.options.crossfadeDuration;
|
||||||
|
|
||||||
|
// Calculate the actual crossfade start point in track A
|
||||||
|
const fadeOutStart = Math.max(0, outPoint);
|
||||||
|
|
||||||
|
// Build the FFmpeg filter complex
|
||||||
|
const filterComplex = this.buildCrossfadeFilter(
|
||||||
|
fadeOutStart,
|
||||||
|
inPoint,
|
||||||
|
crossfadeDuration,
|
||||||
|
this.options.crossfadeCurve,
|
||||||
|
{ isLast }
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timestamp = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
// Determinar si el output es el archivo final (MP3) o intermedio (WAV)
|
||||||
|
const isFinalOutput = outputPath.toLowerCase().endsWith('.mp3');
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
'-i', trackAPath,
|
||||||
|
'-i', trackBPath,
|
||||||
|
'-filter_complex', filterComplex,
|
||||||
|
'-map', '[out]'
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isFinalOutput) {
|
||||||
|
// Archivo final: codificar a MP3 con metadata
|
||||||
|
args.push(
|
||||||
|
'-c:a', 'libmp3lame',
|
||||||
|
'-b:a', `${this.options.outputBitrate}k`,
|
||||||
|
'-metadata', `title=automixer-${timestamp}`,
|
||||||
|
'-metadata', 'artist=automixer',
|
||||||
|
'-metadata', `album=automixer-${timestamp}`,
|
||||||
|
'-metadata', `comment=Generated by automixer at ${timestamp}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Archivo intermedio: usar WAV PCM sin compresión para máxima precisión
|
||||||
|
args.push(
|
||||||
|
'-c:a', 'pcm_s24le',
|
||||||
|
'-ar', '48000'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
args.push('-y', outputPath);
|
||||||
|
|
||||||
|
const ffmpeg = spawn('ffmpeg', args);
|
||||||
|
|
||||||
|
let stderr = '';
|
||||||
|
|
||||||
|
ffmpeg.stderr.on('data', (data) => {
|
||||||
|
stderr += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
ffmpeg.on('close', (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
reject(new Error(`FFmpeg crossfade failed: ${stderr}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
ffmpeg.on('error', (error) => {
|
||||||
|
reject(new Error(`Failed to spawn FFmpeg: ${error.message}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build FFmpeg filter complex for DJ-style EQ crossfade
|
||||||
|
*
|
||||||
|
* Técnica de mezcla DJ real:
|
||||||
|
* 1. Track A suena completo (graves, medios, agudos)
|
||||||
|
* 2. Track B entra SIN GRAVES (solo medios/agudos), sincronizado al beat
|
||||||
|
* 3. Gradualmente: quitas graves de A + subes graves de B (EQ crossfade)
|
||||||
|
* 4. Track A hace fade-out suave mientras B ya tiene el control
|
||||||
|
*
|
||||||
|
* @param {number} fadeStart - Start of crossfade in track A (seconds from start)
|
||||||
|
* @param {number} inPoint - Start point in track B to begin crossfade
|
||||||
|
* @param {number} duration - Crossfade duration
|
||||||
|
* @param {string} curve - Fade curve type
|
||||||
|
* @param {Object} options - { isLast }
|
||||||
|
* @returns {string} - FFmpeg filter complex string
|
||||||
|
*/
|
||||||
|
buildCrossfadeFilter(fadeStart, inPoint, duration, curve = 'log', options = {}) {
|
||||||
|
const curveType = this.getCurveType(curve);
|
||||||
|
const { isLast = true } = options;
|
||||||
|
|
||||||
|
// Frecuencia de corte para separar graves de medios/agudos
|
||||||
|
const bassFreq = 180;
|
||||||
|
|
||||||
|
// Fases del crossfade:
|
||||||
|
// Fase 1 (0-15%): Track B entra sin graves (solo hi-hats, melodia)
|
||||||
|
// Fase 2 (15-25%): EQ crossfade breve de graves
|
||||||
|
// Fase 3: Track A hace fade-out gradual desde el inicio
|
||||||
|
const phase1End = duration * 0.15;
|
||||||
|
const phase2End = duration * 0.25;
|
||||||
|
const fadeOutStart = 0; // Comenzar fade-out desde el inicio del crossfade
|
||||||
|
const fadeOutDuration = duration * 0.7; // Fade-out durante 70% del crossfade
|
||||||
|
|
||||||
|
// Los puntos ya incluyen compensación de latencia de detección
|
||||||
|
// Ahora solo aplicamos los tiempos directamente
|
||||||
|
|
||||||
|
const aFadeStart = fadeStart;
|
||||||
|
const aFadeEnd = fadeStart + duration;
|
||||||
|
const bFadeStart = inPoint;
|
||||||
|
const bFadeEnd = inPoint + duration;
|
||||||
|
const bPostStart = bFadeEnd;
|
||||||
|
|
||||||
|
const filters = [];
|
||||||
|
|
||||||
|
// === TRACK A: Parte antes del crossfade ===
|
||||||
|
filters.push(`[0:a]atrim=0:${aFadeStart},asetpts=PTS-STARTPTS[a_pre]`);
|
||||||
|
|
||||||
|
// === TRACK A durante crossfade ===
|
||||||
|
// Graves de A: fade out gradual durante fase 2-3
|
||||||
|
filters.push(
|
||||||
|
`[0:a]atrim=${aFadeStart}:${aFadeEnd},asetpts=PTS-STARTPTS,` +
|
||||||
|
`lowpass=f=${bassFreq},` +
|
||||||
|
`afade=t=out:st=${phase1End}:d=${phase2End - phase1End}:curve=${curveType}[a_bass]`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Medios/agudos de A: fade out suave y prolongado en fase 3
|
||||||
|
filters.push(
|
||||||
|
`[0:a]atrim=${aFadeStart}:${aFadeEnd},asetpts=PTS-STARTPTS,` +
|
||||||
|
`highpass=f=${bassFreq},` +
|
||||||
|
`afade=t=out:st=${fadeOutStart}:d=${fadeOutDuration}:curve=${curveType}[a_mid_high]`
|
||||||
|
);
|
||||||
|
|
||||||
|
// === TRACK B durante crossfade ===
|
||||||
|
// Medios/agudos de B: entran desde el inicio
|
||||||
|
filters.push(
|
||||||
|
`[1:a]atrim=${bFadeStart}:${bFadeEnd},asetpts=PTS-STARTPTS,` +
|
||||||
|
`highpass=f=${bassFreq},` +
|
||||||
|
`afade=t=in:st=0:d=${phase1End}:curve=${curveType}[b_mid_high]`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Graves de B: entran en fase 2
|
||||||
|
filters.push(
|
||||||
|
`[1:a]atrim=${bFadeStart}:${bFadeEnd},asetpts=PTS-STARTPTS,` +
|
||||||
|
`lowpass=f=${bassFreq},` +
|
||||||
|
`afade=t=in:st=${phase1End}:d=${phase2End - phase1End}:curve=${curveType}[b_bass]`
|
||||||
|
);
|
||||||
|
|
||||||
|
// === TRACK B: Parte después del crossfade ===
|
||||||
|
filters.push(`[1:a]atrim=${bPostStart},asetpts=PTS-STARTPTS[b_post]`);
|
||||||
|
|
||||||
|
// === MEZCLA durante crossfade ===
|
||||||
|
filters.push(
|
||||||
|
'[a_bass][a_mid_high][b_mid_high][b_bass]amix=inputs=4:duration=longest:normalize=0[crossfade_mix]'
|
||||||
|
);
|
||||||
|
|
||||||
|
// === CONCATENACIÓN FINAL ===
|
||||||
|
if (isLast) {
|
||||||
|
const outroFade = this.options.outroFadeDuration || 5;
|
||||||
|
filters.push('[a_pre][crossfade_mix][b_post]concat=n=3:v=0:a=1[pre_out]');
|
||||||
|
filters.push(`[pre_out]areverse,afade=t=in:st=0:d=${outroFade}:curve=log,areverse[out]`);
|
||||||
|
} else {
|
||||||
|
filters.push('[a_pre][crossfade_mix][b_post]concat=n=3:v=0:a=1[out]');
|
||||||
|
}
|
||||||
|
|
||||||
|
return filters.join(';');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get FFmpeg curve type name
|
||||||
|
* @param {string} curve - Curve type
|
||||||
|
* @returns {string} - FFmpeg curve name
|
||||||
|
*/
|
||||||
|
getCurveType(curve) {
|
||||||
|
const curves = {
|
||||||
|
linear: 'tri',
|
||||||
|
log: 'log',
|
||||||
|
sqrt: 'qsin',
|
||||||
|
sine: 'hsin',
|
||||||
|
exponential: 'exp'
|
||||||
|
};
|
||||||
|
return curves[curve] || 'log';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply volume normalization to a track
|
||||||
|
* @param {string} inputPath - Input file path
|
||||||
|
* @param {string} outputPath - Output file path
|
||||||
|
* @param {number} targetLUFS - Target loudness in LUFS
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async normalizeVolume(inputPath, outputPath, targetLUFS = -14) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// First pass: analyze loudness
|
||||||
|
const analyzeArgs = [
|
||||||
|
'-i', inputPath,
|
||||||
|
'-af', `loudnorm=I=${targetLUFS}:TP=-2:LRA=11:print_format=json`,
|
||||||
|
'-f', 'null',
|
||||||
|
'-'
|
||||||
|
];
|
||||||
|
|
||||||
|
const analyze = spawn('ffmpeg', analyzeArgs);
|
||||||
|
let stderr = '';
|
||||||
|
|
||||||
|
analyze.stderr.on('data', (data) => {
|
||||||
|
stderr += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
analyze.on('close', async (_code) => {
|
||||||
|
// Parse loudness info from output
|
||||||
|
const inputI = stderr.match(/"input_i"\s*:\s*"(-?\d+\.?\d*)"/);
|
||||||
|
const inputTP = stderr.match(/"input_tp"\s*:\s*"(-?\d+\.?\d*)"/);
|
||||||
|
const inputLRA = stderr.match(/"input_lra"\s*:\s*"(-?\d+\.?\d*)"/);
|
||||||
|
const inputThresh = stderr.match(/"input_thresh"\s*:\s*"(-?\d+\.?\d*)"/);
|
||||||
|
|
||||||
|
if (!inputI) {
|
||||||
|
// If analysis failed, just copy the file
|
||||||
|
await fs.copyFile(inputPath, outputPath);
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: apply normalization
|
||||||
|
const normalizeArgs = [
|
||||||
|
'-i', inputPath,
|
||||||
|
'-af', `loudnorm=I=${targetLUFS}:TP=-2:LRA=11:measured_I=${inputI[1]}:measured_TP=${inputTP?.[1] || '-1'}:measured_LRA=${inputLRA?.[1] || '11'}:measured_thresh=${inputThresh?.[1] || '-40'}:offset=0:linear=true`,
|
||||||
|
'-c:a', 'libmp3lame',
|
||||||
|
'-b:a', `${this.options.outputBitrate}k`,
|
||||||
|
'-y',
|
||||||
|
outputPath
|
||||||
|
];
|
||||||
|
|
||||||
|
const normalize = spawn('ffmpeg', normalizeArgs);
|
||||||
|
let normStderr = '';
|
||||||
|
|
||||||
|
normalize.stderr.on('data', (data) => {
|
||||||
|
normStderr += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
normalize.on('close', (normCode) => {
|
||||||
|
if (normCode !== 0) {
|
||||||
|
reject(new Error(`Volume normalization failed: ${normStderr}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
normalize.on('error', (error) => {
|
||||||
|
reject(new Error(`Failed to normalize: ${error.message}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
analyze.on('error', (error) => {
|
||||||
|
reject(new Error(`Failed to analyze loudness: ${error.message}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple concatenation without crossfade
|
||||||
|
* @param {string[]} trackPaths - Array of track file paths
|
||||||
|
* @param {string} outputPath - Output file path
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async concatenateTracks(trackPaths, outputPath) {
|
||||||
|
// Create a temporary file list
|
||||||
|
const listFile = path.join(os.tmpdir(), `automixer_list_${Date.now()}.txt`);
|
||||||
|
const listContent = trackPaths.map(p => `file '${p}'`).join('\n');
|
||||||
|
|
||||||
|
await fs.writeFile(listFile, listContent);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const args = [
|
||||||
|
'-f', 'concat',
|
||||||
|
'-safe', '0',
|
||||||
|
'-i', listFile,
|
||||||
|
'-c:a', 'libmp3lame',
|
||||||
|
'-b:a', `${this.options.outputBitrate}k`,
|
||||||
|
'-y',
|
||||||
|
outputPath
|
||||||
|
];
|
||||||
|
|
||||||
|
const ffmpeg = spawn('ffmpeg', args);
|
||||||
|
|
||||||
|
let stderr = '';
|
||||||
|
|
||||||
|
ffmpeg.stderr.on('data', (data) => {
|
||||||
|
stderr += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
ffmpeg.on('close', async (code) => {
|
||||||
|
try {
|
||||||
|
await fs.unlink(listFile);
|
||||||
|
} catch {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code !== 0) {
|
||||||
|
reject(new Error(`FFmpeg concat failed: ${stderr}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
ffmpeg.on('error', (error) => {
|
||||||
|
reject(new Error(`Failed to spawn FFmpeg: ${error.message}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TrackMixer;
|
||||||
386
src/core/AutoMixer.js
Archivo normal
386
src/core/AutoMixer.js
Archivo normal
@@ -0,0 +1,386 @@
|
|||||||
|
/**
|
||||||
|
* AutoMixer - Main orchestrator class
|
||||||
|
*
|
||||||
|
* Coordinates the entire mixing process:
|
||||||
|
* 1. Analyzes all input tracks
|
||||||
|
* 2. Calculates optimal BPM transitions
|
||||||
|
* 3. Applies pitch/tempo adjustments
|
||||||
|
* 4. Creates seamless crossfades between tracks
|
||||||
|
*
|
||||||
|
* @class AutoMixer
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
import { BPMDetector } from '../audio/BPMDetector.js';
|
||||||
|
import { AudioAnalyzer } from '../audio/AudioAnalyzer.js';
|
||||||
|
import { TrackMixer } from '../audio/TrackMixer.js';
|
||||||
|
import { PitchShifter } from '../audio/PitchShifter.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} MixOptions
|
||||||
|
* @property {number} [crossfadeDuration=8] - Duration of crossfade in seconds
|
||||||
|
* @property {number} [targetBPM=null] - Target BPM for all tracks (null = auto-detect)
|
||||||
|
* @property {boolean} [preservePitch=true] - Preserve pitch when changing tempo
|
||||||
|
* @property {number} [maxBPMChange=8] - Maximum BPM change percentage allowed
|
||||||
|
* @property {string} [outputFormat='mp3'] - Output format (mp3, wav, flac)
|
||||||
|
* @property {number} [outputBitrate=320] - Output bitrate in kbps
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} TrackInfo
|
||||||
|
* @property {string} filepath - Path to the audio file
|
||||||
|
* @property {number} bpm - Detected BPM
|
||||||
|
* @property {number} duration - Duration in seconds
|
||||||
|
* @property {number[]} beats - Array of beat timestamps
|
||||||
|
* @property {string} key - Musical key (if detectable)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class AutoMixer extends EventEmitter {
|
||||||
|
/**
|
||||||
|
* Create an AutoMixer instance
|
||||||
|
* @param {MixOptions} options - Mixer configuration options
|
||||||
|
*/
|
||||||
|
constructor(options = {}) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.options = {
|
||||||
|
crossfadeDuration: 8,
|
||||||
|
targetBPM: null,
|
||||||
|
preservePitch: true,
|
||||||
|
maxBPMChange: 15, // Increased to allow bigger tempo changes
|
||||||
|
outputFormat: 'mp3',
|
||||||
|
outputBitrate: 320,
|
||||||
|
tempDir: null,
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
|
||||||
|
this.bpmDetector = new BPMDetector();
|
||||||
|
this.audioAnalyzer = new AudioAnalyzer();
|
||||||
|
this.trackMixer = new TrackMixer(this.options);
|
||||||
|
this.pitchShifter = new PitchShifter();
|
||||||
|
|
||||||
|
this.tracks = [];
|
||||||
|
this.analyzedTracks = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add tracks to the mix queue
|
||||||
|
* @param {string[]} filepaths - Array of paths to audio files
|
||||||
|
* @returns {AutoMixer} - Returns this for chaining
|
||||||
|
*/
|
||||||
|
addTracks(filepaths) {
|
||||||
|
this.tracks.push(...filepaths);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all tracks from the queue
|
||||||
|
* @returns {AutoMixer} - Returns this for chaining
|
||||||
|
*/
|
||||||
|
clearTracks() {
|
||||||
|
this.tracks = [];
|
||||||
|
this.analyzedTracks = [];
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyze all tracks in the queue
|
||||||
|
* Detects BPM, beats, and other audio characteristics
|
||||||
|
* @returns {Promise<TrackInfo[]>} - Array of analyzed track information
|
||||||
|
*/
|
||||||
|
async analyzeTracks() {
|
||||||
|
this.emit('analysis:start', { totalTracks: this.tracks.length });
|
||||||
|
|
||||||
|
this.analyzedTracks = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < this.tracks.length; i++) {
|
||||||
|
const filepath = this.tracks[i];
|
||||||
|
this.emit('analysis:track:start', { index: i, filepath });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate file exists
|
||||||
|
await fs.access(filepath);
|
||||||
|
|
||||||
|
// Get audio duration and metadata
|
||||||
|
const metadata = await this.audioAnalyzer.getMetadata(filepath);
|
||||||
|
|
||||||
|
// Detect BPM and beats
|
||||||
|
const detection = await this.bpmDetector.detect(filepath);
|
||||||
|
|
||||||
|
const trackInfo = {
|
||||||
|
filepath,
|
||||||
|
filename: path.basename(filepath),
|
||||||
|
bpm: detection.bpm,
|
||||||
|
beats: detection.beats,
|
||||||
|
onsets: detection.onsets || [], // Store raw onsets for sync
|
||||||
|
phase: detection.phase || 0,
|
||||||
|
duration: metadata.duration,
|
||||||
|
sampleRate: metadata.sampleRate,
|
||||||
|
channels: metadata.channels
|
||||||
|
};
|
||||||
|
|
||||||
|
this.analyzedTracks.push(trackInfo);
|
||||||
|
this.emit('analysis:track:complete', { index: i, trackInfo });
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
this.emit('analysis:track:error', { index: i, filepath, error });
|
||||||
|
throw new Error(`Failed to analyze track "${filepath}": ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('analysis:complete', { tracks: this.analyzedTracks });
|
||||||
|
return this.analyzedTracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the optimal target BPM for the mix
|
||||||
|
* Uses the median BPM to minimize required tempo changes
|
||||||
|
* @returns {number} - Optimal target BPM
|
||||||
|
*/
|
||||||
|
calculateOptimalBPM() {
|
||||||
|
if (this.analyzedTracks.length === 0) {
|
||||||
|
throw new Error('No tracks analyzed. Call analyzeTracks() first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.options.targetBPM) {
|
||||||
|
return this.options.targetBPM;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use median BPM to minimize overall tempo changes
|
||||||
|
const bpms = this.analyzedTracks.map(t => t.bpm).sort((a, b) => a - b);
|
||||||
|
const mid = Math.floor(bpms.length / 2);
|
||||||
|
|
||||||
|
return bpms.length % 2 !== 0
|
||||||
|
? bpms[mid]
|
||||||
|
: (bpms[mid - 1] + bpms[mid]) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate tempo adjustment ratio for a track
|
||||||
|
* @param {number} sourceBPM - Original BPM
|
||||||
|
* @param {number} targetBPM - Target BPM
|
||||||
|
* @returns {number} - Tempo ratio (1.0 = no change)
|
||||||
|
*/
|
||||||
|
calculateTempoRatio(sourceBPM, targetBPM) {
|
||||||
|
const ratio = targetBPM / sourceBPM;
|
||||||
|
const changePercent = Math.abs(ratio - 1) * 100;
|
||||||
|
|
||||||
|
// Check if change exceeds maximum allowed
|
||||||
|
if (changePercent > this.options.maxBPMChange) {
|
||||||
|
// Try halving or doubling the source BPM to find better match
|
||||||
|
const halfRatio = targetBPM / (sourceBPM / 2);
|
||||||
|
const doubleRatio = targetBPM / (sourceBPM * 2);
|
||||||
|
|
||||||
|
const halfChange = Math.abs(halfRatio - 1) * 100;
|
||||||
|
const doubleChange = Math.abs(doubleRatio - 1) * 100;
|
||||||
|
|
||||||
|
// Pick the option with smallest change
|
||||||
|
if (halfChange < doubleChange && halfChange <= this.options.maxBPMChange) {
|
||||||
|
return halfRatio;
|
||||||
|
}
|
||||||
|
if (doubleChange <= this.options.maxBPMChange) {
|
||||||
|
return doubleRatio;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If neither works, still apply the original ratio (better than no adjustment)
|
||||||
|
// This ensures all tracks match tempo even with big differences
|
||||||
|
}
|
||||||
|
|
||||||
|
return ratio;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the optimal transition point between two tracks
|
||||||
|
*
|
||||||
|
* ENFOQUE SIMPLIFICADO:
|
||||||
|
* 1. outPoint es un punto en la zona de mezcla de A
|
||||||
|
* 2. inPoint es un punto cerca del inicio de B
|
||||||
|
* 3. La CLAVE: la diferencia (outPoint - inPoint) debe ser un múltiplo
|
||||||
|
* EXACTO del beat interval para que los kicks coincidan.
|
||||||
|
*
|
||||||
|
* @param {TrackInfo} trackA - Outgoing track
|
||||||
|
* @param {TrackInfo} trackB - Incoming track
|
||||||
|
* @returns {Object} - Transition points { outPoint, inPoint }
|
||||||
|
*/
|
||||||
|
findTransitionPoints(trackA, trackB) {
|
||||||
|
const duration = trackA.adjustedDuration || trackA.duration;
|
||||||
|
const bpmA = trackA.adjustedBPM || trackA.bpm || 128;
|
||||||
|
const bpmB = trackB.adjustedBPM || trackB.bpm || 128;
|
||||||
|
|
||||||
|
// Ambos tracks tienen el mismo BPM target
|
||||||
|
const beatInterval = 60 / bpmA; // ~461ms para 130 BPM
|
||||||
|
const halfBeat = beatInterval / 2;
|
||||||
|
|
||||||
|
// Usar el PRIMER ONSET detectado como referencia absoluta
|
||||||
|
const firstOnsetA = (trackA.onsets && trackA.onsets.length > 0) ? trackA.onsets[0] : 0;
|
||||||
|
const firstOnsetB = (trackB.onsets && trackB.onsets.length > 0) ? trackB.onsets[0] : 0;
|
||||||
|
|
||||||
|
// Ajustar fases si hubo cambio de tempo
|
||||||
|
const tempoRatioA = trackA.tempoRatio || 1;
|
||||||
|
const tempoRatioB = trackB.tempoRatio || 1;
|
||||||
|
const adjustedOnsetA = firstOnsetA / tempoRatioA;
|
||||||
|
const adjustedOnsetB = firstOnsetB / tempoRatioB;
|
||||||
|
|
||||||
|
// Calcular fase normalizada [0, beatInterval)
|
||||||
|
let phaseA = adjustedOnsetA % beatInterval;
|
||||||
|
let phaseB = adjustedOnsetB % beatInterval;
|
||||||
|
|
||||||
|
// Mix zone: 65-80% of track A
|
||||||
|
const mixZoneStart = duration * 0.65;
|
||||||
|
const mixZoneEnd = duration * 0.80;
|
||||||
|
const mixZoneMid = (mixZoneStart + mixZoneEnd) / 2;
|
||||||
|
|
||||||
|
// PASO 1: outPoint debe estar en un beat de A (alineado a su fase)
|
||||||
|
// Encontrar el beat más cercano a mixZoneMid
|
||||||
|
const beatsFromStartA = Math.round((mixZoneMid - adjustedOnsetA) / beatInterval);
|
||||||
|
let outPoint = adjustedOnsetA + beatsFromStartA * beatInterval;
|
||||||
|
|
||||||
|
// Asegurar que está en la zona de mezcla
|
||||||
|
while (outPoint < mixZoneStart) outPoint += beatInterval;
|
||||||
|
while (outPoint > mixZoneEnd) outPoint -= beatInterval;
|
||||||
|
|
||||||
|
// PASO 2: inPoint debe estar en un beat de B
|
||||||
|
// Usar directamente los onsets detectados para encontrar un beat real
|
||||||
|
const targetInPoint = 4; // ~4 segundos
|
||||||
|
const beatsFromStartB = Math.round((targetInPoint - adjustedOnsetB) / beatInterval);
|
||||||
|
let inPoint = adjustedOnsetB + beatsFromStartB * beatInterval;
|
||||||
|
|
||||||
|
// Asegurar que está en el rango válido (2-8 segundos)
|
||||||
|
while (inPoint < 2) inPoint += beatInterval;
|
||||||
|
while (inPoint > 8) inPoint -= beatInterval;
|
||||||
|
|
||||||
|
// PASO 3: Alinear las fases
|
||||||
|
// outPoint ya está en un beat de A (fase = phaseA)
|
||||||
|
// inPoint ya está en un beat de B (fase = phaseB)
|
||||||
|
// Para que coincidan, ajustamos inPoint por la diferencia de fases
|
||||||
|
|
||||||
|
let phaseDiff = (outPoint % beatInterval) - (inPoint % beatInterval);
|
||||||
|
|
||||||
|
// Normalizar a [-halfBeat, +halfBeat]
|
||||||
|
while (phaseDiff > halfBeat) phaseDiff -= beatInterval;
|
||||||
|
while (phaseDiff < -halfBeat) phaseDiff += beatInterval;
|
||||||
|
|
||||||
|
// Ajustar inPoint para que su fase coincida con outPoint
|
||||||
|
inPoint += phaseDiff;
|
||||||
|
|
||||||
|
// Re-verificar que inPoint es válido
|
||||||
|
while (inPoint < 1) inPoint += beatInterval;
|
||||||
|
while (inPoint > 10) inPoint -= beatInterval;
|
||||||
|
|
||||||
|
return {
|
||||||
|
outPoint,
|
||||||
|
inPoint,
|
||||||
|
beatIntervalA: beatInterval,
|
||||||
|
beatIntervalB: beatInterval
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the final mix from all analyzed tracks
|
||||||
|
* @param {string} outputPath - Path for the output file
|
||||||
|
* @returns {Promise<string>} - Path to the created mix
|
||||||
|
*/
|
||||||
|
async createMix(outputPath) {
|
||||||
|
if (this.analyzedTracks.length === 0) {
|
||||||
|
throw new Error('No tracks analyzed. Call analyzeTracks() first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.analyzedTracks.length === 1) {
|
||||||
|
// Just copy the single track
|
||||||
|
await fs.copyFile(this.analyzedTracks[0].filepath, outputPath);
|
||||||
|
return outputPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('mix:start', { totalTracks: this.analyzedTracks.length, outputPath });
|
||||||
|
|
||||||
|
const targetBPM = this.calculateOptimalBPM();
|
||||||
|
this.emit('mix:bpm', { targetBPM });
|
||||||
|
|
||||||
|
// Prepare tracks with tempo adjustments
|
||||||
|
const preparedTracks = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < this.analyzedTracks.length; i++) {
|
||||||
|
const track = this.analyzedTracks[i];
|
||||||
|
this.emit('mix:prepare:start', { index: i, track });
|
||||||
|
|
||||||
|
const tempoRatio = this.calculateTempoRatio(track.bpm, targetBPM);
|
||||||
|
|
||||||
|
// If tempo adjustment needed, create adjusted version
|
||||||
|
let processedPath = track.filepath;
|
||||||
|
|
||||||
|
// Always adjust tempo if ratio differs from 1 (even small differences matter for beat sync)
|
||||||
|
if (Math.abs(tempoRatio - 1) > 0.001) {
|
||||||
|
console.log(` -> Adjusting tempo for track ${i + 1}`);
|
||||||
|
processedPath = await this.pitchShifter.adjustTempo(
|
||||||
|
track.filepath,
|
||||||
|
tempoRatio,
|
||||||
|
this.options.preservePitch
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
preparedTracks.push({
|
||||||
|
...track,
|
||||||
|
processedPath,
|
||||||
|
tempoRatio,
|
||||||
|
adjustedBPM: track.bpm * tempoRatio,
|
||||||
|
adjustedDuration: track.duration / tempoRatio,
|
||||||
|
adjustedBeats: track.beats.map(b => b / tempoRatio),
|
||||||
|
adjustedOnsets: (track.onsets || []).map(o => o / tempoRatio)
|
||||||
|
});
|
||||||
|
|
||||||
|
this.emit('mix:prepare:complete', { index: i, tempoRatio });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate transition points for all track pairs
|
||||||
|
const transitions = [];
|
||||||
|
for (let i = 0; i < preparedTracks.length - 1; i++) {
|
||||||
|
const transition = this.findTransitionPoints(
|
||||||
|
preparedTracks[i],
|
||||||
|
preparedTracks[i + 1]
|
||||||
|
);
|
||||||
|
transitions.push(transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the final mix
|
||||||
|
this.emit('mix:render:start');
|
||||||
|
|
||||||
|
await this.trackMixer.mixTracks(
|
||||||
|
preparedTracks,
|
||||||
|
transitions,
|
||||||
|
outputPath,
|
||||||
|
(progress) => this.emit('mix:render:progress', progress)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cleanup temporary files
|
||||||
|
for (const track of preparedTracks) {
|
||||||
|
if (track.processedPath !== track.filepath) {
|
||||||
|
try {
|
||||||
|
await fs.unlink(track.processedPath);
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emit('mix:complete', { outputPath });
|
||||||
|
return outputPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the complete mixing process
|
||||||
|
* Analyzes tracks and creates the mix in one call
|
||||||
|
* @param {string[]} inputFiles - Array of input file paths
|
||||||
|
* @param {string} outputPath - Path for the output file
|
||||||
|
* @returns {Promise<string>} - Path to the created mix
|
||||||
|
*/
|
||||||
|
async mix(inputFiles, outputPath) {
|
||||||
|
this.clearTracks();
|
||||||
|
this.addTracks(inputFiles);
|
||||||
|
await this.analyzeTracks();
|
||||||
|
return await this.createMix(outputPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AutoMixer;
|
||||||
24
src/index.js
Archivo normal
24
src/index.js
Archivo normal
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* AutoMixer - Automatic DJ-style audio mixer
|
||||||
|
*
|
||||||
|
* Main entry point for the automixer library.
|
||||||
|
* Provides programmatic access to audio mixing functionality.
|
||||||
|
*
|
||||||
|
* @module automixer
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { AutoMixer } from './core/AutoMixer.js';
|
||||||
|
import { BPMDetector } from './audio/BPMDetector.js';
|
||||||
|
import { AudioAnalyzer } from './audio/AudioAnalyzer.js';
|
||||||
|
import { TrackMixer } from './audio/TrackMixer.js';
|
||||||
|
import { PitchShifter } from './audio/PitchShifter.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
AutoMixer,
|
||||||
|
BPMDetector,
|
||||||
|
AudioAnalyzer,
|
||||||
|
TrackMixer,
|
||||||
|
PitchShifter
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AutoMixer;
|
||||||
173
src/utils/index.js
Archivo normal
173
src/utils/index.js
Archivo normal
@@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* Utility functions for AutoMixer
|
||||||
|
*
|
||||||
|
* @module utils
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format seconds to mm:ss string
|
||||||
|
* @param {number} seconds - Duration in seconds
|
||||||
|
* @returns {string} - Formatted time string
|
||||||
|
*/
|
||||||
|
export function formatDuration(seconds) {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format seconds to hh:mm:ss string
|
||||||
|
* @param {number} seconds - Duration in seconds
|
||||||
|
* @returns {string} - Formatted time string
|
||||||
|
*/
|
||||||
|
export function formatLongDuration(seconds) {
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const mins = Math.floor((seconds % 3600) / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert BPM to beat interval in seconds
|
||||||
|
* @param {number} bpm - Beats per minute
|
||||||
|
* @returns {number} - Interval between beats in seconds
|
||||||
|
*/
|
||||||
|
export function bpmToInterval(bpm) {
|
||||||
|
return 60 / bpm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert beat interval to BPM
|
||||||
|
* @param {number} interval - Interval in seconds
|
||||||
|
* @returns {number} - Beats per minute
|
||||||
|
*/
|
||||||
|
export function intervalToBpm(interval) {
|
||||||
|
return 60 / interval;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate tempo ratio between two BPMs
|
||||||
|
* @param {number} sourceBPM - Source BPM
|
||||||
|
* @param {number} targetBPM - Target BPM
|
||||||
|
* @returns {number} - Tempo ratio
|
||||||
|
*/
|
||||||
|
export function calculateTempoRatio(sourceBPM, targetBPM) {
|
||||||
|
return targetBPM / sourceBPM;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the nearest beat to a given timestamp
|
||||||
|
* @param {number[]} beats - Array of beat timestamps
|
||||||
|
* @param {number} timestamp - Target timestamp
|
||||||
|
* @returns {number} - Nearest beat timestamp
|
||||||
|
*/
|
||||||
|
export function findNearestBeat(beats, timestamp) {
|
||||||
|
if (!beats || beats.length === 0) {
|
||||||
|
return timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
return beats.reduce((closest, beat) => {
|
||||||
|
return Math.abs(beat - timestamp) < Math.abs(closest - timestamp)
|
||||||
|
? beat
|
||||||
|
: closest;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate percentage change between two values
|
||||||
|
* @param {number} original - Original value
|
||||||
|
* @param {number} updated - New value
|
||||||
|
* @returns {number} - Percentage change
|
||||||
|
*/
|
||||||
|
export function percentageChange(original, updated) {
|
||||||
|
return ((updated - original) / original) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clamp a value between min and max
|
||||||
|
* @param {number} value - Value to clamp
|
||||||
|
* @param {number} min - Minimum value
|
||||||
|
* @param {number} max - Maximum value
|
||||||
|
* @returns {number} - Clamped value
|
||||||
|
*/
|
||||||
|
export function clamp(value, min, max) {
|
||||||
|
return Math.min(Math.max(value, min), max);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Linear interpolation between two values
|
||||||
|
* @param {number} a - Start value
|
||||||
|
* @param {number} b - End value
|
||||||
|
* @param {number} t - Interpolation factor (0-1)
|
||||||
|
* @returns {number} - Interpolated value
|
||||||
|
*/
|
||||||
|
export function lerp(a, b, t) {
|
||||||
|
return a + (b - a) * t;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique ID
|
||||||
|
* @returns {string} - Unique ID string
|
||||||
|
*/
|
||||||
|
export function generateId() {
|
||||||
|
return `${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delay execution for specified milliseconds
|
||||||
|
* @param {number} ms - Milliseconds to delay
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export function delay(ms) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a file path has an audio extension
|
||||||
|
* @param {string} filepath - File path to check
|
||||||
|
* @returns {boolean} - True if audio file
|
||||||
|
*/
|
||||||
|
export function isAudioFile(filepath) {
|
||||||
|
const audioExtensions = ['.mp3', '.wav', '.flac', '.ogg', '.m4a', '.aac', '.wma'];
|
||||||
|
const ext = filepath.toLowerCase().slice(filepath.lastIndexOf('.'));
|
||||||
|
return audioExtensions.includes(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert decibels to linear amplitude
|
||||||
|
* @param {number} db - Value in decibels
|
||||||
|
* @returns {number} - Linear amplitude (0-1)
|
||||||
|
*/
|
||||||
|
export function dbToLinear(db) {
|
||||||
|
return Math.pow(10, db / 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert linear amplitude to decibels
|
||||||
|
* @param {number} linear - Linear amplitude (0-1)
|
||||||
|
* @returns {number} - Value in decibels
|
||||||
|
*/
|
||||||
|
export function linearToDb(linear) {
|
||||||
|
return 20 * Math.log10(Math.max(linear, 0.00001));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
formatDuration,
|
||||||
|
formatLongDuration,
|
||||||
|
bpmToInterval,
|
||||||
|
intervalToBpm,
|
||||||
|
calculateTempoRatio,
|
||||||
|
findNearestBeat,
|
||||||
|
percentageChange,
|
||||||
|
clamp,
|
||||||
|
lerp,
|
||||||
|
generateId,
|
||||||
|
delay,
|
||||||
|
isAudioFile,
|
||||||
|
dbToLinear,
|
||||||
|
linearToDb
|
||||||
|
};
|
||||||
148
tests/automixer.test.js
Archivo normal
148
tests/automixer.test.js
Archivo normal
@@ -0,0 +1,148 @@
|
|||||||
|
/**
|
||||||
|
* AutoMixer Tests
|
||||||
|
*
|
||||||
|
* Basic test suite for the automixer library
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert';
|
||||||
|
import { AutoMixer, BPMDetector, AudioAnalyzer, PitchShifter } from '../src/index.js';
|
||||||
|
|
||||||
|
describe('AutoMixer', () => {
|
||||||
|
it('should create an instance with default options', () => {
|
||||||
|
const mixer = new AutoMixer();
|
||||||
|
assert.ok(mixer);
|
||||||
|
assert.strictEqual(mixer.options.crossfadeDuration, 8);
|
||||||
|
assert.strictEqual(mixer.options.preservePitch, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create an instance with custom options', () => {
|
||||||
|
const mixer = new AutoMixer({
|
||||||
|
crossfadeDuration: 12,
|
||||||
|
targetBPM: 128,
|
||||||
|
preservePitch: false
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(mixer.options.crossfadeDuration, 12);
|
||||||
|
assert.strictEqual(mixer.options.targetBPM, 128);
|
||||||
|
assert.strictEqual(mixer.options.preservePitch, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add and clear tracks', () => {
|
||||||
|
const mixer = new AutoMixer();
|
||||||
|
|
||||||
|
mixer.addTracks(['track1.mp3', 'track2.mp3']);
|
||||||
|
assert.strictEqual(mixer.tracks.length, 2);
|
||||||
|
|
||||||
|
mixer.addTracks(['track3.mp3']);
|
||||||
|
assert.strictEqual(mixer.tracks.length, 3);
|
||||||
|
|
||||||
|
mixer.clearTracks();
|
||||||
|
assert.strictEqual(mixer.tracks.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate tempo ratio correctly', () => {
|
||||||
|
const mixer = new AutoMixer({ maxBPMChange: 10 });
|
||||||
|
|
||||||
|
// Simple ratio
|
||||||
|
const ratio1 = mixer.calculateTempoRatio(120, 132);
|
||||||
|
assert.strictEqual(ratio1, 1.1);
|
||||||
|
|
||||||
|
// Identity
|
||||||
|
const ratio2 = mixer.calculateTempoRatio(128, 128);
|
||||||
|
assert.strictEqual(ratio2, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when analyzing without tracks', async () => {
|
||||||
|
const mixer = new AutoMixer();
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
async () => mixer.createMix('output.mp3'),
|
||||||
|
{ message: 'No tracks analyzed. Call analyzeTracks() first.' }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('BPMDetector', () => {
|
||||||
|
it('should create an instance with default options', () => {
|
||||||
|
const detector = new BPMDetector();
|
||||||
|
assert.ok(detector);
|
||||||
|
assert.strictEqual(detector.options.minBPM, 60);
|
||||||
|
assert.strictEqual(detector.options.maxBPM, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should normalize BPM correctly', () => {
|
||||||
|
const detector = new BPMDetector({ minBPM: 60, maxBPM: 200 });
|
||||||
|
|
||||||
|
// Normal BPM
|
||||||
|
assert.strictEqual(detector.normalizeBPM(120), 120);
|
||||||
|
|
||||||
|
// Half BPM (should double)
|
||||||
|
assert.strictEqual(detector.normalizeBPM(55), 110);
|
||||||
|
|
||||||
|
// Double BPM (should halve)
|
||||||
|
assert.strictEqual(detector.normalizeBPM(240), 120);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extrapolate beats correctly', () => {
|
||||||
|
const detector = new BPMDetector();
|
||||||
|
|
||||||
|
// 120 BPM = 0.5s per beat
|
||||||
|
const beats = detector.extrapolateBeats([0, 0.5, 1.0], 120, 5);
|
||||||
|
|
||||||
|
assert.ok(beats.length > 0);
|
||||||
|
assert.ok(beats.every(b => b >= 0 && b < 5));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AudioAnalyzer', () => {
|
||||||
|
it('should create an instance', () => {
|
||||||
|
const analyzer = new AudioAnalyzer();
|
||||||
|
assert.ok(analyzer);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have cache management', () => {
|
||||||
|
const analyzer = new AudioAnalyzer();
|
||||||
|
analyzer.clearCache();
|
||||||
|
assert.strictEqual(analyzer.ffprobeCache.size, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PitchShifter', () => {
|
||||||
|
it('should create an instance with default options', () => {
|
||||||
|
const shifter = new PitchShifter();
|
||||||
|
assert.ok(shifter);
|
||||||
|
assert.strictEqual(shifter.options.outputFormat, 'mp3');
|
||||||
|
assert.strictEqual(shifter.options.outputBitrate, 320);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should build atempo chain correctly', () => {
|
||||||
|
const shifter = new PitchShifter();
|
||||||
|
|
||||||
|
// Simple ratio within range
|
||||||
|
const chain1 = shifter.buildAtempoChain(1.5);
|
||||||
|
assert.strictEqual(chain1, 'atempo=1.5');
|
||||||
|
|
||||||
|
// Ratio above 2.0 (needs chaining)
|
||||||
|
const chain2 = shifter.buildAtempoChain(3.0);
|
||||||
|
assert.ok(chain2.includes('atempo=2.0'));
|
||||||
|
assert.ok(chain2.includes('atempo=1.5'));
|
||||||
|
|
||||||
|
// Ratio below 0.5 (needs chaining)
|
||||||
|
const chain3 = shifter.buildAtempoChain(0.25);
|
||||||
|
assert.ok(chain3.includes('atempo=0.5'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Module Exports', () => {
|
||||||
|
it('should export all components', async () => {
|
||||||
|
const module = await import('../src/index.js');
|
||||||
|
|
||||||
|
assert.ok(module.AutoMixer);
|
||||||
|
assert.ok(module.BPMDetector);
|
||||||
|
assert.ok(module.AudioAnalyzer);
|
||||||
|
assert.ok(module.TrackMixer);
|
||||||
|
assert.ok(module.PitchShifter);
|
||||||
|
assert.ok(module.default);
|
||||||
|
});
|
||||||
|
});
|
||||||
Referencia en una nueva incidencia
Block a user