UNPKG

simple-beatmaker

Version:

Generate drum sounds from scratch and create beats programmatically with pure JavaScript - no external WAV files needed!

731 lines (642 loc) • 26 kB
const fs = require('fs'); const path = require('path'); // Preset patterns that can be selected by name const presetPatterns = { 'basic-rock': { name: 'Basic Rock', description: 'Classic 4/4 rock beat', bars: 4, pattern: [ [ ['kick'], null, ['hihat'], null, ['snare'], null, ['hihat'], null, ['kick'], null, ['hihat'], null, ['snare'], null, ['hihat'], null ], [ ['kick'], null, ['hihat'], null, ['snare'], null, ['hihat'], null, ['kick'], null, ['hihat'], null, ['snare'], null, ['hihat'], null ], [ ['kick'], null, ['hihat'], null, ['snare'], null, ['hihat'], null, ['kick'], null, ['hihat'], null, ['snare'], null, ['hihat'], null ], [ ['kick'], null, ['hihat'], null, ['snare'], ['snare'], ['hihat'], null, ['kick'], ['kick'], ['hihat'], null, ['snare'], null, ['hihat'], ['kick'] ] ] }, 'electronic-dance': { name: 'Electronic Dance', description: 'Electronic dance pattern with sharp beats', bars: 2, pattern: [ [ ['kick'], null, null, null, ['kick'], null, ['hihat'], null, ['kick'], null, null, null, ['kick'], null, ['hihat'], null ], [ ['kick'], null, ['snare'], null, ['kick'], null, ['snare'], ['hihat'], ['kick'], null, ['snare'], null, ['kick'], ['snare'], ['hihat'], null ] ] }, 'vintage-groove': { name: 'Vintage Groove', description: 'Laid-back vintage drum groove', bars: 4, pattern: [ [ ['kick'], null, null, ['hihat'], null, ['snare'], null, ['hihat'], ['kick'], null, null, ['hihat'], null, ['snare'], null, ['hihat'] ], [ ['kick'], null, null, ['hihat'], null, ['snare'], null, ['hihat'], ['kick'], null, ['kick'], ['hihat'], null, ['snare'], null, ['hihat'] ], [ ['kick'], null, null, ['hihat'], null, ['snare'], null, ['hihat'], ['kick'], null, null, ['hihat'], null, ['snare'], ['snare'], ['hihat'] ], [ ['kick'], null, null, ['hihat'], ['snare'], null, ['snare'], null, ['kick'], ['kick'], null, ['hihat'], ['snare'], null, ['hihat'], null ] ] }, 'funky-break': { name: 'Funky Break', description: 'Syncopated funk pattern', bars: 2, pattern: [ [ ['kick'], null, null, ['hihat'], null, ['snare'], ['kick'], null, null, ['hihat'], ['kick'], null, ['snare'], null, ['hihat'], null ], [ ['kick'], null, null, ['hihat'], null, ['snare'], ['kick'], ['hihat'], null, null, ['kick'], ['snare'], null, ['snare'], ['hihat'], ['kick'] ] ] }, 'minimal-techno': { name: 'Minimal Techno', description: 'Minimal techno beat', bars: 4, pattern: [ [ ['kick'], null, null, null, null, null, null, null, ['kick'], null, null, null, null, null, null, null ], [ ['kick'], null, null, null, null, null, null, null, ['kick'], null, null, null, null, null, ['hihat'], null ], [ ['kick'], null, null, null, null, null, ['snare'], null, ['kick'], null, null, null, null, null, null, null ], [ ['kick'], null, null, null, null, null, ['snare'], null, ['kick'], null, null, ['hihat'], null, null, ['hihat'], ['hihat'] ] ] } }; class SimpleBeatmaker { constructor() { this.bpm = 120; this.sampleRate = 44100; this.channels = 2; this.bitDepth = 16; this.isPlaying = false; this.currentDrumSet = 'classic'; // Default drum set this.outputDir = null; // Default: current directory } setBPM(bpm) { this.bpm = bpm; return this; } // Set the drum set to use setDrumSet(drumSetName) { const availableSets = ['classic', 'electronic', 'vintage']; if (availableSets.includes(drumSetName.toLowerCase())) { this.currentDrumSet = drumSetName.toLowerCase(); } else { console.warn(`Unknown drum set "${drumSetName}". Available sets: ${availableSets.join(', ')}`); } return this; } // Set the output directory for WAV files setOutputDir(directory) { if (directory) { // Resolve the path and create directory if it doesn't exist const resolvedPath = path.resolve(directory); if (!fs.existsSync(resolvedPath)) { fs.mkdirSync(resolvedPath, { recursive: true }); console.log(`šŸ“ Created output directory: ${resolvedPath}`); } this.outputDir = resolvedPath; console.log(`šŸ“ Output directory set to: ${resolvedPath}`); } else { this.outputDir = null; console.log('šŸ“ Output directory reset to current directory'); } return this; } // Get available drum sets getAvailableDrumSets() { return { classic: 'Classic acoustic drum sounds with warm tones', electronic: 'Electronic/synthetic drum sounds with sharp attack', vintage: 'Vintage-style drums with analog character' }; } // Get available preset patterns getAvailablePresets() { const presets = {}; Object.keys(presetPatterns).forEach(key => { presets[key] = { name: presetPatterns[key].name, description: presetPatterns[key].description, bars: presetPatterns[key].bars }; }); return presets; } // Use a preset pattern usePreset(presetName) { if (presetPatterns[presetName]) { const preset = presetPatterns[presetName]; this.bars = preset.bars; this.pattern = preset.pattern; console.log(`āœ“ Using preset: ${preset.name} (${preset.bars} bars)`); } else { throw new Error(`Preset "${presetName}" not found. Available presets: ${Object.keys(presetPatterns).join(', ')}`); } return this; } // Create a custom pattern createPattern(bars, pattern) { this.bars = bars; this.pattern = pattern; console.log(`āœ“ Custom pattern created (${bars} bars)`); return this; } // Generate kick drum sound based on current drum set generateKick(duration = 0.5) { const samples = Math.floor(this.sampleRate * duration); const buffer = Buffer.alloc(samples * this.channels * (this.bitDepth / 8)); for (let i = 0; i < samples; i++) { const t = i / this.sampleRate; let sample = 0; switch (this.currentDrumSet) { case 'classic': // Classic kick: Deep, warm low frequency const frequency = 50 * Math.exp(-t * 25); const amplitude = Math.exp(-t * 6) * 0.9; sample = Math.sin(2 * Math.PI * frequency * t) * amplitude; // Add subtle click const click = Math.exp(-t * 80) * 0.2 * Math.random(); sample += click; break; case 'electronic': // Electronic kick: Sharp attack, synthetic const freq = 60 * Math.exp(-t * 35); const amp = Math.exp(-t * 12) * 0.8; sample = Math.sin(2 * Math.PI * freq * t) * amp; // Add sharp click and sub-bass const sharpClick = Math.exp(-t * 150) * 0.4; const subBass = Math.sin(2 * Math.PI * 40 * t) * Math.exp(-t * 4) * 0.3; sample += sharpClick + subBass; break; case 'vintage': // Vintage kick: Warmer, slightly distorted const vintageFreq = 55 * Math.exp(-t * 20); const vintageAmp = Math.exp(-t * 5) * 0.85; sample = Math.sin(2 * Math.PI * vintageFreq * t) * vintageAmp; // Add harmonic distortion sample += Math.sin(2 * Math.PI * vintageFreq * 2 * t) * vintageAmp * 0.1; // Softer click const softClick = Math.exp(-t * 60) * 0.15 * Math.random(); sample += softClick; break; } const finalSample = Math.max(-1, Math.min(1, sample)); const intSample = Math.floor(finalSample * 32767); buffer.writeInt16LE(intSample, i * 4); buffer.writeInt16LE(intSample, i * 4 + 2); } return buffer; } // Generate snare drum sound based on current drum set generateSnare(duration = 0.2) { const samples = Math.floor(this.sampleRate * duration); const buffer = Buffer.alloc(samples * this.channels * (this.bitDepth / 8)); for (let i = 0; i < samples; i++) { const t = i / this.sampleRate; let sample = 0; switch (this.currentDrumSet) { case 'classic': // Classic snare: Natural noise with tone const noise = (Math.random() - 0.5) * 2; const tone = Math.sin(2 * Math.PI * 200 * t); const amplitude = Math.exp(-t * 12) * 0.7; sample = (noise * 0.8 + tone * 0.2) * amplitude; break; case 'electronic': // Electronic snare: Sharp, synthetic const electroNoise = (Math.random() - 0.5) * 2; const electroTone = Math.sin(2 * Math.PI * 300 * t) + Math.sin(2 * Math.PI * 150 * t); const electroAmp = Math.exp(-t * 20) * 0.6; sample = (electroNoise * 0.6 + electroTone * 0.4) * electroAmp; // Add digital-style click sample += Math.exp(-t * 100) * 0.3; break; case 'vintage': // Vintage snare: Warm, slightly compressed const vintageNoise = (Math.random() - 0.5) * 1.8; const vintageTone = Math.sin(2 * Math.PI * 180 * t); const vintageAmp = Math.exp(-t * 10) * 0.75; sample = (vintageNoise * 0.7 + vintageTone * 0.3) * vintageAmp; // Add vintage-style saturation sample = Math.tanh(sample * 1.5) * 0.8; break; } const finalSample = Math.max(-1, Math.min(1, sample)); const intSample = Math.floor(finalSample * 32767); buffer.writeInt16LE(intSample, i * 4); buffer.writeInt16LE(intSample, i * 4 + 2); } return buffer; } // Generate hihat sound based on current drum set generateHihat(duration = 0.1) { const samples = Math.floor(this.sampleRate * duration); const buffer = Buffer.alloc(samples * this.channels * (this.bitDepth / 8)); for (let i = 0; i < samples; i++) { const t = i / this.sampleRate; let sample = 0; switch (this.currentDrumSet) { case 'classic': // Classic hihat: Natural high-frequency content const noise = (Math.random() - 0.5) * 2; const highFreq = Math.sin(2 * Math.PI * 8000 * t) * Math.random(); const amplitude = Math.exp(-t * 35) * 0.4; sample = (noise + highFreq) * amplitude; break; case 'electronic': // Electronic hihat: Sharp, digital const electroNoise = (Math.random() - 0.5) * 2; const digitalFreq = Math.sin(2 * Math.PI * 12000 * t) * Math.random(); const electroAmp = Math.exp(-t * 50) * 0.35; sample = (electroNoise * 0.7 + digitalFreq * 0.3) * electroAmp; // Add metallic ring sample += Math.sin(2 * Math.PI * 15000 * t) * Math.exp(-t * 60) * 0.1; break; case 'vintage': // Vintage hihat: Warmer, filtered const vintageNoise = (Math.random() - 0.5) * 1.8; const filteredFreq = Math.sin(2 * Math.PI * 6000 * t) * Math.random(); const vintageAmp = Math.exp(-t * 30) * 0.45; sample = (vintageNoise * 0.8 + filteredFreq * 0.2) * vintageAmp; break; } const finalSample = Math.max(-1, Math.min(1, sample)); const intSample = Math.floor(finalSample * 32767); buffer.writeInt16LE(intSample, i * 4); buffer.writeInt16LE(intSample, i * 4 + 2); } return buffer; } // Generate clap sound based on current drum set generateClap(duration = 0.15) { const samples = Math.floor(this.sampleRate * duration); const buffer = Buffer.alloc(samples * this.channels * (this.bitDepth / 8)); for (let i = 0; i < samples; i++) { const t = i / this.sampleRate; let sample = 0; switch (this.currentDrumSet) { case 'classic': // Classic clap: Multiple burst noise pattern const burst1 = t < 0.01 ? (Math.random() - 0.5) * Math.exp(-t * 100) : 0; const burst2 = (t > 0.01 && t < 0.02) ? (Math.random() - 0.5) * Math.exp(-(t-0.01) * 80) : 0; const burst3 = (t > 0.02 && t < 0.04) ? (Math.random() - 0.5) * Math.exp(-(t-0.02) * 60) : 0; const tail = t > 0.04 ? (Math.random() - 0.5) * Math.exp(-t * 20) * 0.3 : 0; sample = (burst1 + burst2 + burst3 + tail) * 0.6; break; case 'electronic': // Electronic clap: Sharp digital bursts const eBurst1 = t < 0.005 ? (Math.random() - 0.5) * Math.exp(-t * 200) : 0; const eBurst2 = (t > 0.005 && t < 0.015) ? (Math.random() - 0.5) * Math.exp(-(t-0.005) * 120) : 0; const eBurst3 = (t > 0.015 && t < 0.03) ? (Math.random() - 0.5) * Math.exp(-(t-0.015) * 80) : 0; const eTail = t > 0.03 ? (Math.random() - 0.5) * Math.exp(-t * 30) * 0.2 : 0; // Add some digital processing sample = (eBurst1 + eBurst2 + eBurst3 + eTail) * 0.5; sample = Math.tanh(sample * 2) * 0.7; // Digital saturation break; case 'vintage': // Vintage clap: Warmer, more compressed const vBurst1 = t < 0.015 ? (Math.random() - 0.5) * Math.exp(-t * 80) : 0; const vBurst2 = (t > 0.015 && t < 0.025) ? (Math.random() - 0.5) * Math.exp(-(t-0.015) * 60) : 0; const vBurst3 = (t > 0.025 && t < 0.05) ? (Math.random() - 0.5) * Math.exp(-(t-0.025) * 40) : 0; const vTail = t > 0.05 ? (Math.random() - 0.5) * Math.exp(-t * 15) * 0.4 : 0; sample = (vBurst1 + vBurst2 + vBurst3 + vTail) * 0.65; // Vintage-style compression sample = Math.tanh(sample * 1.2) * 0.8; break; } const finalSample = Math.max(-1, Math.min(1, sample)); const intSample = Math.floor(finalSample * 32767); buffer.writeInt16LE(intSample, i * 4); buffer.writeInt16LE(intSample, i * 4 + 2); } return buffer; } // Generate crash cymbal sound based on current drum set generateCrash(duration = 1.0) { const samples = Math.floor(this.sampleRate * duration); const buffer = Buffer.alloc(samples * this.channels * (this.bitDepth / 8)); for (let i = 0; i < samples; i++) { const t = i / this.sampleRate; let sample = 0; switch (this.currentDrumSet) { case 'classic': // Classic crash: Complex metallic harmonics const noise = (Math.random() - 0.5) * 2; const metallic1 = Math.sin(2 * Math.PI * 4000 * t) * Math.random(); const metallic2 = Math.sin(2 * Math.PI * 6000 * t) * Math.random(); const metallic3 = Math.sin(2 * Math.PI * 8000 * t) * Math.random(); const shimmer = Math.sin(2 * Math.PI * 12000 * t) * Math.random() * 0.5; const amplitude = Math.exp(-t * 2) * 0.6; sample = (noise * 0.6 + metallic1 * 0.2 + metallic2 * 0.15 + metallic3 * 0.1 + shimmer) * amplitude; break; case 'electronic': // Electronic crash: Digital shimmer with controlled harmonics const eNoise = (Math.random() - 0.5) * 1.8; const digital1 = Math.sin(2 * Math.PI * 5000 * t) * Math.random(); const digital2 = Math.sin(2 * Math.PI * 8000 * t) * Math.random(); const digital3 = Math.sin(2 * Math.PI * 12000 * t) * Math.random(); const eAmplitude = Math.exp(-t * 3) * 0.5; sample = (eNoise * 0.5 + digital1 * 0.25 + digital2 * 0.2 + digital3 * 0.15) * eAmplitude; break; case 'vintage': // Vintage crash: Warmer, less harsh const vNoise = (Math.random() - 0.5) * 1.6; const warm1 = Math.sin(2 * Math.PI * 3000 * t) * Math.random(); const warm2 = Math.sin(2 * Math.PI * 5000 * t) * Math.random(); const warm3 = Math.sin(2 * Math.PI * 7000 * t) * Math.random(); const vAmplitude = Math.exp(-t * 1.8) * 0.65; sample = (vNoise * 0.7 + warm1 * 0.2 + warm2 * 0.15 + warm3 * 0.1) * vAmplitude; // Vintage-style saturation sample = Math.tanh(sample * 1.1) * 0.9; break; } const finalSample = Math.max(-1, Math.min(1, sample)); const intSample = Math.floor(finalSample * 32767); buffer.writeInt16LE(intSample, i * 4); buffer.writeInt16LE(intSample, i * 4 + 2); } return buffer; } // Generate tom drum sound based on current drum set generateTom(duration = 0.4) { const samples = Math.floor(this.sampleRate * duration); const buffer = Buffer.alloc(samples * this.channels * (this.bitDepth / 8)); for (let i = 0; i < samples; i++) { const t = i / this.sampleRate; let sample = 0; switch (this.currentDrumSet) { case 'classic': // Classic tom: Tuned drum with pitch bend const frequency = 120 * Math.exp(-t * 8); // Pitch bend down const tone = Math.sin(2 * Math.PI * frequency * t); const overtone = Math.sin(2 * Math.PI * frequency * 1.5 * t) * 0.3; const amplitude = Math.exp(-t * 4) * 0.8; // Add some drum head resonance const resonance = Math.sin(2 * Math.PI * 80 * t) * Math.exp(-t * 2) * 0.2; sample = (tone + overtone + resonance) * amplitude; break; case 'electronic': // Electronic tom: Synthetic with sharp attack const eFreq = 150 * Math.exp(-t * 12); const eTone = Math.sin(2 * Math.PI * eFreq * t); const eOvertone = Math.sin(2 * Math.PI * eFreq * 2 * t) * 0.4; const eAmplitude = Math.exp(-t * 6) * 0.7; // Add electronic-style harmonics const digital = Math.sin(2 * Math.PI * eFreq * 3 * t) * 0.2; sample = (eTone + eOvertone + digital) * eAmplitude; break; case 'vintage': // Vintage tom: Warm, slightly saturated const vFreq = 110 * Math.exp(-t * 6); const vTone = Math.sin(2 * Math.PI * vFreq * t); const vOvertone = Math.sin(2 * Math.PI * vFreq * 1.3 * t) * 0.25; const vAmplitude = Math.exp(-t * 3.5) * 0.85; // Add vintage character const warmth = Math.sin(2 * Math.PI * 60 * t) * Math.exp(-t * 1.5) * 0.15; sample = (vTone + vOvertone + warmth) * vAmplitude; // Vintage-style compression sample = Math.tanh(sample * 1.3) * 0.85; break; } const finalSample = Math.max(-1, Math.min(1, sample)); const intSample = Math.floor(finalSample * 32767); buffer.writeInt16LE(intSample, i * 4); buffer.writeInt16LE(intSample, i * 4 + 2); } return buffer; } // Generate silence buffer for spacing generateSilence(duration) { const samples = Math.floor(this.sampleRate * duration); return Buffer.alloc(samples * this.channels * (this.bitDepth / 8)); } // Create WAV file header createWAVHeader(dataLength) { const buffer = Buffer.alloc(44); // RIFF header buffer.write('RIFF', 0); buffer.writeUInt32LE(36 + dataLength, 4); buffer.write('WAVE', 8); // fmt chunk buffer.write('fmt ', 12); buffer.writeUInt32LE(16, 16); // fmt chunk size buffer.writeUInt16LE(1, 20); // PCM format buffer.writeUInt16LE(this.channels, 22); buffer.writeUInt32LE(this.sampleRate, 24); buffer.writeUInt32LE(this.sampleRate * this.channels * (this.bitDepth / 8), 28); buffer.writeUInt16LE(this.channels * (this.bitDepth / 8), 32); buffer.writeUInt16LE(this.bitDepth, 34); // data chunk buffer.write('data', 36); buffer.writeUInt32LE(dataLength, 40); return buffer; } // Generate the complete beat as a WAV file async generateWAV(loops = 1, filename = null) { if (!this.pattern) { throw new Error('No pattern defined. Use usePreset() or createPattern() first.'); } console.log(`Generating ${this.bars}-bar pattern at ${this.bpm} BPM for ${loops} loop(s) using ${this.currentDrumSet} drum set...`); // Calculate timing const beatDuration = 60 / this.bpm; // Duration of one beat in seconds const barDuration = beatDuration * 4; // 4 beats per bar const stepDuration = barDuration / 16; // 16 steps per bar (16th notes) // Pre-generate drum sounds const kickBuffer = this.generateKick(); const snareBuffer = this.generateSnare(); const hihatBuffer = this.generateHihat(); const clapBuffer = this.generateClap(); const crashBuffer = this.generateCrash(); const tomBuffer = this.generateTom(); // Calculate total duration and create main audio buffer const totalDuration = this.bars * barDuration * loops; const totalSamples = Math.floor(this.sampleRate * totalDuration); const audioData = Buffer.alloc(totalSamples * this.channels * (this.bitDepth / 8)); let bufferOffset = 0; for (let loop = 0; loop < loops; loop++) { for (let bar = 0; bar < this.bars; bar++) { const barPattern = this.pattern[bar] || []; for (let step = 0; step < 16; step++) { const stepPattern = barPattern[step]; const stepSamples = Math.floor(this.sampleRate * stepDuration); const stepBufferSize = stepSamples * this.channels * (this.bitDepth / 8); if (stepPattern) { // Generate mixed audio for this step let mixedBuffer = Buffer.alloc(stepBufferSize); stepPattern.forEach(drum => { let drumBuffer; switch (drum.toLowerCase()) { case 'kick': case 'k': drumBuffer = kickBuffer; break; case 'snare': case 's': drumBuffer = snareBuffer; break; case 'hihat': case 'h': drumBuffer = hihatBuffer; break; case 'clap': case 'c': drumBuffer = clapBuffer; break; case 'crash': case 'x': drumBuffer = crashBuffer; break; case 'tom': case 't': drumBuffer = tomBuffer; break; default: return; } // Mix the drum sound into the step buffer for (let i = 0; i < Math.min(mixedBuffer.length, drumBuffer.length); i += 2) { const existingSample = mixedBuffer.readInt16LE(i); const newSample = drumBuffer.readInt16LE(i); const mixed = Math.max(-32768, Math.min(32767, existingSample + newSample)); mixedBuffer.writeInt16LE(mixed, i); } }); // Copy mixed buffer to main audio data mixedBuffer.copy(audioData, bufferOffset, 0, Math.min(mixedBuffer.length, audioData.length - bufferOffset)); } bufferOffset += stepBufferSize; } } } // Create WAV file const wavHeader = this.createWAVHeader(audioData.length); const wavFile = Buffer.concat([wavHeader, audioData]); // Save to file or return buffer if (filename) { const outputPath = this.outputDir ? path.join(this.outputDir, filename) : path.resolve(filename); fs.writeFileSync(outputPath, wavFile); console.log(`WAV file saved: ${outputPath}`); return outputPath; } else { // Generate default filename with drum set name const defaultFilename = `beat_${this.currentDrumSet}_${this.bpm}bpm_${this.bars}bars_${Date.now()}.wav`; const outputPath = this.outputDir ? path.join(this.outputDir, defaultFilename) : path.resolve(defaultFilename); fs.writeFileSync(outputPath, wavFile); console.log(`WAV file saved: ${outputPath}`); return outputPath; } } // Play the pattern (now generates WAV file and provides instructions) async play(loops = 1, filename = null) { if (this.isPlaying) { console.log('Already generating...'); return; } this.isPlaying = true; try { const filePath = await this.generateWAV(loops, filename); console.log('\nšŸŽµ Beat generated successfully!'); console.log(`šŸ“ File location: ${filePath}`); console.log(`🄁 Drum set: ${this.currentDrumSet}`); console.log('\nšŸ”Š To play the beat:'); console.log(' • Open the WAV file in any audio player'); console.log(' • Or use: start "" "' + filePath + '" (Windows)'); console.log(' • Or use: open "' + filePath + '" (macOS)'); console.log(' • Or use: xdg-open "' + filePath + '" (Linux)'); // Try to auto-play on Windows if (process.platform === 'win32') { try { const { exec } = require('child_process'); exec(`start "" "${filePath}"`, (error) => { if (!error) { console.log('\nšŸŽ¶ Opening in default audio player...'); } }); } catch (e) { // Silent fail - user can manually open the file } } return filePath; } finally { this.isPlaying = false; } } // Stop is not needed for file generation, but keeping for API compatibility stop() { console.log('File generation cannot be stopped once started.'); } } module.exports = SimpleBeatmaker;