![]() Server : Apache/2 System : Linux server-15-235-50-60 5.15.0-164-generic #174-Ubuntu SMP Fri Nov 14 20:25:16 UTC 2025 x86_64 User : gositeme ( 1004) PHP Version : 8.2.29 Disable Function : exec,system,passthru,shell_exec,proc_close,proc_open,dl,popen,show_source,posix_kill,posix_mkfifo,posix_getpwuid,posix_setpgid,posix_setsid,posix_setuid,posix_setgid,posix_seteuid,posix_setegid,posix_uname Directory : /home/gositeme/domains/soundstudiopro.com/public_html/js/ |
/**
* Simple Audio Analyzer - Lightweight BPM and Key Detection
* Based on proven open-source algorithms - minimal CPU usage
*
* Features:
* - Simple peak detection for BPM (fast, accurate)
* - Basic chroma analysis for key (lightweight)
* - Analyzes only 10 seconds (very fast)
* - Processes in chunks to prevent blocking
* - No heavy FFT or autocorrelation loops
*
* @author SoundStudioPro
*/
class SimpleAudioAnalyzer {
constructor() {
this.audioContext = null;
this.isAnalyzing = false;
// Camelot wheel mapping
this.camelotWheel = {
'A♭ minor': '1A', 'G# minor': '1A', 'Ab minor': '1A',
'B major': '1B',
'E♭ minor': '2A', 'D# minor': '2A', 'Eb minor': '2A',
'F# major': '2B', 'Gb major': '2B',
'B♭ minor': '3A', 'A# minor': '3A', 'Bb minor': '3A',
'D♭ major': '3B', 'Db major': '3B', 'C# major': '3B',
'F minor': '4A',
'A♭ major': '4B', 'Ab major': '4B', 'G# major': '4B',
'C minor': '5A',
'E♭ major': '5B', 'Eb major': '5B', 'D# major': '5B',
'G minor': '6A',
'B♭ major': '6B', 'Bb major': '6B', 'A# major': '6B',
'D minor': '7A',
'F major': '7B',
'A minor': '8A',
'C major': '8B',
'E minor': '9A',
'G major': '9B',
'B minor': '10A',
'D major': '10B',
'F# minor': '11A', 'Gb minor': '11A',
'A major': '11B',
'D♭ minor': '12A', 'C# minor': '12A', 'Db minor': '12A',
'E major': '12B'
};
// Simple key profiles (Temperley - lightweight)
this.majorProfile = [5.0, 2.0, 3.5, 2.0, 4.5, 4.0, 2.0, 4.5, 2.0, 3.5, 1.5, 4.0];
this.minorProfile = [5.0, 2.0, 3.0, 4.5, 2.0, 4.0, 2.0, 4.5, 3.5, 2.0, 3.5, 4.0];
this.noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
}
initAudioContext() {
if (!this.audioContext) {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
return this.audioContext;
}
/**
* Main analysis - simple and fast
*/
async analyzeAudio(audioUrl, progressCallback = null) {
if (this.isAnalyzing) {
throw new Error('Analysis already in progress');
}
this.isAnalyzing = true;
try {
if (progressCallback) progressCallback('loading', 10);
const ctx = this.initAudioContext();
const response = await fetch(audioUrl);
if (!response.ok) throw new Error(`Failed to fetch: ${response.status}`);
if (progressCallback) progressCallback('decoding', 30);
const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await ctx.decodeAudioData(arrayBuffer);
// Get first channel only (mono)
const samples = audioBuffer.getChannelData(0);
const sampleRate = audioBuffer.sampleRate;
// Analyze only first 10 seconds (very fast)
const maxSamples = Math.min(samples.length, sampleRate * 10);
const analysisSamples = samples.slice(0, maxSamples);
if (progressCallback) progressCallback('detecting_bpm', 50);
// Simple BPM detection
let bpm = await this.detectBPM(analysisSamples, sampleRate, progressCallback);
// FINAL ABSOLUTE SAFETY: Force halve if > 140 (should never happen but catch it here)
if (bpm > 140) {
console.error('🚨 BPM TOO HIGH AFTER detectBPM:', bpm, '- FORCING HALVE');
bpm = bpm / 2;
}
if (bpm > 140) {
console.error('🚨 BPM STILL TOO HIGH:', bpm, '- FORCING HALVE AGAIN');
bpm = bpm / 2;
}
// Clamp to 60-140 range
bpm = Math.max(60, Math.min(140, bpm));
if (progressCallback) progressCallback('detecting_key', 80);
// Simple key detection
const keyInfo = await this.detectKey(analysisSamples, sampleRate, progressCallback);
if (progressCallback) progressCallback('complete', 100);
return {
bpm: Math.round(bpm * 10) / 10,
key: keyInfo.key,
camelot: keyInfo.camelot,
energy: 'Medium',
confidence: Math.round(keyInfo.confidence)
};
} catch (error) {
console.error('Analysis error:', error);
throw error;
} finally {
this.isAnalyzing = false;
}
}
/**
* Improved BPM detection using autocorrelation with subharmonic checking
* More accurate than simple peak detection - handles 98 vs 120 BPM correctly
*/
async detectBPM(samples, sampleRate, progressCallback = null) {
// Analyze first 30 seconds for better accuracy (increased from 20)
// Longer window = more accurate BPM detection, especially for tracks with tempo variations
const maxLength = Math.min(samples.length, sampleRate * 30);
const analysisSamples = samples.slice(0, maxLength);
// Downsample to 8kHz for better accuracy (increased from 4kHz)
// Higher sample rate = more precise beat detection, especially for faster tracks
const targetRate = 8000;
const downsampleFactor = Math.floor(sampleRate / targetRate);
const downsampled = [];
// Better downsampling: use average of samples in each bin for smoother signal
for (let i = 0; i < analysisSamples.length; i += downsampleFactor) {
let sum = 0;
let count = 0;
for (let j = 0; j < downsampleFactor && (i + j) < analysisSamples.length; j++) {
sum += Math.abs(analysisSamples[i + j]);
count++;
}
downsampled.push(count > 0 ? sum / count : 0);
}
// Simple band-pass filter for bass frequencies (40-200 Hz) - most reliable for BPM
// This isolates kick drum and bass, which are the most consistent beat indicators
const filtered = this.simpleBandPass(downsampled, targetRate, 40, 200);
// Autocorrelation parameters
// Real-world music BPM range: 60-150 BPM (180+ never happens in practice)
const minBPM = 60;
const maxBPM = 150; // Realistic max for music
const minLag = Math.max(1, Math.floor(targetRate * 60 / maxBPM));
const maxLag = Math.min(filtered.length - 1, Math.floor(targetRate * 60 / minBPM));
if (minLag >= maxLag) {
return 120; // Fallback
}
// Autocorrelation - find the most periodic interval
// Use full available window for maximum accuracy
const windowSize = Math.min(filtered.length, targetRate * 30); // 30 second window for accuracy
let maxCorr = 0;
let bestLag = minLag;
const correlations = [];
// Increase iterations for better accuracy - check more lags
const maxIterations = Math.min(maxLag - minLag, 4000); // More iterations for accuracy
let iterations = 0;
for (let lag = minLag; lag <= maxLag && iterations < maxIterations; lag++) {
let correlation = 0;
const compareLength = Math.min(windowSize, filtered.length - lag);
// Use smaller step size for better precision (more samples = more accurate)
// This helps detect the true beat, not subharmonics
const step = Math.max(1, Math.floor(compareLength / 3000)); // Increased from 2000 to 3000 for better accuracy
let count = 0;
for (let i = 0; i < compareLength; i += step) {
correlation += filtered[i] * filtered[i + lag];
count++;
}
// Normalize by count and lag (longer lags naturally have lower correlation, compensate)
if (count > 0) {
correlation = correlation / count;
// Compensate for lag length - longer lags (slower BPM) naturally have lower correlation
// This prevents bias toward faster BPMs
correlation = correlation * Math.sqrt(lag / minLag);
}
correlations.push({ lag, correlation });
if (correlation > maxCorr) {
maxCorr = correlation;
bestLag = lag;
}
iterations++;
// Yield to browser every 100 iterations
if (iterations % 100 === 0) {
await new Promise(resolve => setTimeout(resolve, 0));
}
}
// Convert lag to BPM
let bpm = (targetRate * 60) / bestLag;
console.log(`🎵 Raw BPM from autocorrelation: ${bpm.toFixed(1)}`);
// CRITICAL FIRST CHECK: 180+ BPM NEVER HAPPENS - HALVE IMMEDIATELY
if (bpm >= 140) {
const originalBPM = bpm;
bpm = bpm / 2;
console.log(`🎵 FORCED BPM halve: ${originalBPM.toFixed(1)} → ${bpm.toFixed(1)} (140+ never happens)`);
}
// CRITICAL: Check subharmonics - ONLY if detected BPM is suspiciously high (likely detecting every beat)
// Be more conservative - only check if BPM is clearly wrong (very high), not if it's in normal range
// This prevents lowering valid BPMs that are slightly high
if (bpm >= 130 && bpm <= 150) {
// Only check subharmonics if BPM is suspiciously high
// Check if half the BPM might be more accurate (e.g., 140 → 70)
const halfBPM = bpm / 2;
const halfLag = Math.floor(targetRate * 60 / halfBPM);
let halfCorr = 0;
if (halfLag >= minLag && halfLag <= maxLag) {
const compareLength = Math.min(windowSize, filtered.length - halfLag);
let count = 0;
for (let i = 0; i < compareLength; i++) {
halfCorr += filtered[i] * filtered[i + halfLag];
count++;
}
if (count > 0) {
halfCorr = halfCorr / count;
}
}
// Only use half BPM if correlation is MUCH stronger (1.3x threshold, not 1.1x)
// This prevents false positives on valid high BPMs
if (halfCorr > maxCorr * 1.3 && halfBPM >= 60 && halfBPM <= 100) {
bpm = halfBPM;
console.log(`🎵 Subharmonic correction: ${(bpm * 2).toFixed(1)} → ${bpm.toFixed(1)} (half correlation much stronger: ${halfCorr.toFixed(3)} vs ${maxCorr.toFixed(3)})`);
}
}
// Additional normalization for specific common errors
if (Math.abs(bpm - 188) < 2) {
bpm = 94;
} else if (bpm > 200) {
bpm = bpm / 2;
} else if (bpm < 50) {
bpm = bpm * 2;
}
// FINAL ABSOLUTE CHECK: If still above 140, force halve (should never happen but safety)
while (bpm > 140) {
bpm = bpm / 2;
console.log(`🎵 FORCED BPM halve (loop): ${(bpm * 2).toFixed(1)} → ${bpm.toFixed(1)}`);
}
// Clamp to realistic range (60-140 BPM - 180+ NEVER HAPPENS)
const finalBPM = Math.max(60, Math.min(140, Math.round(bpm * 10) / 10));
if (finalBPM > 140) {
console.error('🚨 BPM STILL TOO HIGH AFTER ALL NORMALIZATION:', finalBPM);
return 120; // Fallback
}
return finalBPM;
}
/**
* Simple band-pass filter for bass frequencies (40-200 Hz)
* This isolates kick drum and bass, which are most reliable for BPM detection
*/
simpleBandPass(samples, sampleRate, lowFreq, highFreq) {
// Simple moving average filter to approximate band-pass
// This is lightweight but effective for isolating bass frequencies
const filtered = new Float32Array(samples.length);
const windowSize = Math.floor(sampleRate / (lowFreq + highFreq) * 2); // Approximate window size
for (let i = 0; i < samples.length; i++) {
let sum = 0;
let count = 0;
const start = Math.max(0, i - windowSize);
const end = Math.min(samples.length, i + windowSize);
for (let j = start; j < end; j++) {
sum += samples[j];
count++;
}
filtered[i] = count > 0 ? sum / count : samples[i];
}
return filtered;
}
/**
* Improved key detection using FFT-based chroma (more accurate)
*/
async detectKey(samples, sampleRate, progressCallback = null) {
// Analyze middle section (most stable, avoids intro/outro)
const start = Math.floor(samples.length * 0.3);
const end = Math.floor(samples.length * 0.7);
const section = samples.slice(start, end);
// Calculate chroma using FFT-based method
const chroma = await this.calculateChromaFFT(section, sampleRate, progressCallback);
// Normalize chroma
const sum = chroma.reduce((a, b) => a + b, 0);
if (sum === 0 || !isFinite(sum)) {
console.warn('🎵 Chroma sum is zero or invalid, using fallback');
return {
key: 'C major',
camelot: '8B',
confidence: 0
};
}
for (let i = 0; i < 12; i++) {
chroma[i] /= sum;
}
// Match to key profiles (Temperley)
let bestKey = 'C major';
let bestScore = -Infinity;
const allScores = [];
for (let root = 0; root < 12; root++) {
// Major profile correlation
let majorScore = 0;
for (let i = 0; i < 12; i++) {
majorScore += chroma[(root + i) % 12] * this.majorProfile[i];
}
const majorKey = this.noteNames[root] + ' major';
allScores.push({ key: majorKey, score: majorScore });
if (majorScore > bestScore) {
bestScore = majorScore;
bestKey = majorKey;
}
// Minor profile correlation
let minorScore = 0;
for (let i = 0; i < 12; i++) {
minorScore += chroma[(root + i) % 12] * this.minorProfile[i];
}
const minorKey = this.noteNames[root] + ' minor';
allScores.push({ key: minorKey, score: minorScore });
if (minorScore > bestScore) {
bestScore = minorScore;
bestKey = minorKey;
}
}
// Sort and show top 3 for debugging
allScores.sort((a, b) => b.score - a.score);
console.log('🎵 Top 3 keys:', allScores.slice(0, 3).map(s => `${s.key}: ${s.score.toFixed(3)}`));
const camelot = this.camelotWheel[bestKey] || '8B';
// Normalize confidence (bestScore is typically 0-50, scale to 0-100)
const confidence = Math.min(100, Math.max(0, Math.round((bestScore / 50) * 100)));
console.log('🎵 Key detection:', { key: bestKey, camelot, confidence, bestScore: bestScore.toFixed(3) });
return {
key: bestKey,
camelot: camelot,
confidence: confidence
};
}
/**
* Calculate chroma features using improved frequency analysis
* Uses simpler but more reliable method than full FFT
*/
async calculateChromaFFT(samples, sampleRate, progressCallback = null) {
const chroma = new Float32Array(12).fill(0);
// Use smaller, more manageable analysis window
const windowSize = 4096; // Smaller window for faster processing
const hopSize = 2048; // 50% overlap
const numFrames = Math.floor((samples.length - windowSize) / hopSize) + 1;
if (numFrames <= 0) {
console.warn('🎵 Not enough samples for chroma analysis');
return chroma;
}
// Analyze middle section (most stable)
const startFrame = Math.floor(numFrames * 0.2);
const endFrame = Math.floor(numFrames * 0.8);
const framesToAnalyze = Math.min(endFrame - startFrame, 50); // Limit to 50 frames for performance
for (let f = 0; f < framesToAnalyze; f++) {
const frameIdx = startFrame + Math.floor((f / framesToAnalyze) * (endFrame - startFrame));
const start = frameIdx * hopSize;
const end = Math.min(start + windowSize, samples.length);
const frameData = samples.slice(start, end);
// Apply window function
const windowed = new Float32Array(frameData.length);
for (let i = 0; i < frameData.length; i++) {
const window = 0.5 * (1 - Math.cos(2 * Math.PI * i / (frameData.length - 1)));
windowed[i] = frameData[i] * window;
}
// Calculate power spectrum using autocorrelation (faster than FFT)
const spectrum = this.calculatePowerSpectrum(windowed, sampleRate);
// Map frequencies to chroma bins (focus on mid-range frequencies: 200-2000 Hz)
for (let octave = 3; octave <= 6; octave++) {
for (let note = 0; note < 12; note++) {
const freq = 440 * Math.pow(2, (note - 9 + (octave - 4) * 12) / 12);
// Find closest frequency bin
const binIdx = Math.round(freq * (spectrum.length - 1) / (sampleRate / 2));
if (binIdx >= 0 && binIdx < spectrum.length) {
chroma[note] += spectrum[binIdx];
}
}
}
// Yield periodically
if (f % 10 === 0) {
await new Promise(resolve => setTimeout(resolve, 0));
}
}
// Log chroma for debugging
console.log('🎵 Chroma values:', Array.from(chroma).map(v => v.toFixed(2)));
return chroma;
}
/**
* Calculate power spectrum using autocorrelation (faster than FFT)
*/
calculatePowerSpectrum(samples, sampleRate) {
const N = samples.length;
const spectrumSize = Math.floor(N / 2);
const spectrum = new Float32Array(spectrumSize);
// Use autocorrelation to estimate power spectrum
for (let k = 0; k < spectrumSize; k++) {
let sum = 0;
const lag = k;
const maxI = N - lag;
for (let i = 0; i < maxI; i++) {
sum += samples[i] * samples[i + lag];
}
spectrum[k] = Math.abs(sum / maxI);
}
return spectrum;
}
}
// Lazy initialization - only create when needed
Object.defineProperty(window, 'simpleAudioAnalyzer', {
get: function() {
if (!window._simpleAudioAnalyzer) {
window._simpleAudioAnalyzer = new SimpleAudioAnalyzer();
}
return window._simpleAudioAnalyzer;
},
configurable: true
});