![]() 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/private_html/js/ |
/**
* Audio Analyzer V2 - Improved BPM and Key Detection
* Based on proven open-source algorithms
*
* BPM Detection: Improved autocorrelation with better subharmonic handling
* Key Detection: Enhanced chroma analysis with multiple profile matching
*
* @author SoundStudioPro
*/
class AudioAnalyzerV2 {
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'
};
// Key profiles (Temperley - better for modern 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];
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;
}
async analyzeAudio(audioUrl, progressCallback = null) {
if (this.isAnalyzing) {
throw new Error('Analysis already in progress');
}
this.isAnalyzing = true;
// Add timeout to prevent infinite loops (60 seconds max)
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => {
this.isAnalyzing = false;
reject(new Error('Analysis timeout: took longer than 60 seconds'));
}, 60000);
});
try {
// Race between analysis and timeout
const analysisPromise = this._performAnalysis(audioUrl, progressCallback);
await Promise.race([analysisPromise, timeoutPromise]);
return await analysisPromise; // Return the actual result
} catch (error) {
this.isAnalyzing = false;
throw error;
}
}
async _performAnalysis(audioUrl, progressCallback = null) {
try {
if (progressCallback) progressCallback('loading', 0);
const ctx = this.initAudioContext();
const response = await fetch(audioUrl);
if (!response.ok) throw new Error(`Failed to fetch audio: ${response.status}`);
if (progressCallback) progressCallback('decoding', 30);
const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await ctx.decodeAudioData(arrayBuffer);
if (progressCallback) progressCallback('analyzing', 50);
const channelData = audioBuffer.getChannelData(0);
const sampleRate = audioBuffer.sampleRate;
const duration = audioBuffer.duration;
// Max 10 minutes for performance
if (duration > 600) {
throw new Error('Audio file too long (max 10 minutes)');
}
// Detect BPM
if (progressCallback) progressCallback('detecting_bpm', 60);
const bpm = await this.detectBPM(channelData, sampleRate);
console.log('🎵 Detected BPM:', bpm);
// Detect Key
if (progressCallback) progressCallback('detecting_key', 80);
const keyInfo = await this.detectKey(channelData, sampleRate);
console.log('🎵 Detected Key:', keyInfo);
// Calculate Energy
if (progressCallback) progressCallback('calculating_energy', 90);
const energy = this.calculateEnergy(channelData);
if (progressCallback) progressCallback('complete', 100);
const result = {
bpm: Math.round(bpm * 10) / 10,
key: keyInfo.key,
camelot: keyInfo.camelot,
confidence: keyInfo.confidence || 0,
energy: energy,
duration: Math.round(duration),
analyzed: true,
analyzedAt: new Date().toISOString()
};
console.log('🎵 Final result:', result);
this.isAnalyzing = false;
return result;
} catch (error) {
console.error('🎵 Analysis error:', error);
this.isAnalyzing = false;
throw error;
}
}
/**
* Improved BPM Detection
* Based on proven autocorrelation with better subharmonic handling
*/
async detectBPM(samples, sampleRate) {
// Use 30 seconds for analysis (good balance of accuracy and speed)
const maxLength = sampleRate * 30;
const sampleLength = Math.min(samples.length, maxLength);
const analysisSamples = samples.slice(0, sampleLength);
// Downsample to ~8kHz for faster processing
const targetRate = 8000;
const downsampleFactor = Math.max(1, Math.floor(sampleRate / targetRate));
const downsampled = [];
for (let i = 0; i < analysisSamples.length; i += downsampleFactor) {
downsampled.push(Math.abs(analysisSamples[i]));
}
const downsampledRate = sampleRate / downsampleFactor;
// Filter to bass frequencies (40-200 Hz) - most reliable for BPM
const filtered = this.bandPassFilter(downsampled, downsampledRate, 40, 200);
// Autocorrelation parameters
const minBPM = 60;
const maxBPM = 180; // Increased from 170 to catch valid high BPMs
const minLag = Math.max(1, Math.floor(downsampledRate * 60 / maxBPM));
const maxLag = Math.min(filtered.length - 1, Math.floor(downsampledRate * 60 / minBPM));
// Safety check: ensure valid lag range
if (minLag >= maxLag || minLag <= 0 || maxLag <= 0) {
console.warn('🎵 Invalid lag range, using fallback BPM 120');
return 120;
}
// Calculate autocorrelation
const windowSize = Math.min(filtered.length, downsampledRate * 15);
// Safety check: ensure window size is valid
if (windowSize <= 0 || windowSize > filtered.length) {
console.warn('🎵 Invalid window size, using fallback BPM 120');
return 120;
}
let maxCorr = 0;
let bestLag = minLag;
const correlations = [];
// Safety: limit max iterations to prevent infinite loops
const maxIterations = Math.min(maxLag - minLag + 1, 10000); // Max 10k iterations
let iterationCount = 0;
for (let lag = minLag; lag <= maxLag && iterationCount < maxIterations; lag++) {
iterationCount++;
let corr = 0;
let count = 0;
const maxI = Math.min(windowSize - lag, filtered.length - lag);
for (let i = 0; i < maxI; i++) {
corr += filtered[i] * filtered[i + lag];
count++;
}
if (count > 0) {
corr /= count;
correlations.push({ lag, corr });
if (corr > maxCorr) {
maxCorr = corr;
bestLag = lag;
}
}
}
if (bestLag === 0) return 120; // Fallback
let bpm = (downsampledRate * 60) / bestLag;
// Check for subharmonics (if we got 164, actual might be 82)
// Check harmonics (if we got 41, actual might be 82)
const candidates = [{ bpm, corr: maxCorr, lag: bestLag }];
// Check subharmonic (half-beat)
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 += filtered[i] * filtered[i + halfLag];
count++;
}
if (count > 0) {
halfCorr /= count;
if (halfCorr > maxCorr * 0.65) {
const halfBpm = (downsampledRate * 60) / halfLag;
candidates.push({ bpm: halfBpm, corr: halfCorr, lag: halfLag });
}
}
}
}
// Check harmonic (double-beat)
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 += filtered[i] * filtered[i + doubleLag];
count++;
}
if (count > 0) {
doubleCorr /= count;
if (doubleCorr > maxCorr * 0.65) {
const doubleBpm = (downsampledRate * 60) / doubleLag;
candidates.push({ bpm: doubleBpm, corr: doubleCorr, lag: doubleLag });
}
}
}
}
// Pick best candidate: prefer values in 60-140 range, then by correlation
candidates.sort((a, b) => {
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;
return b.corr - a.corr;
});
let finalBpm = candidates[0].bpm;
// Safety check: validate BPM is a valid number
if (!isFinite(finalBpm) || isNaN(finalBpm) || finalBpm <= 0) {
console.warn('🎵 Invalid BPM detected:', finalBpm, '- using fallback 120');
return 120;
}
// Only normalize clearly wrong values
// 188 is a known bug (94*2) - fix it
if (Math.abs(finalBpm - 188) < 2) {
finalBpm = 94;
}
// Values > 200 are almost always wrong - FIXED: add max iterations to prevent infinite loop
else if (finalBpm > 200) {
let iterations = 0;
const maxIterations = 10; // Safety limit
while (finalBpm > 200 && iterations < maxIterations) {
finalBpm = finalBpm / 2;
iterations++;
}
// If still > 200 after max iterations, force to 120
if (finalBpm > 200) {
console.warn('🎵 BPM still > 200 after normalization, forcing to 120');
finalBpm = 120;
}
}
// Values < 50 are very low, likely wrong
else if (finalBpm < 50 && finalBpm > 0) {
const doubled = finalBpm * 2;
if (doubled <= 180 && isFinite(doubled)) {
finalBpm = doubled;
}
}
console.log('🎵 BPM candidates:', candidates.map(c => `${c.bpm.toFixed(1)} (corr: ${c.corr.toFixed(3)})`));
console.log('🎵 Selected BPM:', finalBpm);
return finalBpm;
}
/**
* Improved Key Detection
* Enhanced chroma analysis with better frequency mapping
*/
async detectKey(channelData, sampleRate) {
// Use 20 seconds for better accuracy
const maxLength = sampleRate * 20;
const sampleLength = Math.min(channelData.length, maxLength);
const samples = channelData.slice(0, sampleLength);
// Calculate chroma features
const chroma = this.calculateChroma(samples, sampleRate);
// Normalize chroma
const sum = chroma.reduce((a, b) => a + b, 0);
if (sum > 0) {
for (let i = 0; i < 12; i++) {
chroma[i] /= sum;
}
}
// Detect key from chroma
const keyInfo = this.detectKeyFromChroma(chroma);
if (!keyInfo || !keyInfo.key || !keyInfo.camelot) {
return { key: 'C major', camelot: '8B', confidence: 0 };
}
return keyInfo;
}
/**
* Calculate chroma features (12 pitch classes)
*/
calculateChroma(samples, sampleRate) {
const chroma = new Float32Array(12).fill(0);
const fftSize = 8192;
const hopSize = fftSize / 2;
const numFrames = Math.floor((samples.length - fftSize) / hopSize);
if (numFrames <= 0 || numFrames > 10000) {
console.warn('🎵 Invalid numFrames for chroma calculation:', numFrames);
return chroma;
}
// Safety: limit frames to prevent excessive processing
const maxFrames = Math.min(numFrames, 1000); // Max 1000 frames
for (let frame = 0; frame < maxFrames; 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)));
}
// FFT
const fft = this.fft(frameData);
const magnitude = fft.magnitude;
// Map to chroma bins
for (let pitchClass = 0; pitchClass < 12; pitchClass++) {
let energy = 0;
for (let octave = 3; octave <= 5; 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];
}
}
chroma[pitchClass] += energy;
}
}
return chroma;
}
/**
* FFT implementation
*/
fft(samples) {
const N = samples.length;
// Safety check: prevent processing of extremely large arrays
if (N <= 0 || N > 65536) {
console.warn('🎵 Invalid FFT size:', N, '- using fallback');
return { magnitude: new Float32Array(4096) };
}
const magnitude = new Float32Array(Math.floor(N / 2));
const maxK = Math.min(Math.floor(N / 2), 4096); // Limit to 4096 bins
for (let k = 0; k < maxK; k++) {
let real = 0;
let imag = 0;
const angleIncrement = -2 * Math.PI * k / N;
// Safety: limit inner loop iterations
const maxN = Math.min(N, 8192);
for (let n = 0; n < maxN; 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);
}
return { magnitude };
}
/**
* Detect key from chroma vector
*/
detectKeyFromChroma(chroma) {
let bestKey = 'C major';
let bestCorrelation = -1;
const correlations = [];
for (let keyIndex = 0; keyIndex < 12; keyIndex++) {
const rotatedChroma = this.rotateArray(chroma, keyIndex);
// Major
const majorCorr = this.correlate(rotatedChroma, this.majorProfile);
const majorKey = this.noteNames[keyIndex] + ' major';
correlations.push({ key: majorKey, correlation: majorCorr, isMinor: false });
if (majorCorr > bestCorrelation) {
bestCorrelation = majorCorr;
bestKey = majorKey;
}
// Minor
const minorCorr = this.correlate(rotatedChroma, this.minorProfile);
const minorKey = this.noteNames[keyIndex] + ' minor';
correlations.push({ key: minorKey, correlation: minorCorr, isMinor: true });
if (minorCorr > bestCorrelation) {
bestCorrelation = minorCorr;
bestKey = minorKey;
}
}
// Log top 3 for debugging
correlations.sort((a, b) => b.correlation - a.correlation);
console.log('🎵 Top 3 keys:', correlations.slice(0, 3).map(c => {
const camelot = this.getCamelotKey(c.key);
return `${c.key} (${camelot}): ${c.correlation.toFixed(3)}`;
}));
const camelot = this.getCamelotKey(bestKey);
const confidence = Math.round(((bestCorrelation + 1) / 2) * 100);
return {
key: bestKey,
camelot: camelot,
confidence: Math.min(100, Math.max(0, confidence)),
correlation: bestCorrelation
};
}
/**
* Utility functions
*/
rotateArray(arr, n) {
const len = arr.length;
n = ((n % len) + len) % len;
return [...arr.slice(n), ...arr.slice(0, n)];
}
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 numerator = sumAB - (sumA * sumB / n);
const denominator = Math.sqrt((sumA2 - sumA * sumA / n) * (sumB2 - sumB * sumB / n));
return denominator === 0 ? 0 : numerator / denominator;
}
getCamelotKey(key) {
const normalizedKey = key.replace('♯', '#').replace('♭', 'b');
return this.camelotWheel[normalizedKey] || '8B';
}
bandPassFilter(samples, sampleRate, lowFreq, highFreq) {
const filtered = new Float32Array(samples.length);
const dt = 1.0 / sampleRate;
const lowRC = 1.0 / (2 * Math.PI * lowFreq);
const highRC = 1.0 / (2 * Math.PI * highFreq);
// Simple IIR filter
let low = samples[0];
let high = samples[0];
for (let i = 1; i < samples.length; i++) {
const alphaLow = dt / (lowRC + dt);
const alphaHigh = highRC / (highRC + dt);
low = low + alphaLow * (samples[i] - low);
high = alphaHigh * (high + samples[i] - samples[i-1]);
filtered[i] = high - low;
}
return filtered;
}
calculateEnergy(channelData) {
let sum = 0;
const length = Math.min(channelData.length, channelData.length);
for (let i = 0; i < length; i++) {
sum += Math.abs(channelData[i]);
}
const avg = sum / length;
if (avg > 0.3) return 'High';
if (avg > 0.15) return 'Medium';
return 'Low';
}
}
// Create global instance
window.audioAnalyzerV2 = new AudioAnalyzerV2();
/**
* Analyze a track and update the UI
*/
async function analyzeTrackAudio(trackId, audioUrl) {
// Validate analyzer is available
if (!window.audioAnalyzerV2) {
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.audioAnalyzerV2;
// 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 (V2)...', { 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
if (!result || !result.bpm || !result.key || !result.camelot) {
throw new Error('Analysis result is incomplete. Please try again.');
}
// Only fix clearly wrong values (188 -> 94)
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) {
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 database
try {
const response = await fetch('/api/save_audio_analysis.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
track_id: trackId,
bpm: result.bpm,
key: result.key,
camelot: result.camelot,
energy: result.energy || 'Medium',
confidence: result.confidence || 0
})
});
const data = await response.json();
if (data.success) {
console.log('🎵 Analysis saved to database');
} else {
console.warn('🎵 Failed to save analysis:', data.error);
}
} catch (saveError) {
console.error('🎵 Error saving analysis:', saveError);
// Don't throw - analysis succeeded, saving is secondary
}
return result;
} catch (error) {
console.error('🎵 Analysis error:', error);
// Reset UI
elementsToUpdate.forEach(el => {
el.innerHTML = '<span style="color: #888;">—</span>';
el.classList.remove('analyzing');
});
throw error;
}
}