![]() 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/ |
/**
* Audio Analyzer - BPM and Key Detection
* Uses Web Audio API to analyze audio and detect BPM, musical key, and energy
*
* Key Detection Improvements:
* - Uses Temperley profiles (better for modern/pop music) instead of Krumhansl-Schmuckler
* - Also tests Aarden-Essen profiles and picks the best match
* - Improved chroma calculation with FFT-based frequency analysis
* - Analyzes multiple octaves (2-6) with proper weighting
* - Chroma smoothing to reduce noise
*
* Future improvements:
* - Could integrate with external APIs (AcousticBrainz, Spotify Audio Features)
* - Could use Essentia.js library for professional-grade analysis
* - Could add machine learning model for even better accuracy
*
* @author SoundStudioPro
*/
class AudioAnalyzer {
constructor() {
this.audioContext = null;
this.isAnalyzing = false;
// Camelot wheel mapping (standard to Camelot)
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'
};
// Key profiles for detection
// Using Temperley profiles (better for modern/pop music) instead of Krumhansl-Schmuckler
// Temperley profiles are more accurate for electronic, pop, rock, and contemporary music
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];
// Alternative: Aarden-Essen profiles (also good for modern music)
this.majorProfileAlt = [17.7661, 0.145624, 14.9265, 0.160186, 19.8049, 11.3587, 0.291248, 22.062, 0.145624, 8.15494, 0.232998, 4.95122];
this.minorProfileAlt = [18.2648, 0.737619, 14.0499, 16.8599, 0.702494, 14.4362, 0.702494, 18.6161, 4.56621, 1.93186, 7.59219, 1.75623];
// Note names for key detection
this.noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
}
/**
* Initialize the audio context
*/
initAudioContext() {
if (!this.audioContext) {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
return this.audioContext;
}
/**
* Analyze an audio URL and return BPM, key, and energy
* @param {string} audioUrl - URL of the audio file
* @param {function} progressCallback - Called with progress updates
* @returns {Promise<object>} - Analysis results
*/
async analyzeAudio(audioUrl, progressCallback = null) {
if (this.isAnalyzing) {
console.warn('🎵 Analysis already in progress');
throw new Error('Analysis is already in progress. Please wait for it to complete.');
}
this.isAnalyzing = true;
try {
if (progressCallback) progressCallback('loading', 0);
// Initialize audio context
const ctx = this.initAudioContext();
// Fetch audio data
console.log('🎵 Fetching audio from:', audioUrl);
const response = await fetch(audioUrl);
if (!response.ok) {
throw new Error(`Failed to fetch audio: ${response.status}`);
}
if (progressCallback) progressCallback('loading', 30);
const arrayBuffer = await response.arrayBuffer();
if (progressCallback) progressCallback('decoding', 40);
// Decode audio data
console.log('🎵 Decoding audio...');
const audioBuffer = await ctx.decodeAudioData(arrayBuffer);
if (progressCallback) progressCallback('analyzing', 50);
// Get audio data as float array
const channelData = audioBuffer.getChannelData(0);
const sampleRate = audioBuffer.sampleRate;
const duration = audioBuffer.duration;
console.log('🎵 Audio loaded:', { sampleRate, duration: duration.toFixed(2) + 's' });
// SAFETY: Prevent analysis of extremely long files (performance protection)
if (duration > 600) { // 10 minutes max
throw new Error('Audio file too long for analysis (max 10 minutes)');
}
// Detect BPM (already normalized inside detectBPM)
if (progressCallback) progressCallback('detecting_bpm', 60);
const detectedBpm = await this.detectBPM(channelData, sampleRate);
console.log('🎵 BPM detection:', { detected: detectedBpm });
// Detect Key
if (progressCallback) progressCallback('detecting_key', 75);
const keyInfo = await this.detectKey(channelData, sampleRate);
// Validate keyInfo
if (!keyInfo || !keyInfo.key || !keyInfo.camelot) {
console.error('🎵 Key detection failed or returned invalid result:', keyInfo);
throw new Error('Key detection failed. The audio may be too short or corrupted.');
}
// Calculate Energy
if (progressCallback) progressCallback('calculating_energy', 90);
const energy = this.calculateEnergy(channelData);
// Validate energy
if (!energy || typeof energy !== 'string') {
console.warn('🎵 Energy calculation returned invalid value:', energy, '- using fallback');
// Use fallback - recalculate
const recalculatedEnergy = this.calculateEnergy(channelData);
if (recalculatedEnergy && typeof recalculatedEnergy === 'string') {
energy = recalculatedEnergy;
} else {
// Last resort fallback
energy = 'Medium';
console.warn('🎵 Using fallback energy value: Medium');
}
}
if (progressCallback) progressCallback('complete', 100);
// Validate detectedBpm
if (!detectedBpm || detectedBpm <= 0 || !isFinite(detectedBpm)) {
console.error('🎵 BPM detection returned invalid value:', detectedBpm);
throw new Error('BPM detection failed. The audio may be too short or corrupted.');
}
// ABSOLUTE SAFETY: Hard clamp - 188 CANNOT pass through
// This is the final line of defense against the 188 bug
let finalBpm = detectedBpm;
// CLEAN: Only normalize clearly wrong values, preserve valid BPMs
// Valid BPM range: 60-200 (some genres go up to 200 BPM)
// Only normalize if it's clearly a detection error
// Values > 200: Almost always wrong, halve them
if (finalBpm > 200) {
const original = finalBpm;
while (finalBpm > 200) {
finalBpm = finalBpm / 2;
}
console.log(`🎵 Normalized very high BPM ${original} → ${finalBpm}`);
}
// 188 is a common error (94*2) - normalize it specifically
else if (Math.abs(finalBpm - 188) < 2) {
finalBpm = 94;
console.log(`🎵 Fixed common error: 188 → 94`);
}
// Values < 50: Very low, likely wrong, double them
else if (finalBpm < 50 && finalBpm > 0) {
const doubled = finalBpm * 2;
if (doubled <= 200) {
finalBpm = doubled;
console.log(`🎵 Normalized very low BPM ${detectedBpm} → ${finalBpm}`);
}
}
// Otherwise keep the detected value (60-200 is valid range)
// Final validation before creating result
if (!keyInfo || !keyInfo.key || !keyInfo.camelot) {
throw new Error('Key detection returned invalid data. Please try again.');
}
if (!energy || typeof energy !== 'string') {
energy = 'Medium'; // Fallback
}
if (!finalBpm || finalBpm <= 0 || !isFinite(finalBpm)) {
throw new Error('BPM detection returned invalid value. Please try again.');
}
const result = {
bpm: Math.round(finalBpm * 10) / 10, // Round to 1 decimal (e.g., 94.2)
key: keyInfo.key,
camelot: keyInfo.camelot,
confidence: keyInfo.confidence || 0,
energy: energy,
duration: Math.round(duration),
analyzed: true,
analyzedAt: new Date().toISOString()
};
// Final validation of result object
if (!result.bpm || !result.key || !result.camelot) {
console.error('🎵 Result object validation failed:', result);
throw new Error('Analysis completed but result is invalid. Please try again.');
}
console.log('🎵 Final analysis result:', result);
console.log('🎵 Result validation:', {
hasBpm: !!result.bpm,
hasKey: !!result.key,
hasCamelot: !!result.camelot,
hasEnergy: !!result.energy,
bpmValue: result.bpm,
keyValue: result.key
});
// Final check before returning
if (!result || !result.bpm || !result.key || !result.camelot) {
console.error('🎵 FATAL: Result validation failed before return:', result);
throw new Error('Result validation failed. Analysis may have encountered an error.');
}
return result;
} catch (error) {
console.error('🎵 Analysis error:', error);
console.error('🎵 Error details:', {
message: error.message,
stack: error.stack,
audioUrl: audioUrl ? 'present' : 'missing'
});
// Provide more helpful error messages
let errorMessage = error.message || 'Unknown error during analysis';
if (error.message && error.message.includes('Failed to fetch')) {
errorMessage = 'Failed to load audio file. The file may be missing or inaccessible.';
} else if (error.message && error.message.includes('decodeAudioData')) {
errorMessage = 'Failed to decode audio file. The file format may not be supported.';
} else if (error.message && error.message.includes('too long')) {
errorMessage = 'Audio file is too long for analysis (max 10 minutes).';
}
// Create a new error with the helpful message
const helpfulError = new Error(errorMessage);
helpfulError.originalError = error;
throw helpfulError;
} finally {
this.isAnalyzing = false;
}
}
/**
* Detect BPM using multiple methods and pick the best result
* RESTORED: Multi-method detection for better accuracy (user feedback: previous was better)
* OPTIMIZED: Reduced processing time while maintaining accuracy
*/
async detectBPM(channelData, sampleRate) {
// Use 30 seconds max for faster processing
const sampleLength = Math.min(channelData.length, sampleRate * 30);
const samples = channelData.slice(0, sampleLength);
// Try multiple detection methods (restored for accuracy)
const results = [];
// Method 1: Autocorrelation on bass frequencies (40-200 Hz) - most reliable
const bassFiltered = this.bandPassFilter(samples, sampleRate, 40, 200);
let bpm1 = await this.detectBPMAutocorrelation(bassFiltered, sampleRate);
if (bpm1 > 0) {
// Don't normalize here - let the final normalization handle it after averaging
// This prevents over-normalization of valid BPMs
results.push(bpm1);
}
// Method 2: Autocorrelation on kick frequencies (60-100 Hz) - good for electronic music
const kickFiltered = this.bandPassFilter(samples, sampleRate, 60, 100);
let bpm2 = await this.detectBPMAutocorrelation(kickFiltered, sampleRate);
if (bpm2 > 0) {
// Don't normalize here - let the final normalization handle it
results.push(bpm2);
}
// Method 3: Onset-based detection - good for acoustic music
let onsetBPM = this.detectBPMOnsets(samples, sampleRate);
if (onsetBPM > 0) {
// Don't normalize here - let the final normalization handle it
results.push(onsetBPM);
}
console.log('🎵 BPM detection results (after normalization):', results);
// Pick the most common value (or average if close)
if (results.length === 0) {
return 120; // Fallback
}
// Group similar BPMs together (within 3 BPM for tighter grouping)
// This prevents 82 and 94 from being grouped together
const groups = [];
for (const bpm of results) {
let found = false;
for (const group of groups) {
// Tighter grouping: within 3 BPM (not 5) to avoid grouping 82 with 94
if (Math.abs(group.avg - bpm) < 3) {
group.values.push(bpm);
group.avg = group.values.reduce((a, b) => a + b) / group.values.length;
found = true;
break;
}
}
if (!found) {
groups.push({ values: [bpm], avg: bpm });
}
}
// Sort by: 1) Most values, 2) Prefer values in common range (60-140)
groups.sort((a, b) => {
const aInRange = a.avg >= 60 && a.avg <= 140;
const bInRange = b.avg >= 60 && b.avg <= 140;
// If one is in range and other isn't, prefer the one in range
if (aInRange && !bInRange) return -1;
if (!aInRange && bInRange) return 1;
// Otherwise prefer group with more values
return b.values.length - a.values.length;
});
const bestBPM = groups[0].avg;
console.log('🎵 BPM detection groups:', groups.map(g => `${g.avg.toFixed(1)} (${g.values.length} votes)`));
console.log('🎵 Selected BPM:', bestBPM);
// Normalize to reasonable range (this should fix 188 -> 94)
return this.normalizeBPM(bestBPM);
}
/**
* Detect BPM using autocorrelation
*/
async detectBPMAutocorrelation(samples, sampleRate) {
// OPTIMIZATION: More aggressive downsampling (target ~4kHz instead of 8kHz)
// This reduces processing time by ~75%
const downsampleFactor = Math.max(1, Math.floor(sampleRate / 4000));
const downsampled = [];
for (let i = 0; i < samples.length; i += downsampleFactor) {
downsampled.push(Math.abs(samples[i]));
}
const downsampledRate = sampleRate / downsampleFactor;
const minBPM = 60;
const maxBPM = 180;
// Convert BPM range to lag range
const minLag = Math.floor(downsampledRate * 60 / maxBPM);
const maxLag = Math.floor(downsampledRate * 60 / minBPM);
// Calculate autocorrelation
let maxCorr = 0;
let bestLag = minLag;
const correlations = [];
// OPTIMIZATION: Reduce window to 10 seconds (instead of 20) for faster processing
const windowSize = Math.min(downsampled.length, downsampledRate * 10);
for (let lag = minLag; lag <= maxLag; lag++) {
let corr = 0;
let count = 0;
for (let i = 0; i < windowSize - lag; i++) {
corr += downsampled[i] * downsampled[i + lag];
count++;
}
if (count > 0) {
corr /= count;
correlations.push({ lag, corr });
if (corr > maxCorr) {
maxCorr = corr;
bestLag = lag;
}
}
}
if (bestLag === 0) return 0;
// Calculate BPM from lag
let bpm = (downsampledRate * 60) / bestLag;
// Check for subharmonics and harmonics (improved detection)
// Check both half-beat (subharmonic) and double-beat (harmonic) patterns
const candidates = [{ bpm, corr: maxCorr, lag: bestLag }];
// Check subharmonic (half-beat): if we found 164, actual might be 82
if (bpm > 140) {
const halfLag = bestLag * 2;
if (halfLag <= maxLag) {
let halfCorr = 0;
let count = 0;
for (let i = 0; i < windowSize - halfLag; i++) {
halfCorr += downsampled[i] * downsampled[i + halfLag];
count++;
}
if (count > 0) halfCorr /= count;
if (halfCorr > maxCorr * 0.6) {
const halfBpm = (downsampledRate * 60) / halfLag;
candidates.push({ bpm: halfBpm, corr: halfCorr, lag: halfLag });
}
}
}
// Check harmonic (double-beat): if we found 41, actual might be 82
if (bpm < 100) {
const doubleLag = Math.floor(bestLag / 2);
if (doubleLag >= minLag) {
let doubleCorr = 0;
let count = 0;
for (let i = 0; i < windowSize - doubleLag; i++) {
doubleCorr += downsampled[i] * downsampled[i + doubleLag];
count++;
}
if (count > 0) doubleCorr /= count;
if (doubleCorr > maxCorr * 0.6) {
const doubleBpm = (downsampledRate * 60) / doubleLag;
candidates.push({ bpm: doubleBpm, corr: doubleCorr, lag: doubleLag });
}
}
}
// Pick the candidate with best correlation, but prefer values in 60-140 range
candidates.sort((a, b) => {
// Prefer values in common range (60-140)
const aInRange = a.bpm >= 60 && a.bpm <= 140;
const bInRange = b.bpm >= 60 && b.bpm <= 140;
if (aInRange && !bInRange) return -1;
if (!aInRange && bInRange) return 1;
// Otherwise prefer higher correlation
return b.corr - a.corr;
});
return candidates[0].bpm;
}
/**
* Detect BPM using onset detection
*/
detectBPMOnsets(samples, sampleRate) {
// Apply low-pass filter to focus on bass/drums
const filtered = this.lowPassFilter(samples, sampleRate, 200);
// Detect onsets
const onsets = this.detectOnsets(filtered, sampleRate);
if (onsets.length < 4) {
return 0;
}
// Calculate intervals between onsets
const intervals = [];
for (let i = 1; i < onsets.length; i++) {
intervals.push(onsets[i] - onsets[i-1]);
}
// Find the most common interval
const beatPeriod = this.findMostCommonInterval(intervals, sampleRate);
if (beatPeriod <= 0) return 0;
// Convert to BPM
return 60 / beatPeriod;
}
/**
* Band-pass filter
*/
bandPassFilter(samples, sampleRate, lowFreq, highFreq) {
// Simple band-pass using two filters
const lowPassed = this.lowPassFilter(samples, sampleRate, highFreq);
const highPassed = this.highPassFilter(lowPassed, sampleRate, lowFreq);
return highPassed;
}
/**
* High-pass filter
*/
highPassFilter(samples, sampleRate, cutoff) {
const RC = 1.0 / (cutoff * 2 * Math.PI);
const dt = 1.0 / sampleRate;
const alpha = RC / (RC + dt);
const filtered = new Float32Array(samples.length);
filtered[0] = samples[0];
for (let i = 1; i < samples.length; i++) {
filtered[i] = alpha * (filtered[i-1] + samples[i] - samples[i-1]);
}
return filtered;
}
/**
* Simple low-pass filter
*/
lowPassFilter(samples, sampleRate, cutoff) {
const RC = 1.0 / (cutoff * 2 * Math.PI);
const dt = 1.0 / sampleRate;
const alpha = dt / (RC + dt);
const filtered = new Float32Array(samples.length);
filtered[0] = samples[0];
for (let i = 1; i < samples.length; i++) {
filtered[i] = filtered[i-1] + alpha * (samples[i] - filtered[i-1]);
}
return filtered;
}
/**
* Detect onsets (sudden increases in energy)
*/
detectOnsets(samples, sampleRate) {
const hopSize = Math.floor(sampleRate * 0.01); // 10ms hop
const windowSize = Math.floor(sampleRate * 0.05); // 50ms window
const onsets = [];
let prevEnergy = 0;
for (let i = 0; i < samples.length - windowSize; i += hopSize) {
let energy = 0;
for (let j = 0; j < windowSize; j++) {
energy += samples[i + j] * samples[i + j];
}
energy /= windowSize;
// Detect onset if energy increased significantly
if (energy > prevEnergy * 1.5 && energy > 0.001) {
onsets.push(i / sampleRate);
}
prevEnergy = energy;
}
return onsets;
}
/**
* Find the most common interval using histogram
*/
findMostCommonInterval(intervals, sampleRate) {
// Create histogram of intervals
const binSize = 0.01; // 10ms bins
const histogram = {};
for (const interval of intervals) {
const bin = Math.round(interval / binSize);
histogram[bin] = (histogram[bin] || 0) + 1;
}
// Find peak in histogram
let maxCount = 0;
let peakBin = 0;
for (const bin in histogram) {
if (histogram[bin] > maxCount) {
maxCount = histogram[bin];
peakBin = parseInt(bin);
}
}
return peakBin * binSize;
}
/**
* Fallback BPM detection using autocorrelation
*/
fallbackBPM(samples, sampleRate) {
// Downsample for faster processing
const downsampleFactor = 8;
const downsampled = [];
for (let i = 0; i < samples.length; i += downsampleFactor) {
downsampled.push(Math.abs(samples[i]));
}
const downsampledRate = sampleRate / downsampleFactor;
// Calculate autocorrelation for lag corresponding to 60-180 BPM
const minLag = Math.floor(downsampledRate * 60 / 180); // 180 BPM
const maxLag = Math.floor(downsampledRate * 60 / 60); // 60 BPM
let maxCorr = 0;
let bestLag = minLag;
for (let lag = minLag; lag <= maxLag; lag++) {
let corr = 0;
const len = Math.min(downsampled.length - lag, 5000);
for (let i = 0; i < len; i++) {
corr += downsampled[i] * downsampled[i + lag];
}
if (corr > maxCorr) {
maxCorr = corr;
bestLag = lag;
}
}
return (downsampledRate * 60) / bestLag;
}
/**
* Detect musical key using improved chroma features
* OPTIMIZED: Use single method and shorter analysis window for performance
*/
async detectKey(channelData, sampleRate) {
// OPTIMIZATION: Use only 15 seconds max (instead of 60) and single window
const maxLength = sampleRate * 15; // 15 seconds max
const sampleLength = Math.min(channelData.length, maxLength);
const samples = channelData.slice(0, sampleLength);
// OPTIMIZATION: Use only ONE chroma method (improved) instead of 3
// This reduces processing time by 66%
const chroma = this.calculateChromaImproved(samples, sampleRate);
// Debug: Log chroma values to verify they're different per track
console.log('🎵 Chroma values:', Array.from(chroma).map(v => v.toFixed(3)));
// Normalize
const sum = chroma.reduce((a, b) => a + b, 0);
if (sum > 0) {
for (let i = 0; i < 12; i++) {
chroma[i] /= sum;
}
} else {
console.warn('🎵 WARNING: Chroma sum is zero - detection may be inaccurate');
}
// Detect key from chroma
const keyInfo = this.detectKeyFromChroma(chroma);
console.log('🎵 Detected key:', keyInfo);
// Validate and provide fallback if needed
if (!keyInfo || !keyInfo.key || !keyInfo.camelot) {
console.warn('🎵 Key detection returned invalid result, using fallback:', keyInfo);
return {
key: 'C major',
camelot: '8B',
confidence: 0
};
}
return keyInfo;
}
/**
* Detect key from a single chroma vector
*/
detectKeyFromChroma(chroma) {
// Compare with major and minor profiles for each key
// Try both Temperley and Aarden-Essen profiles, pick the best
let bestKey = 'C major';
let bestCorrelation = -1;
const correlations = [];
// Test with both profile sets
const profileSets = [
{ major: this.majorProfile, minor: this.minorProfile, name: 'Temperley' },
{ major: this.majorProfileAlt, minor: this.minorProfileAlt, name: 'Aarden-Essen' }
];
for (const profileSet of profileSets) {
for (let keyIndex = 0; keyIndex < 12; keyIndex++) {
// Rotate chroma to align with key
const rotatedChroma = this.rotateArray(chroma, keyIndex);
// Correlate with major profile
const majorCorr = this.correlate(rotatedChroma, profileSet.major);
correlations.push({
key: this.noteNames[keyIndex] + ' major',
correlation: majorCorr,
isMinor: false
});
if (majorCorr > bestCorrelation) {
bestCorrelation = majorCorr;
bestKey = this.noteNames[keyIndex] + ' major';
}
// Correlate with minor profile
const minorCorr = this.correlate(rotatedChroma, profileSet.minor);
correlations.push({
key: this.noteNames[keyIndex] + ' minor',
correlation: minorCorr,
isMinor: true
});
if (minorCorr > bestCorrelation) {
bestCorrelation = minorCorr;
bestKey = this.noteNames[keyIndex] + ' minor';
}
}
}
// Debug: Log top correlations to see what's being detected
correlations.sort((a, b) => b.correlation - a.correlation);
const top3 = correlations.slice(0, 3);
console.log('🎵 Top 3 key correlations:', top3.map(c => {
const camelot = this.getCamelotKey(c.key);
return `${c.key} (${camelot}): ${c.correlation.toFixed(3)}`;
}));
// Get Camelot notation
const camelot = this.getCamelotKey(bestKey);
// Calculate confidence from correlation (normalize -1 to 1 range to 0-100)
const confidence = Math.round(((bestCorrelation + 1) / 2) * 100);
console.log('🎵 Selected key:', { key: bestKey, camelot, confidence, correlation: bestCorrelation.toFixed(3) });
return {
key: bestKey,
camelot: camelot,
confidence: Math.min(100, Math.max(0, confidence)),
correlation: bestCorrelation
};
}
/**
* Vote on key from multiple window analyses
*/
voteOnKey(keyVotes) {
if (keyVotes.length === 0) {
return {
key: 'C major',
camelot: '8B',
confidence: 0
};
}
// Count votes for each key
const voteCounts = {};
let totalCorrelation = 0;
for (const vote of keyVotes) {
const key = vote.key;
if (!voteCounts[key]) {
voteCounts[key] = { count: 0, totalCorr: 0 };
}
voteCounts[key].count++;
voteCounts[key].totalCorr += vote.correlation;
totalCorrelation += vote.correlation;
}
// Find key with most votes
let bestKey = 'C major';
let maxVotes = 0;
let bestAvgCorr = 0;
for (const [key, data] of Object.entries(voteCounts)) {
const avgCorr = data.totalCorr / data.count;
// Weight by both vote count and average correlation
const score = data.count * 0.6 + avgCorr * 0.4;
if (score > maxVotes || (score === maxVotes && avgCorr > bestAvgCorr)) {
maxVotes = score;
bestKey = key;
bestAvgCorr = avgCorr;
}
}
// Get Camelot notation
const camelot = this.getCamelotKey(bestKey);
// Confidence based on agreement and correlation
const agreement = voteCounts[bestKey].count / keyVotes.length;
const avgCorrelation = voteCounts[bestKey].totalCorr / voteCounts[bestKey].count;
const confidence = Math.round((agreement * 0.5 + (avgCorrelation + 1) / 2 * 0.5) * 100);
return {
key: bestKey,
camelot: camelot,
confidence: Math.min(100, Math.max(0, confidence))
};
}
/**
* Calculate CENS (Chroma Energy Normalized Statistics) - most robust chroma representation
* This is used in professional audio analysis tools
*/
calculateChromaCENS(samples, sampleRate) {
const fftSize = 16384;
const hopSize = fftSize / 4;
const sampleLength = Math.min(samples.length, sampleRate * 30);
// Step 1: Calculate chroma features
const chromaFrames = [];
const numFrames = Math.floor((sampleLength - fftSize) / hopSize);
for (let frame = 0; frame < numFrames; frame++) {
const offset = frame * hopSize;
const frameData = samples.slice(offset, offset + fftSize);
// Apply window
for (let i = 0; i < frameData.length; i++) {
frameData[i] *= 0.5 * (1 - Math.cos(2 * Math.PI * i / (frameData.length - 1)));
}
// Calculate chroma for this frame
const frameChroma = new Float32Array(12);
const fft = this.fft(frameData);
const magnitude = fft.magnitude;
for (let pitchClass = 0; pitchClass < 12; pitchClass++) {
let energy = 0;
for (let octave = 2; octave <= 6; octave++) {
const freq = 440 * Math.pow(2, (pitchClass - 9 + (octave - 4) * 12) / 12);
const binIndex = Math.round(freq * fftSize / sampleRate);
if (binIndex > 0 && binIndex < magnitude.length) {
energy += magnitude[binIndex];
}
}
frameChroma[pitchClass] = energy;
}
// Normalize frame
const sum = frameChroma.reduce((a, b) => a + b, 0);
if (sum > 0) {
for (let i = 0; i < 12; i++) {
frameChroma[i] /= sum;
}
}
chromaFrames.push(Array.from(frameChroma));
}
// Step 2: Quantize chroma (CENS quantization)
const quantizedFrames = chromaFrames.map(chroma => {
const quantized = new Float32Array(12);
for (let i = 0; i < 12; i++) {
// Quantize to 5 levels (0-4)
quantized[i] = Math.min(4, Math.floor(chroma[i] * 5));
}
return Array.from(quantized);
});
// Step 3: Smooth over time (temporal smoothing)
const smoothed = [];
const smoothingWindow = 3; // 3 frames
for (let i = 0; i < quantizedFrames.length; i++) {
const smoothedFrame = new Float32Array(12);
let count = 0;
for (let j = Math.max(0, i - smoothingWindow); j <= Math.min(quantizedFrames.length - 1, i + smoothingWindow); j++) {
for (let k = 0; k < 12; k++) {
smoothedFrame[k] += quantizedFrames[j][k];
}
count++;
}
for (let k = 0; k < 12; k++) {
smoothedFrame[k] /= count;
}
smoothed.push(Array.from(smoothedFrame));
}
// Step 4: Average over all frames
const finalChroma = new Float32Array(12);
for (const frame of smoothed) {
for (let i = 0; i < 12; i++) {
finalChroma[i] += frame[i];
}
}
for (let i = 0; i < 12; i++) {
finalChroma[i] /= smoothed.length;
}
// Normalize
const sum = finalChroma.reduce((a, b) => a + b, 0);
if (sum > 0) {
for (let i = 0; i < 12; i++) {
finalChroma[i] /= sum;
}
}
return Array.from(finalChroma);
}
/**
* Improved chroma calculation using FFT-based frequency analysis
*/
calculateChromaImproved(samples, sampleRate) {
const chroma = new Float32Array(12).fill(0);
// OPTIMIZATION: Reduce FFT size from 16384 to 8192 (4x faster, still accurate)
const fftSize = 8192;
const hopSize = fftSize / 2;
// OPTIMIZATION: Use only 15 seconds (matching detectKey) instead of 60
const sampleLength = Math.min(samples.length, sampleRate * 15);
const numFrames = Math.floor((sampleLength - fftSize) / hopSize);
// Safety check
if (numFrames <= 0) {
console.warn('🎵 WARNING: Not enough samples for chroma calculation');
return chroma;
}
// Use Web Audio API AnalyserNode if available for faster FFT
const useWebAudioFFT = this.audioContext && typeof this.audioContext.createAnalyser === 'function';
for (let frame = 0; frame < numFrames; frame++) {
const offset = frame * hopSize;
const frameData = samples.slice(offset, offset + fftSize);
// Apply Hann window
for (let i = 0; i < frameData.length; i++) {
frameData[i] *= 0.5 * (1 - Math.cos(2 * Math.PI * i / (frameData.length - 1)));
}
// Calculate FFT magnitude spectrum
const fft = this.fft(frameData);
const magnitude = fft.magnitude;
const freqs = fft.frequencies;
// Map FFT bins to chroma bins using log-frequency mapping
for (let pitchClass = 0; pitchClass < 12; pitchClass++) {
let energy = 0;
// OPTIMIZATION: Reduce octave range from 2-6 to 3-5 (still accurate, faster)
for (let octave = 3; octave <= 5; octave++) {
// Calculate exact frequency for this pitch class and octave
const freq = 440 * Math.pow(2, (pitchClass - 9 + (octave - 4) * 12) / 12);
// Find closest FFT bin
const binIndex = Math.round(freq * fftSize / sampleRate);
if (binIndex > 0 && binIndex < magnitude.length) {
// Get energy at this frequency
let binEnergy = magnitude[binIndex];
// Also check neighboring bins (weighted average for smoother results)
if (binIndex > 0) binEnergy += magnitude[binIndex - 1] * 0.3;
if (binIndex < magnitude.length - 1) binEnergy += magnitude[binIndex + 1] * 0.3;
// Weight by octave (lower octaves more important for key detection)
const octaveWeight = 1.0 / (octave - 1);
energy += binEnergy * octaveWeight;
}
}
chroma[pitchClass] += energy;
}
}
// Apply smoothing to chroma vector (helps with noisy signals)
const smoothed = this.smoothChroma(chroma);
// Normalize
const sum = smoothed.reduce((a, b) => a + b, 0);
if (sum > 0) {
for (let i = 0; i < 12; i++) {
smoothed[i] /= sum;
}
}
return Array.from(smoothed);
}
/**
* Optimized DFT implementation for frequency analysis
* Uses windowing and efficient calculation for better accuracy
*/
fft(samples) {
const N = samples.length;
const magnitude = new Float32Array(N / 2);
// Optimized DFT with pre-computed constants
for (let k = 0; k < N / 2; k++) {
let real = 0;
let imag = 0;
const angleIncrement = -2 * Math.PI * k / N;
// Unroll loop for better performance (process 4 samples at a time)
let n = 0;
for (; n < N - 3; n += 4) {
const angle0 = angleIncrement * n;
const angle1 = angleIncrement * (n + 1);
const angle2 = angleIncrement * (n + 2);
const angle3 = angleIncrement * (n + 3);
real += samples[n] * Math.cos(angle0) +
samples[n + 1] * Math.cos(angle1) +
samples[n + 2] * Math.cos(angle2) +
samples[n + 3] * Math.cos(angle3);
imag += samples[n] * Math.sin(angle0) +
samples[n + 1] * Math.sin(angle1) +
samples[n + 2] * Math.sin(angle2) +
samples[n + 3] * Math.sin(angle3);
}
// Handle remaining samples
for (; n < N; n++) {
const angle = angleIncrement * n;
real += samples[n] * Math.cos(angle);
imag += samples[n] * Math.sin(angle);
}
magnitude[k] = Math.sqrt(real * real + imag * imag) / N; // Normalize
}
return {
magnitude,
frequencies: new Float32Array(magnitude.length).map((_, i) => i)
};
}
/**
* Smooth chroma vector to reduce noise
*/
smoothChroma(chroma) {
const smoothed = new Float32Array(12);
for (let i = 0; i < 12; i++) {
const prev = chroma[(i + 11) % 12];
const curr = chroma[i];
const next = chroma[(i + 1) % 12];
smoothed[i] = prev * 0.2 + curr * 0.6 + next * 0.2;
}
return smoothed;
}
/**
* Calculate chroma features (12 pitch classes)
*/
calculateChroma(samples, sampleRate) {
const chroma = new Float32Array(12).fill(0);
const fftSize = 4096;
// Use a portion of the audio
const sampleLength = Math.min(samples.length, sampleRate * 20);
const numFrames = Math.floor(sampleLength / fftSize);
for (let frame = 0; frame < numFrames; frame++) {
const offset = frame * fftSize;
const frameData = samples.slice(offset, offset + fftSize);
// Apply Hann window
for (let i = 0; i < fftSize; i++) {
frameData[i] *= 0.5 * (1 - Math.cos(2 * Math.PI * i / (fftSize - 1)));
}
// Simple DFT for chroma bins (not full FFT for performance)
for (let pitchClass = 0; pitchClass < 12; pitchClass++) {
// Calculate energy at frequencies corresponding to this pitch class
for (let octave = 1; octave <= 8; octave++) {
const freq = 440 * Math.pow(2, (pitchClass - 9 + (octave - 4) * 12) / 12);
const bin = Math.round(freq * fftSize / sampleRate);
if (bin > 0 && bin < fftSize / 2) {
// Goertzel algorithm for single frequency
const energy = this.goertzel(frameData, freq, sampleRate);
chroma[pitchClass] += energy;
}
}
}
}
// Normalize
const sum = chroma.reduce((a, b) => a + b, 0);
if (sum > 0) {
for (let i = 0; i < 12; i++) {
chroma[i] /= sum;
}
}
return Array.from(chroma);
}
/**
* Goertzel algorithm for efficient single-frequency DFT
*/
goertzel(samples, targetFreq, sampleRate) {
const k = Math.round(targetFreq * samples.length / sampleRate);
const w = (2 * Math.PI * k) / samples.length;
const cosw = Math.cos(w);
const sinw = Math.sin(w);
const coeff = 2 * cosw;
let s0 = 0, s1 = 0, s2 = 0;
for (let i = 0; i < samples.length; i++) {
s0 = samples[i] + coeff * s1 - s2;
s2 = s1;
s1 = s0;
}
const real = s1 - s2 * cosw;
const imag = s2 * sinw;
return Math.sqrt(real * real + imag * imag);
}
/**
* Rotate array by n positions
*/
rotateArray(arr, n) {
const len = arr.length;
n = ((n % len) + len) % len;
return [...arr.slice(n), ...arr.slice(0, n)];
}
/**
* Pearson correlation coefficient
*/
correlate(a, b) {
const n = a.length;
let sumA = 0, sumB = 0, sumAB = 0, sumA2 = 0, sumB2 = 0;
for (let i = 0; i < n; i++) {
sumA += a[i];
sumB += b[i];
sumAB += a[i] * b[i];
sumA2 += a[i] * a[i];
sumB2 += b[i] * b[i];
}
const num = n * sumAB - sumA * sumB;
const den = Math.sqrt((n * sumA2 - sumA * sumA) * (n * sumB2 - sumB * sumB));
return den === 0 ? 0 : num / den;
}
/**
* Get Camelot notation for a key
*/
getCamelotKey(key) {
// Normalize key name
const normalizedKey = key.replace('♯', '#').replace('♭', 'b');
return this.camelotWheel[normalizedKey] || this.camelotWheel[key] || '8B';
}
/**
* Calculate overall energy level
*/
calculateEnergy(samples) {
// Calculate RMS energy
let sumSquared = 0;
for (let i = 0; i < samples.length; i++) {
sumSquared += samples[i] * samples[i];
}
const rms = Math.sqrt(sumSquared / samples.length);
// Normalize to 0-100 scale
const normalizedEnergy = Math.min(100, rms * 500);
// Classify as low/medium/high
if (normalizedEnergy < 30) return 'Low';
if (normalizedEnergy < 60) return 'Medium';
return 'High';
}
/**
* Normalize BPM to reasonable range (70-170)
* Most music falls in this range - anything outside is likely a detection error
*
* Common issues:
* - 188 BPM is likely 94 BPM (doubled)
* - 47 BPM is likely 94 BPM (halved)
* - 60 BPM might be 120 BPM (half-beat detection)
*
* @param {number} bpm - Raw detected BPM
* @returns {number} - Normalized BPM
*/
normalizeBPM(bpm) {
// SAFETY: Validate input to prevent infinite loops
if (!isFinite(bpm) || bpm <= 0 || isNaN(bpm)) {
console.warn('🎵 Invalid BPM value:', bpm, '- using fallback 120');
return 120;
}
const originalBPM = bpm;
// CLEAN: Only normalize clearly wrong values
// Valid BPM range: 60-200 (don't break valid high BPMs!)
// Values > 200: Almost always wrong, halve them
if (bpm > 200) {
const original = bpm;
while (bpm > 200) {
bpm = bpm / 2;
}
console.log(`🎵 Normalized very high BPM ${original} → ${bpm}`);
return bpm;
}
// 188 is a common error (94*2) - fix it specifically
if (Math.abs(bpm - 188) < 2) {
console.log(`🎵 Fixed common error: ${bpm} → 94`);
return 94;
}
// Values < 50: Very low, likely wrong, double them
if (bpm < 50 && bpm > 0) {
const doubled = bpm * 2;
if (doubled <= 200) {
console.log(`🎵 Normalized very low BPM ${originalBPM} → ${doubled}`);
return doubled;
}
}
// Otherwise return the original value (60-200 is valid)
return bpm;
}
}
// Create global instance
window.audioAnalyzer = new AudioAnalyzer();
/**
* Analyze a track and update the UI
*/
async function analyzeTrackAudio(trackId, audioUrl) {
// Validate analyzer is available
if (!window.audioAnalyzer) {
throw new Error('Audio analyzer not initialized. Please refresh the page.');
}
// Validate audio URL
if (!audioUrl || audioUrl.trim() === '') {
throw new Error('Audio URL is missing or invalid');
}
const analyzer = window.audioAnalyzer;
// Get UI elements
const bpmElement = document.querySelector('[data-analyze="bpm"]');
const keyElement = document.querySelector('[data-analyze="key"]');
const energyElement = document.querySelector('[data-analyze="energy"]');
// Show analyzing state
const elementsToUpdate = [bpmElement, keyElement, energyElement].filter(el => el);
elementsToUpdate.forEach(el => {
el.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
el.classList.add('analyzing');
});
try {
console.log('🎵 Starting analysis...', { trackId, audioUrl: audioUrl ? 'present' : 'missing' });
const result = await analyzer.analyzeAudio(audioUrl, (stage, progress) => {
console.log(`🎵 Analysis progress: ${stage} (${progress}%)`);
});
console.log('🎵 Analysis completed, result:', result);
// Validate result - should never be null if we got here
if (!result) {
console.error('🎵 FATAL: analyzeAudio returned null/undefined');
throw new Error('Analysis completed but returned no result. This should not happen.');
}
// Validate result structure
if (!result.bpm || !result.key || !result.camelot) {
console.error('🎵 Result missing required fields:', result);
throw new Error('Analysis result is incomplete. Please try again.');
}
// CLEAN: Only fix clearly wrong values (188 -> 94), preserve valid BPMs
let displayBpm = result.bpm;
if (Math.abs(displayBpm - 188) < 2) {
displayBpm = 94;
result.bpm = 94;
console.log(`🎵 UI FIX: Fixed 188 → 94`);
}
// Update UI with results
if (bpmElement) {
bpmElement.textContent = displayBpm;
bpmElement.classList.remove('analyzing');
}
if (keyElement) {
// Show both standard key and Camelot
keyElement.innerHTML = `${result.key} <span class="camelot-key">${result.camelot}</span>`;
keyElement.classList.remove('analyzing');
}
if (energyElement) {
energyElement.textContent = result.energy || 'Medium';
energyElement.classList.remove('analyzing');
}
// Save to server
saveAnalyzedMetadata(trackId, result);
return result;
} catch (error) {
console.error('🎵 Track analysis failed:', error);
console.error('🎵 Error details:', {
message: error.message,
stack: error.stack,
audioUrl: audioUrl ? 'present' : 'missing',
trackId: trackId
});
// Show error state
elementsToUpdate.forEach(el => {
el.innerHTML = '<i class="fas fa-question" title="Could not analyze"></i>';
el.classList.remove('analyzing');
el.classList.add('analysis-failed');
});
// Re-throw error so caller can handle it
throw error;
}
}
/**
* Save analyzed metadata to server
*/
async function saveAnalyzedMetadata(trackId, analysisResult) {
try {
const response = await fetch('/api/save_audio_analysis.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
track_id: trackId,
bpm: analysisResult.bpm,
key: analysisResult.key,
camelot: analysisResult.camelot,
energy: analysisResult.energy,
confidence: analysisResult.confidence
})
});
const data = await response.json();
if (data.success) {
console.log('🎵 Analysis saved successfully');
} else {
console.warn('🎵 Failed to save analysis:', data.error);
}
} catch (error) {
console.error('🎵 Error saving analysis:', error);
}
}