← Mike Higgins Home
Custom Code Experiment •

Web Audio API: Junkies for the 808

Source Code View HTML / CSS / JavaScript
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
    <title>Audio Visualiser | Junkies for the 808 V1</title>
    <style>
        * {
            box-sizing: border-box;
            margin: 0;
            padding: 0;
            user-select: none;
            -webkit-tap-highlight-color: transparent;
            -webkit-touch-callout: none;
        }

        body, html {
            overflow: hidden;
            width: 100%;
            height: 100%;
            background: #000;
            color: #fff;
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            touch-action: manipulation;
        }

        #app {
            position: relative;
            width: 100%;
            height: 100%;
        }

        canvas {
            display: block;
            position: absolute;
            top: 0;
            left: 0;
        }

        #controls {
            position: fixed;
            bottom: 20px;
            left: 20px;
            right: 20px;
            z-index: 1000;
            display: flex;
            flex-wrap: wrap;
            gap: 15px;
            align-items: center;
            background: rgba(0, 0, 0, 0.9);
            padding: 20px;
            border-radius: 15px;
            backdrop-filter: blur(20px);
            border: 1px solid rgba(255, 255, 255, 0.15);
            box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
            transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s;
        }

        /* Mobile-specific controls styling - COMPACT VERSION */
        @media (max-width: 768px) {
            #controls {
                position: fixed;
                bottom: 0;
                left: 0;
                right: 0;
                transform: translateY(100%);
                border-radius: 15px 15px 0 0;
                padding: 15px 15px 80px 15px; /* Reduced padding */
                max-height: 85vh; /* Increased slightly but still compact */
                overflow-y: auto;
                -webkit-overflow-scrolling: touch;
                display: none; /* Hidden by default on mobile */
                flex-direction: column;
                align-items: stretch;
                gap: 12px; /* Reduced gap */
                background: rgba(0, 0, 0, 0.95);
                backdrop-filter: blur(20px);
                border: 1px solid rgba(255, 255, 255, 0.2);
                box-shadow: 0 -10px 40px rgba(0, 0, 0, 0.7);
            }

            #controls.show {
                display: flex;
                transform: translateY(0);
            }

            .control-group {
                width: 100%;
                margin-bottom: 5px; /* Reduced margin */
            }

            label {
                font-size: 12px; /* Smaller font */
                margin-bottom: 5px; /* Reduced margin */
                display: block;
                color: #8af;
                text-transform: uppercase;
                letter-spacing: 0.5px; /* Reduced letter spacing */
            }

            select, input[type="range"], button {
                width: 100%;
                min-width: auto;
                padding: 8px 10px; /* Reduced padding */
                font-size: 14px; /* Slightly smaller font */
                border-radius: 8px;
            }

            select {
                background: rgba(0, 20, 40, 0.8);
                border: 1px solid rgba(100, 150, 255, 0.3);
                color: white;
                height: 36px; /* Fixed height */
            }

            input[type="range"] {
                height: 36px; /* Reduced height */
                -webkit-appearance: none;
                appearance: none;
                background: transparent;
                margin: 8px 0;
            }

            input[type="range"]::-webkit-slider-thumb {
                -webkit-appearance: none;
                width: 20px; /* Smaller thumb */
                height: 20px;
                border-radius: 50%;
                background: #0066ff;
                cursor: pointer;
                margin-top: -8px;
            }

            input[type="range"]::-moz-range-thumb {
                width: 20px;
                height: 20px;
                border-radius: 50%;
                background: #0066ff;
                cursor: pointer;
                border: none;
            }

            input[type="range"]::-webkit-slider-runnable-track {
                height: 4px;
                background: rgba(255, 255, 255, 0.2);
                border-radius: 2px;
            }

            /* Compact button grid */
            .button-group {
                display: grid;
                grid-template-columns: repeat(2, 1fr); /* 2 columns */
                grid-template-rows: repeat(2, auto); /* 2 rows */
                gap: 8px; /* Smaller gap */
                width: 100%;
                margin-top: 5px;
            }

            .button-group button {
                padding: 10px 5px; /* Reduced padding */
                font-size: 13px; /* Smaller font */
                border-radius: 8px;
                min-height: 40px; /* Reduced height */
                min-width: 0; /* Allow buttons to shrink */
                margin: 0;
            }

            /* Specific button layout */
            #playButton {
                grid-column: 1;
                grid-row: 1;
            }
            #pauseButton {
                grid-column: 2;
                grid-row: 1;
            }
            #stopButton {
                grid-column: 1;
                grid-row: 2;
            }
            #uploadButton {
                grid-column: 2;
                grid-row: 2;
            }

            .colour-picker {
                display: flex;
                justify-content: space-between; /* Evenly space */
                gap: 5px; /* Smaller gap */
                margin-top: 5px;
            }

            .colour-swatch {
                width: 32px; /* Smaller swatches */
                height: 32px;
                flex: 1; /* Equal width */
                border-radius: 6px;
                border: 2px solid rgba(255, 255, 255, 0.3);
            }

            /* Group sliders more closely */
            .slider-group {
                margin-bottom: 8px;
            }

            .slider-group label {
                display: flex;
                justify-content: space-between;
                align-items: center;
                margin-bottom: 4px;
            }

            .slider-value {
                font-size: 11px;
                color: #8af;
                background: rgba(0, 20, 40, 0.5);
                padding: 2px 6px;
                border-radius: 10px;
                min-width: 30px;
                text-align: center;
            }
        }

        .control-group {
            display: flex;
            flex-direction: column;
            gap: 8px;
        }

        label {
            font-size: 12px;
            color: #8af;
            text-transform: uppercase;
            letter-spacing: 1px;
        }

        select, input[type="range"] {
            background: rgba(0, 20, 40, 0.8);
            border: 1px solid rgba(100, 150, 255, 0.3);
            color: white;
            padding: 8px 12px;
            border-radius: 5px;
            min-width: 150px;
            font-size: 14px;
        }

        input[type="range"] {
            width: 200px;
        }

        button {
            background: linear-gradient(45deg, #0066ff, #00ccff);
            border: none;
            color: white;
            padding: 10px 20px;
            border-radius: 5px;
            cursor: pointer;
            font-weight: bold;
            text-transform: uppercase;
            letter-spacing: 1px;
            transition: all 0.3s;
            min-width: 120px;
        }

        button:hover {
            transform: translateY(-2px);
            box-shadow: 0 5px 15px rgba(0, 102, 255, 0.4);
        }

        button:active {
            transform: translateY(0);
        }

        .button-group {
            display: flex;
            gap: 10px;
        }

        /* Mini Player for Mobile */
        #miniPlayer {
            display: none;
            position: fixed;
            bottom: 20px;
            left: 50%;
            transform: translateX(-50%);
            z-index: 1001;
            background: rgba(0, 0, 0, 0.8);
            backdrop-filter: blur(15px);
            border-radius: 50px;
            padding: 10px 20px;
            border: 1px solid rgba(255, 255, 255, 0.15);
            box-shadow: 0 5px 25px rgba(0, 102, 255, 0.3);
            min-width: 180px;
            justify-content: space-between;
            align-items: center;
            gap: 12px;
            animation: float 3s ease-in-out infinite;
        }

        @media (max-width: 768px) {
            #miniPlayer {
                display: flex;
            }

            @keyframes float {
                0%, 100% {
                    transform: translateX(-50%) translateY(0);
                }
                50% {
                    transform: translateX(-50%) translateY(-5px);
                }
            }
        }

        .mini-player-btn {
            background: transparent;
            border: none;
            color: white;
            width: 36px;
            height: 36px;
            border-radius: 50%;
            cursor: pointer;
            display: flex;
            align-items: center;
            justify-content: center;
            font-size: 16px;
            transition: all 0.3s;
        }

        .mini-player-btn:hover {
            background: rgba(255, 255, 255, 0.1);
        }

        .mini-player-btn.play {
            background: linear-gradient(45deg, #0066ff, #00ccff);
            width: 44px;
            height: 44px;
            font-size: 18px;
        }

        .mini-player-btn.play:hover {
            transform: scale(1.1);
        }

        #miniTimeDisplay {
            font-size: 12px;
            color: #8af;
            min-width: 50px;
            text-align: center;
            font-family: monospace;
            font-weight: bold;
        }

        #fileInput {
            display: none;
        }

        #songInfo {
            position: absolute;
            top: 20px;
            left: 20px;
            z-index: 1000;
            background: rgba(0, 0, 0, 0.7);
            padding: 12px 15px; /* Slightly reduced padding */
            border-radius: 10px;
            backdrop-filter: blur(10px);
            border: 1px solid rgba(255, 255, 255, 0.1);
            max-width: 300px;
        }

        @media (max-width: 768px) {
            #songInfo {
                top: 10px;
                left: 10px;
                right: 10px;
                max-width: none;
                padding: 10px 12px;
                background: rgba(0, 0, 0, 0.85);
            }
        }

        #songTitle {
            font-size: 16px;
            font-weight: bold;
            margin-bottom: 3px; /* Reduced margin */
            color: #8af;
        }

        #songStats {
            font-size: 12px;
            color: #aaa;
            margin-bottom: 3px; /* Reduced margin */
        }

        #progressDisplay {
            margin-top: 3px;
            font-size: 11px;
            color: #8af;
        }

        #loading {
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            text-align: center;
            z-index: 2000;
            background: rgba(0, 0, 0, 0.9);
            padding: 25px; /* Reduced padding */
            border-radius: 15px;
            border: 1px solid rgba(0, 100, 255, 0.5);
        }

        .spinner {
            width: 40px; /* Smaller spinner */
            height: 40px;
            border: 3px solid rgba(0, 100, 255, 0.3);
            border-top: 3px solid #0066ff;
            border-radius: 50%;
            animation: spin 1s linear infinite;
            margin: 0 auto 15px;
        }

        @keyframes spin {
            0% { transform: rotate(0deg); }
            100% { transform: rotate(360deg); }
        }

        #progressBar {
            width: 180px; /* Reduced width */
            height: 3px;
            background: rgba(255, 255, 255, 0.1);
            margin: 15px auto; /* Reduced margin */
            border-radius: 2px;
            overflow: hidden;
        }

        #progress {
            height: 100%;
            background: linear-gradient(90deg, #0066ff, #00ccff);
            width: 0%;
            transition: width 0.3s;
        }

        #fpsCounter {
            position: absolute;
            top: 20px;
            right: 20px;
            z-index: 1000;
            color: #0f0;
            font-family: monospace;
            font-size: 14px;
        }

        @media (max-width: 768px) {
            #fpsCounter {
                top: 10px;
                right: 10px;
                font-size: 12px;
            }
        }

        .colour-picker {
            display: flex;
            gap: 5px;
            align-items: centre;
        }

        .colour-swatch {
            width: 30px;
            height: 30px;
            border-radius: 5px;
            cursor: pointer;
            border: 2px solid rgba(255, 255, 255, 0.3);
            transition: transform 0.2s;
        }

        .colour-swatch:hover {
            transform: scale(1.1);
        }

        .colour-swatch.active {
            border-color: #fff;
            box-shadow: 0 0 10px rgba(255, 255, 255, 0.5);
        }

        .hidden {
            display: none !important;
        }

        #playbackControls {
            display: flex;
            align-items: centre;
            gap: 10px;
            margin-top: 10px;
        }

        @media (max-width: 768px) {
            #playbackControls {
                margin-top: 8px;
                gap: 8px;
            }
        }

        #timeSlider {
            flex-grow: 1;
            height: 4px;
            background: rgba(255, 255, 255, 0.1);
            border-radius: 2px;
            position: relative;
            cursor: pointer;
        }

        @media (max-width: 768px) {
            #timeSlider {
                height: 5px;
            }
        }

        #timeProgress {
            position: absolute;
            height: 100%;
            background: linear-gradient(90deg, #0066ff, #00ccff);
            border-radius: 2px;
            width: 0%;
        }

        #timeDisplay {
            font-size: 11px;
            color: #8af;
            min-width: 80px;
            text-align: centre;
        }

        @media (max-width: 768px) {
            #timeDisplay {
                font-size: 11px;
                min-width: 70px;
            }
        }

        .artist-credit {
            position: absolute;
            bottom: 20px;
            right: 20px;
            z-index: 1000;
            background: rgba(0, 0, 0, 0.7);
            padding: 8px 12px; /* Reduced padding */
            border-radius: 10px;
            backdrop-filter: blur(10px);
            border: 1px solid rgba(255, 255, 255, 0.1);
            font-size: 11px; /* Smaller font */
            color: #8af;
            max-width: 280px; /* Slightly smaller */
        }

        @media (max-width: 768px) {
            .artist-credit {
                bottom: 80px; /* Adjusted to avoid overlap with mini player */
                right: 10px;
                left: 10px;
                text-align: center;
                max-width: none;
                background: rgba(0, 0, 0, 0.8);
                padding: 8px 10px;
                font-size: 11px;
            }
        }

        .artist-credit a {
            color: #00ccff;
            text-decoration: none;
            transition: colour 0.3s;
        }

        .artist-credit a:hover {
            colour: #fff;
            text-decoration: underline;
        }

        #errorMessage {
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            text-align: centre;
            z-index: 2000;
            background: rgba(0, 0, 0, 0.9);
            padding: 25px; /* Reduced padding */
            border-radius: 15px;
            border: 1px solid rgba(255, 50, 50, 0.5);
            max-width: 350px; /* Slightly smaller */
        }

        #errorMessage h3 {
            colour: #ff5555;
            margin-bottom: 12px; /* Reduced margin */
            font-size: 16px; /* Smaller font */
        }

        #errorMessage p {
            margin-bottom: 12px;
            colour: #ccc;
            font-size: 14px;
        }

        #errorMessage button {
            background: linear-gradient(45deg, #ff5555, #ff8888);
            margin-top: 8px;
            padding: 8px 16px; /* Reduced padding */
            font-size: 14px;
        }

        /* Mobile overlay to close menu when tapping outside */
        #mobileOverlay {
            display: none;
            position: fixed;
            top: 0;
            left: 0;
            right: 0;
            bottom: 0;
            background: rgba(0, 0, 0, 0.3);
            z-index: 999;
            backdrop-filter: blur(2px);
        }

        @media (max-width: 768px) {
            #mobileOverlay.show {
                display: block;
            }
        }
    </style>
</head>
<body>
    <div id="app">
        <canvas id="canvas"></canvas>

        <div id="songInfo" class="hidden">
            <div id="songTitle">Junkies for the 808 V1</div>
            <div id="songStats">Loading your track...</div>
            <div id="progressDisplay">00:00 / 00:00</div>
            <div id="playbackControls" class="hidden">
                <div id="timeSlider">
                    <div id="timeProgress"></div>
                </div>
                <div id="timeDisplay">00:00</div>
            </div>
        </div>

        <div class="artist-credit">
            Music: <a href="https://scrapbook.mikehiggins.me.uk/" target="_blank" rel="noopener">Mike Higgins</a> - Junkies for the 808 V1
        </div>

        <div id="fpsCounter">FPS: 0</div>

        <div id="loading" class="hidden">
            <div class="spinner"></div>
            <div>Loading Your Track...</div>
            <div id="progressBar">
                <div id="progress"></div>
            </div>
        </div>

        <div id="errorMessage" class="hidden">
            <h3>Audio Loading Error</h3>
            <p id="errorText">There was an issue loading the audio file. Please upload your own MP3 file to visualise.</p>
            <button id="closeError">Close</button>
        </div>

        <!-- Hidden audio element -->
        <audio id="audioElement" style="display: none;" crossorigin="anonymous"></audio>

        <!-- Mini Player for Mobile -->
        <div id="miniPlayer">
            <button class="mini-player-btn" id="miniPlayBtn">▶</button>
            <div id="miniTimeDisplay">00:00</div>
            <button class="mini-player-btn" id="miniMenuBtn">⚙</button>
        </div>

        <!-- Mobile Overlay for closing menu -->
        <div id="mobileOverlay"></div>

        <div id="controls">
            <div class="control-group">
                <label>Visualisation Mode</label>
                <select id="modeSelect">
                    <option value="waveform">Waveform</option>
                    <option value="circle">Circular Spectrum</option>
                    <option value="tunnel">3D Tunnel</option>
                    <option value="particleSpectrum">Particle Spectrum</option>
                    <option value="liquid">Liquid Visualisation</option>
                </select>
            </div>

            <div class="control-group">
                <label>Colour Scheme</label>
                <div class="colour-picker">
                    <div class="colour-swatch active" style="background: linear-gradient(45deg, #0066ff, #00ccff);" data-scheme="blue"></div>
                    <div class="colour-swatch" style="background: linear-gradient(45deg, #ff0066, #ff00cc);" data-scheme="pink"></div>
                    <div class="colour-swatch" style="background: linear-gradient(45deg, #00ff66, #00ffcc);" data-scheme="green"></div>
                    <div class="colour-swatch" style="background: linear-gradient(45deg, #ff6600, #ffcc00);" data-scheme="orange"></div>
                    <div class="colour-swatch" style="background: linear-gradient(45deg, #6600ff, #cc00ff);" data-scheme="purple"></div>
                </div>
            </div>

            <div class="control-group">
                <label for="bassBoost">Bass Boost: <span id="bassBoostValue" class="slider-value">1.0</span></label>
                <input type="range" id="bassBoost" min="0.5" max="3" step="0.1" value="1">
            </div>

            <div class="control-group">
                <label for="trebleBoost">Treble Boost: <span id="trebleBoostValue" class="slider-value">1.0</span></label>
                <input type="range" id="trebleBoost" min="0.5" max="3" step="0.1" value="1">
            </div>

            <div class="control-group">
                <label for="intensity">Intensity: <span id="intensityValue" class="slider-value">1.0</span></label>
                <input type="range" id="intensity" min="0.5" max="2" step="0.1" value="1">
            </div>

            <div class="button-group">
                <button id="playButton" disabled>Play</button>
                <button id="pauseButton" disabled>Pause</button>
                <button id="stopButton" disabled>Stop</button>
                <button id="uploadButton">Upload Audio</button>
                <input type="file" id="fileInput" accept="audio/*">
            </div>
        </div>
    </div>

    <script>
        class EnhancedAudioVisualiser {
            constructor() {
                this.init();
            }

            async init() {
                // DOM Elements
                this.canvas = document.getElementById('canvas');
                this.ctx = this.canvas.getContext('2d');
                this.loading = document.getElementById('loading');
                this.progress = document.getElementById('progress');
                this.controls = document.getElementById('controls');
                this.songInfo = document.getElementById('songInfo');
                this.songTitle = document.getElementById('songTitle');
                this.songStats = document.getElementById('songStats');
                this.progressDisplay = document.getElementById('progressDisplay');
                this.playbackControls = document.getElementById('playbackControls');
                this.timeSlider = document.getElementById('timeSlider');
                this.timeProgress = document.getElementById('timeProgress');
                this.timeDisplay = document.getElementById('timeDisplay');
                this.fpsCounter = document.getElementById('fpsCounter');
                this.errorMessage = document.getElementById('errorMessage');
                this.errorText = document.getElementById('errorText');
                this.audioElement = document.getElementById('audioElement');
                this.mobileOverlay = document.getElementById('mobileOverlay');

                // Mini player elements
                this.miniPlayer = document.getElementById('miniPlayer');
                this.miniPlayBtn = document.getElementById('miniPlayBtn');
                this.miniMenuBtn = document.getElementById('miniMenuBtn');
                this.miniTimeDisplay = document.getElementById('miniTimeDisplay');

                // Slider value displays
                this.bassBoostValue = document.getElementById('bassBoostValue');
                this.trebleBoostValue = document.getElementById('trebleBoostValue');
                this.intensityValue = document.getElementById('intensityValue');

                // Check if mobile
                this.isMobile = window.innerWidth <= 768;

                // Audio Context and nodes
                this.audioContext = new (window.AudioContext || window.webkitAudioContext)();

                // Create audio processing chain
                this.analyser = this.audioContext.createAnalyser();
                this.analyser.fftSize = 4096;
                this.analyser.smoothingTimeConstant = 0.5;

                // Create gain node for master volume
                this.gainNode = this.audioContext.createGain();
                this.gainNode.gain.value = 0.7;

                // Create EQ filters
                this.bassFilter = this.audioContext.createBiquadFilter();
                this.bassFilter.type = 'lowshelf';
                this.bassFilter.frequency.value = 200;

                this.midFilter = this.audioContext.createBiquadFilter();
                this.midFilter.type = 'peaking';
                this.midFilter.frequency.value = 1000;
                this.midFilter.Q.value = 1;

                this.trebleFilter = this.audioContext.createBiquadFilter();
                this.trebleFilter.type = 'highshelf';
                this.trebleFilter.frequency.value = 3000;

                // Connect the audio chain
                this.bassFilter.connect(this.midFilter);
                this.midFilter.connect(this.trebleFilter);
                this.trebleFilter.connect(this.gainNode);
                this.gainNode.connect(this.analyser);
                this.analyser.connect(this.audioContext.destination);

                // Create media element source
                this.mediaElementSource = null;

                // Audio data arrays
                this.bufferLength = this.analyser.frequencyBinCount;
                this.frequencyData = new Uint8Array(this.bufferLength);
                this.timeData = new Uint8Array(this.bufferLength);

                // Frequency bands
                this.bassBand = { start: 20, end: 250 };
                this.midBand = { start: 250, end: 2000 };
                this.trebleBand = { start: 2000, end: 20000 };

                // Visualisation settings
                this.mode = 'waveform';
                this.colourScheme = 'blue';
                this.bassBoost = 1;
                this.trebleBoost = 1;
                this.intensity = 1;

                // State
                this.isPlaying = false;
                this.currentTime = 0;
                this.startTime = 0;
                this.fps = 0;
                this.lastTime = 0;
                this.frameCount = 0;
                this.animationFrameId = null;

                // Visualisation-specific data
                this.particles = [];
                this.liquidPoints = [];

                // Colours
                this.colours = {
                    blue: { primary: '#0066ff', secondary: '#00ccff', accent: '#8af' },
                    pink: { primary: '#ff0066', secondary: '#ff00cc', accent: '#f8a' },
                    green: { primary: '#00ff66', secondary: '#00ffcc', accent: '#8fa' },
                    orange: { primary: '#ff6600', secondary: '#ffcc00', accent: '#fa8' },
                    purple: { primary: '#6600ff', secondary: '#cc00ff', accent: '#c8f' }
                };

                this.currentColours = this.colours.blue;

                this.setupEventListeners();
                this.resize();
                this.createLiquidPoints();
                this.createParticles();

                // Start with demo visualisation while loading
                this.startDemoVisualisation();

                // Try to load your MP3 from GitHub via jsDelivr
                setTimeout(() => this.loadDefaultAudio(), 100);
            }

            setupEventListeners() {
                window.addEventListener('resize', () => {
                    this.resize();
                    this.isMobile = window.innerWidth <= 768;
                    // Update controls visibility based on screen size
                    if (!this.isMobile && this.controls.classList.contains('show')) {
                        this.hideMobileMenu();
                    }
                });

                // Mini player controls
                this.miniPlayBtn.addEventListener('click', () => {
                    if (this.isPlaying) {
                        this.pause();
                    } else {
                        this.play();
                    }
                });

                this.miniMenuBtn.addEventListener('click', () => this.toggleMobileMenu());

                // Mobile overlay
                this.mobileOverlay.addEventListener('click', () => this.hideMobileMenu());

                // Controls
                document.getElementById('modeSelect').addEventListener('change', (e) => {
                    this.mode = e.target.value;
                    if (this.mode === 'particleSpectrum') {
                        this.createParticles();
                    }
                    // Auto-close menu on mobile after selection
                    if (this.isMobile) {
                        setTimeout(() => this.hideMobileMenu(), 300);
                    }
                });

                document.querySelectorAll('.colour-swatch').forEach(swatch => {
                    swatch.addEventListener('click', (e) => {
                        document.querySelectorAll('.colour-swatch').forEach(s => s.classList.remove('active'));
                        e.target.classList.add('active');
                        this.colourScheme = e.target.dataset.scheme;
                        this.currentColours = this.colours[this.colourScheme];
                        // Auto-close menu on mobile after selection
                        if (this.isMobile) {
                            setTimeout(() => this.hideMobileMenu(), 300);
                        }
                    });
                });

                document.getElementById('bassBoost').addEventListener('input', (e) => {
                    this.bassBoost = parseFloat(e.target.value);
                    this.bassFilter.gain.value = (this.bassBoost - 1) * 20;
                    this.bassBoostValue.textContent = this.bassBoost.toFixed(1);
                });

                document.getElementById('trebleBoost').addEventListener('input', (e) => {
                    this.trebleBoost = parseFloat(e.target.value);
                    this.trebleFilter.gain.value = (this.trebleBoost - 1) * 20;
                    this.trebleBoostValue.textContent = this.trebleBoost.toFixed(1);
                });

                document.getElementById('intensity').addEventListener('input', (e) => {
                    this.intensity = parseFloat(e.target.value);
                    this.intensityValue.textContent = this.intensity.toFixed(1);
                });

                document.getElementById('playButton').addEventListener('click', () => {
                    this.play();
                    // Auto-close menu on mobile
                    if (this.isMobile) {
                        setTimeout(() => this.hideMobileMenu(), 300);
                    }
                });

                document.getElementById('pauseButton').addEventListener('click', () => {
                    this.pause();
                    // Auto-close menu on mobile
                    if (this.isMobile) {
                        setTimeout(() => this.hideMobileMenu(), 300);
                    }
                });

                document.getElementById('stopButton').addEventListener('click', () => {
                    this.stop();
                    // Auto-close menu on mobile
                    if (this.isMobile) {
                        setTimeout(() => this.hideMobileMenu(), 300);
                    }
                });

                document.getElementById('uploadButton').addEventListener('click', () => {
                    document.getElementById('fileInput').click();
                    // Auto-close menu on mobile
                    if (this.isMobile) {
                        setTimeout(() => this.hideMobileMenu(), 300);
                    }
                });

                document.getElementById('fileInput').addEventListener('change', (e) => {
                    const file = e.target.files[0];
                    if (file) {
                        this.loadAudioFromFile(file);
                    }
                });

                document.getElementById('closeError').addEventListener('click', () => {
                    this.errorMessage.classList.add('hidden');
                });

                // Audio element event listeners
                this.audioElement.addEventListener('timeupdate', () => {
                    this.currentTime = this.audioElement.currentTime;
                    this.updateProgressDisplay();
                    this.updateMiniPlayerDisplay();
                });

                this.audioElement.addEventListener('loadedmetadata', () => {
                    this.updateProgressDisplay();
                    this.updateMiniPlayerDisplay();
                });

                this.audioElement.addEventListener('error', (e) => {
                    console.error('Audio element error:', e);
                });

                // Time slider for seeking
                this.timeSlider.addEventListener('click', (e) => {
                    if (!this.audioElement.src || this.audioElement.duration === 0) return;

                    const rect = this.timeSlider.getBoundingClientRect();
                    const clickPosition = e.clientX - rect.left;
                    const percentage = Math.max(0, Math.min(1, clickPosition / rect.width));

                    this.audioElement.currentTime = percentage * this.audioElement.duration;
                    this.currentTime = this.audioElement.currentTime;
                    this.updateProgressDisplay();
                    this.updateMiniPlayerDisplay();
                });

                // Better touch support for time slider on mobile
                this.timeSlider.addEventListener('touchstart', (e) => {
                    e.preventDefault();
                    const touch = e.touches[0];
                    const rect = this.timeSlider.getBoundingClientRect();
                    const clickPosition = touch.clientX - rect.left;
                    const percentage = Math.max(0, Math.min(1, clickPosition / rect.width));

                    if (!this.audioElement.src || this.audioElement.duration === 0) return;

                    this.audioElement.currentTime = percentage * this.audioElement.duration;
                    this.currentTime = this.audioElement.currentTime;
                    this.updateProgressDisplay();
                    this.updateMiniPlayerDisplay();
                }, { passive: false });
            }

            toggleMobileMenu() {
                if (this.controls.classList.contains('show')) {
                    this.hideMobileMenu();
                } else {
                    this.showMobileMenu();
                }
            }

            showMobileMenu() {
                this.controls.classList.add('show');
                this.mobileOverlay.classList.add('show');
                // Hide mini player when menu is open
                this.miniPlayer.style.display = 'none';
            }

            hideMobileMenu() {
                this.controls.classList.remove('show');
                this.mobileOverlay.classList.remove('show');
                // Show mini player when menu is closed
                if (this.isMobile) {
                    this.miniPlayer.style.display = 'flex';
                }
            }

            resize() {
                this.width = window.innerWidth;
                this.height = window.innerHeight;
                this.canvas.width = this.width;
                this.canvas.height = this.height;
                this.centreX = this.width / 2;
                this.centreY = this.height / 2;

                if (this.mode === 'particleSpectrum') {
                    this.createParticles();
                }
                this.createLiquidPoints();
            }

            getFrequencyValue(startHz, endHz) {
                const sampleRate = this.audioContext.sampleRate;
                const startIndex = Math.floor((startHz / (sampleRate / 2)) * this.bufferLength);
                const endIndex = Math.floor((endHz / (sampleRate / 2)) * this.bufferLength);
                let sum = 0;
                let count = 0;

                for (let i = Math.max(0, startIndex); i < Math.min(this.bufferLength, endIndex); i++) {
                    sum += this.frequencyData[i];
                    count++;
                }

                return count > 0 ? sum / count / 255 : 0;
            }

            getBassLevel() {
                return this.getFrequencyValue(this.bassBand.start, this.bassBand.end) * this.bassBoost;
            }

            getMidLevel() {
                return this.getFrequencyValue(this.midBand.start, this.midBand.end);
            }

            getTrebleLevel() {
                return this.getFrequencyValue(this.trebleBand.start, this.trebleBand.end) * this.trebleBoost;
            }

            createParticles() {
                this.particles = [];
                const particleCount = Math.min(1000, Math.floor(this.width * this.height / 1000));

                for (let i = 0; i < particleCount; i++) {
                    this.particles.push({
                        x: Math.random() * this.width,
                        y: Math.random() * this.height,
                        baseY: Math.random() * this.height,
                        size: Math.random() * 3 + 1,
                        speed: Math.random() * 0.5 + 0.1,
                        colour: this.currentColours.primary,
                        frequencyIndex: Math.floor(Math.random() * this.bufferLength),
                        phase: Math.random() * Math.PI * 2
                    });
                }
            }

            createLiquidPoints() {
                this.liquidPoints = [];
                const pointCount = 100;
                for (let i = 0; i <= pointCount; i++) {
                    this.liquidPoints.push({
                        x: (i / pointCount) * this.width,
                        baseY: this.height * 0.5,
                        y: this.height * 0.5,
                        vy: 0
                    });
                }
            }

            formatTime(seconds) {
                if (isNaN(seconds)) return '00:00';
                const mins = Math.floor(seconds / 60);
                const secs = Math.floor(seconds % 60);
                return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
            }

            updateProgressDisplay() {
                if (!this.audioElement.src) return;

                const current = this.audioElement.currentTime;
                const total = this.audioElement.duration;

                if (isNaN(current) || isNaN(total) || total === 0) return;

                const percentage = (current / total) * 100;

                this.timeProgress.style.width = `${percentage}%`;
                this.timeDisplay.textContent = this.formatTime(current);
                this.progressDisplay.textContent = `${this.formatTime(current)} / ${this.formatTime(total)}`;
            }

            updateMiniPlayerDisplay() {
                if (!this.audioElement.src) return;

                const current = this.audioElement.currentTime;
                if (isNaN(current)) return;

                this.miniTimeDisplay.textContent = this.formatTime(current);

                // Update play/pause button
                if (this.isPlaying) {
                    this.miniPlayBtn.textContent = '❚❚'; // Pause symbol
                } else {
                    this.miniPlayBtn.textContent = '▶'; // Play symbol
                }
            }

            showError(message) {
                this.errorText.textContent = message;
                this.errorMessage.classList.remove('hidden');
            }

            loadDefaultAudio() {
                this.showLoading();
                this.progress.style.width = '30%';

                // Reset audio element
                this.audioElement.pause();
                this.audioElement.currentTime = 0;

                // Using jsDelivr CDN for GitHub files (supports CORS)
                const audioUrl = 'https://cdn.jsdelivr.net/gh/alaric-visi/music@main/junkies-for-the-808/junkies-for-the-808.mp3';

                console.log('Loading MP3 from jsDelivr:', audioUrl);

                // Set up event listeners for loading
                const onLoaded = () => {
                    console.log('Audio loaded successfully from jsDelivr');
                    this.progress.style.width = '100%';

                    // Create media element source if not already created
                    if (!this.mediaElementSource) {
                        this.mediaElementSource = this.audioContext.createMediaElementSource(this.audioElement);
                        this.mediaElementSource.connect(this.bassFilter);
                    }

                    this.songTitle.textContent = 'Junkies for the 808 V1';
                    this.songStats.textContent = `by Mike Higgins | ${Math.floor(this.audioElement.duration)} seconds`;
                    this.songInfo.classList.remove('hidden');
                    this.playbackControls.classList.remove('hidden');

                    // Enable playback controls
                    document.getElementById('playButton').disabled = false;
                    document.getElementById('pauseButton').disabled = false;
                    document.getElementById('stopButton').disabled = false;

                    this.updateProgressDisplay();
                    this.updateMiniPlayerDisplay();

                    setTimeout(() => {
                        this.hideLoading();
                        // Try to auto-play - but don't show error if it fails
                        this.autoPlay();
                    }, 500);
                };

                const onError = (e) => {
                    console.error('Failed to load audio from jsDelivr:', e);
                    this.hideLoading();
                    // Keep demo visualisation running silently
                    this.startDemoVisualisation();
                };

                // Remove existing event listeners
                this.audioElement.removeEventListener('canplaythrough', onLoaded);
                this.audioElement.removeEventListener('error', onError);

                // Add new event listeners
                this.audioElement.addEventListener('canplaythrough', onLoaded, { once: true });
                this.audioElement.addEventListener('error', onError, { once: true });

                // Set the source
                this.audioElement.src = audioUrl;
                this.audioElement.load();

                // Timeout in case loading takes too long
                setTimeout(() => {
                    if (this.audioElement.readyState < 2) {
                        this.progress.style.width = '60%';
                    }
                }, 1000);
            }

            autoPlay() {
                if (!this.audioElement.src) return;

                // Resume audio context if suspended
                if (this.audioContext.state === 'suspended') {
                    this.audioContext.resume();
                }

                const playPromise = this.audioElement.play();

                if (playPromise !== undefined) {
                    playPromise.then(() => {
                        this.isPlaying = true;
                        this.startTime = this.audioContext.currentTime - this.audioElement.currentTime;

                        // Update button states
                        document.getElementById('playButton').disabled = true;
                        document.getElementById('pauseButton').disabled = false;
                        document.getElementById('stopButton').disabled = false;
                        this.updateMiniPlayerDisplay();

                        // Start animation loop
                        this.animate();
                    }).catch(error => {
                        console.log('Auto-play failed (normal for browsers):', error);
                        // Don't show error - just enable play button
                        document.getElementById('playButton').disabled = false;
                        this.updateMiniPlayerDisplay();
                    });
                }
            }

            async loadAudioFromFile(file) {
                this.showLoading();
                this.progress.style.width = '30%';

                try {
                    // Stop any current playback
                    this.stop();

                    // Create object URL from file
                    const objectUrl = URL.createObjectURL(file);

                    // Reset audio element
                    this.audioElement.pause();
                    this.audioElement.currentTime = 0;

                    // Set up loading handlers
                    const onLoaded = () => {
                        this.progress.style.width = '100%';

                        // Create media element source if not already created
                        if (!this.mediaElementSource) {
                            this.mediaElementSource = this.audioContext.createMediaElementSource(this.audioElement);
                            this.mediaElementSource.connect(this.bassFilter);
                        }

                        this.songTitle.textContent = file.name;
                        this.songStats.textContent = `${Math.floor(this.audioElement.duration)} seconds`;
                        this.songInfo.classList.remove('hidden');
                        this.playbackControls.classList.remove('hidden');

                        // Enable playback controls
                        document.getElementById('playButton').disabled = false;
                        document.getElementById('pauseButton').disabled = false;
                        document.getElementById('stopButton').disabled = false;

                        this.updateProgressDisplay();
                        this.updateMiniPlayerDisplay();

                        setTimeout(() => {
                            this.hideLoading();
                            // Auto-play after loading
                            this.play();
                        }, 500);

                        // Clean up object URL after loading
                        URL.revokeObjectURL(objectUrl);
                    };

                    const onError = (e) => {
                        console.error('Failed to load audio file:', e);
                        this.hideLoading();
                        this.showError('Could not load the audio file. Please try a different file.');
                    };

                    // Remove existing event listeners
                    this.audioElement.removeEventListener('canplaythrough', onLoaded);
                    this.audioElement.removeEventListener('error', onError);

                    // Add new event listeners
                    this.audioElement.addEventListener('canplaythrough', onLoaded, { once: true });
                    this.audioElement.addEventListener('error', onError, { once: true });

                    // Set the source
                    this.audioElement.src = objectUrl;
                    this.audioElement.load();

                } catch (error) {
                    console.error('Error loading audio file:', error);
                    this.hideLoading();
                    this.showError('Failed to load audio file. Please try again.');
                }
            }

            showLoading() {
                this.loading.classList.remove('hidden');
            }

            hideLoading() {
                this.loading.classList.add('hidden');
                this.progress.style.width = '0%';
            }

            play() {
                if (!this.audioElement.src) return;

                // Resume audio context if suspended
                if (this.audioContext.state === 'suspended') {
                    this.audioContext.resume();
                }

                if (this.isPlaying) return;

                const playPromise = this.audioElement.play();

                if (playPromise !== undefined) {
                    playPromise.then(() => {
                        this.isPlaying = true;
                        this.startTime = this.audioContext.currentTime - this.audioElement.currentTime;

                        // Update button states
                        document.getElementById('playButton').disabled = true;
                        document.getElementById('pauseButton').disabled = false;
                        document.getElementById('stopButton').disabled = false;
                        this.updateMiniPlayerDisplay();

                        // Start animation loop
                        this.animate();
                    }).catch(error => {
                        console.error('Playback failed:', error);
                        // Don't show error message - just enable the play button
                        document.getElementById('playButton').disabled = false;
                        this.updateMiniPlayerDisplay();
                    });
                }
            }

            pause() {
                if (!this.isPlaying) return;

                this.audioElement.pause();
                this.isPlaying = false;

                // Update button states
                document.getElementById('playButton').disabled = false;
                document.getElementById('pauseButton').disabled = true;
                this.updateMiniPlayerDisplay();

                // Stop animation loop
                if (this.animationFrameId) {
                    cancelAnimationFrame(this.animationFrameId);
                    this.animationFrameId = null;
                }

                // Update display
                this.updateProgressDisplay();
            }

            stop() {
                this.audioElement.pause();
                this.audioElement.currentTime = 0;
                this.currentTime = 0;
                this.isPlaying = false;

                // Update button states
                document.getElementById('playButton').disabled = false;
                document.getElementById('pauseButton').disabled = true;
                document.getElementById('stopButton').disabled = true;
                this.updateMiniPlayerDisplay();

                // Stop animation loop
                if (this.animationFrameId) {
                    cancelAnimationFrame(this.animationFrameId);
                    this.animationFrameId = null;
                }

                // Update display
                this.updateProgressDisplay();
            }

            startDemoVisualisation() {
                // Stop any existing animation
                if (this.animationFrameId) {
                    cancelAnimationFrame(this.animationFrameId);
                }

                // Generate demo frequency data
                this.frequencyData = new Uint8Array(this.bufferLength);
                this.timeData = new Uint8Array(this.bufferLength);

                // Start demo animation
                this.animateDemo();
            }

            animateDemo() {
                const now = performance.now();
                this.frameCount++;

                if (now - this.lastTime >= 1000) {
                    this.fps = Math.round((this.frameCount * 1000) / (now - this.lastTime));
                    this.fpsCounter.textContent = `FPS: ${this.fps}`;
                    this.frameCount = 0;
                    this.lastTime = now;
                }

                // Generate interesting demo patterns
                const time = Date.now() * 0.001;

                for (let i = 0; i < this.bufferLength; i++) {
                    // Create pulsing bass frequencies
                    if (i < this.bufferLength * 0.1) {
                        this.frequencyData[i] = Math.sin(time * 2 + i * 0.01) * 60 + 80;
                    }
                    // Create mid frequencies
                    else if (i < this.bufferLength * 0.5) {
                        this.frequencyData[i] = Math.sin(time * 5 + i * 0.005) * 40 + 60;
                    }
                    // Create treble frequencies
                    else {
                        this.frequencyData[i] = Math.sin(time * 10 + i * 0.002) * 30 + 40;
                    }

                    // Create waveform pattern
                    this.timeData[i] = Math.sin(time * 8 + i * 0.05) * 64 + 128;
                }

                this.clearCanvas();
                this.drawVisualisation();

                this.animationFrameId = requestAnimationFrame(() => this.animateDemo());
            }

            animate() {
                if (!this.isPlaying) return;

                const now = performance.now();
                this.frameCount++;

                if (now - this.lastTime >= 1000) {
                    this.fps = Math.round((this.frameCount * 1000) / (now - this.lastTime));
                    this.fpsCounter.textContent = `FPS: ${this.fps}`;
                    this.frameCount = 0;
                    this.lastTime = now;
                }

                // Get audio data
                this.analyser.getByteFrequencyData(this.frequencyData);
                this.analyser.getByteTimeDomainData(this.timeData);

                this.clearCanvas();
                this.drawVisualisation();

                this.animationFrameId = requestAnimationFrame(() => this.animate());
            }

            clearCanvas() {
                this.ctx.fillStyle = 'rgba(0, 0, 0, 0.15)';
                this.ctx.fillRect(0, 0, this.width, this.height);
            }

            drawVisualisation() {
                switch (this.mode) {
                    case 'waveform':
                        this.drawWaveform();
                        break;
                    case 'circle':
                        this.drawCircularSpectrum();
                        break;
                    case 'tunnel':
                        this.drawTunnel();
                        break;
                    case 'particleSpectrum':
                        this.drawParticleSpectrum();
                        break;
                    case 'liquid':
                        this.drawLiquidVisualisation();
                        break;
                }
            }

            drawWaveform() {
                this.ctx.beginPath();

                const sliceWidth = this.width / this.bufferLength;
                let x = 0;

                const bassLevel = this.getBassLevel();
                const lineWidth = 2 + bassLevel * 5;

                // Create gradient
                const gradient = this.ctx.createLinearGradient(0, 0, this.width, 0);
                gradient.addColorStop(0, this.currentColours.primary);
                gradient.addColorStop(0.5, this.currentColours.secondary);
                gradient.addColorStop(1, this.currentColours.primary);

                this.ctx.strokeStyle = gradient;
                this.ctx.lineWidth = lineWidth;
                this.ctx.lineJoin = 'round';
                this.ctx.lineCap = 'round';

                // Draw waveform
                for (let i = 0; i < this.bufferLength; i++) {
                    const v = this.timeData[i] / 128.0;
                    const y = v * this.height / 2;

                    if (i === 0) {
                        this.ctx.moveTo(x, y);
                    } else {
                        this.ctx.lineTo(x, y);
                    }

                    x += sliceWidth;
                }

                this.ctx.stroke();
            }

            drawCircularSpectrum() {
                const angleStep = (Math.PI * 2) / this.bufferLength;
                const baseRadius = Math.min(this.width, this.height) / 4;

                const bassLevel = this.getBassLevel();
                const radiusPulse = Math.sin(Date.now() * 0.002) * bassLevel * 20;

                this.ctx.save();
                this.ctx.translate(this.centreX, this.centreY);

                for (let i = 0; i < this.bufferLength; i += 2) {
                    const angle = i * angleStep;
                    const value = this.frequencyData[i] / 255;

                    // Apply frequency-specific intensity
                    let intensity = this.intensity;
                    if (i < this.bufferLength * 0.3) {
                        intensity *= this.bassBoost;
                    } else if (i > this.bufferLength * 0.7) {
                        intensity *= this.trebleBoost;
                    }

                    const barLength = value * 200 * intensity;

                    const x1 = Math.cos(angle) * (baseRadius + radiusPulse);
                    const y1 = Math.sin(angle) * (baseRadius + radiusPulse);
                    const x2 = Math.cos(angle) * (baseRadius + barLength + radiusPulse);
                    const y2 = Math.sin(angle) * (baseRadius + barLength + radiusPulse);

                    // Draw bar
                    this.ctx.beginPath();
                    this.ctx.moveTo(x1, y1);
                    this.ctx.lineTo(x2, y2);

                    // Colour based on frequency
                    const hue = (i / this.bufferLength) * 360;
                    const lineWidth = 2 + value * 3;

                    this.ctx.strokeStyle = `hsla(${hue}, 80%, 60%, ${0.5 + value * 0.5})`;
                    this.ctx.lineWidth = lineWidth;
                    this.ctx.lineCap = 'round';
                    this.ctx.stroke();

                    // Draw dot for strong frequencies
                    if (value > 0.7) {
                        this.ctx.beginPath();
                        this.ctx.arc(x2, y2, 3 + value * 5, 0, Math.PI * 2);
                        this.ctx.fillStyle = this.currentColours.accent;
                        this.ctx.fill();
                    }
                }

                this.ctx.restore();
            }

            drawTunnel() {
                const segments = 40;
                const time = Date.now() * 0.001;

                const bassLevel = this.getBassLevel();
                const trebleLevel = this.getTrebleLevel();

                this.ctx.save();
                this.ctx.translate(this.centreX, this.centreY);

                // Tunnel rotation
                const rotationSpeed = 0.002 + bassLevel * 0.003;
                const rotation = time * rotationSpeed;

                for (let i = 0; i < segments; i++) {
                    const depth = i / segments;
                    const radius = 50 + i * 8;

                    // Distortion based on frequencies
                    const bassDistortion = Math.sin(time * 2 + i * 0.3) * bassLevel * 30;
                    const trebleDistortion = Math.sin(time * 5 + i * 0.1) * trebleLevel * 10;
                    const distortedRadius = radius + bassDistortion + trebleDistortion;

                    this.ctx.beginPath();

                    // Draw segment
                    const points = 32;
                    for (let j = 0; j <= points; j++) {
                        const angle = (j / points) * Math.PI * 2 + rotation;

                        const freqIndex = Math.floor((j / points) * this.bufferLength);
                        const freqValue = this.frequencyData[freqIndex] / 255;

                        const distortion = Math.sin(time * 3 + angle * 2) * freqValue * 20 * this.intensity;
                        const r = distortedRadius + distortion;

                        const x = Math.cos(angle) * r;
                        const y = Math.sin(angle) * r;

                        if (j === 0) {
                            this.ctx.moveTo(x, y);
                        } else {
                            this.ctx.lineTo(x, y);
                        }
                    }

                    // Colour based on depth
                    const hue = (i * 10 + time * 50) % 360;
                    const alpha = 1 - depth * 0.7;

                    this.ctx.strokeStyle = `hsla(${hue}, 80%, 60%, ${alpha})`;
                    this.ctx.lineWidth = 2 + bassLevel * 3;
                    this.ctx.stroke();
                }

                this.ctx.restore();
            }

            drawParticleSpectrum() {
                if (this.particles.length === 0) {
                    this.createParticles();
                }

                const bassLevel = this.getBassLevel();
                const trebleLevel = this.getTrebleLevel();

                this.particles.forEach(particle => {
                    const freqValue = this.frequencyData[particle.frequencyIndex] / 255;

                    // Movement based on frequency
                    particle.y = particle.baseY + Math.sin(Date.now() * 0.001 * particle.speed + particle.phase) * freqValue * 100 * this.intensity;

                    // React to bass
                    if (particle.frequencyIndex < this.bufferLength * 0.3) {
                        particle.x += Math.sin(Date.now() * 0.001 + particle.phase) * bassLevel * 2;
                        particle.size = 2 + bassLevel * 3;
                    }

                    // React to treble
                    if (particle.frequencyIndex > this.bufferLength * 0.7) {
                        particle.size = 1 + trebleLevel * 2;
                    }

                    // Wrap around
                    if (particle.x < 0) particle.x = this.width;
                    if (particle.x > this.width) particle.x = 0;
                    if (particle.y < 0) particle.y = this.height;
                    if (particle.y > this.height) particle.y = 0;

                    // Draw particle
                    this.ctx.beginPath();
                    this.ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2);

                    const hue = (particle.frequencyIndex / this.bufferLength) * 360;
                    this.ctx.fillStyle = `hsla(${hue}, 80%, 60%, ${0.7 + freqValue * 0.3})`;
                    this.ctx.fill();
                });
            }

            drawLiquidVisualisation() {
                const bassLevel = this.getBassLevel();
                const midLevel = this.getMidLevel();

                // Update liquid points
                this.liquidPoints.forEach((point, i) => {
                    const freqIndex = Math.floor((i / this.liquidPoints.length) * this.bufferLength);
                    const targetY = this.height * 0.5 - (this.frequencyData[freqIndex] / 255) * 200 * this.intensity;

                    // Add bass vibration
                    const bassVibration = Math.sin(Date.now() * 0.003 + i * 0.1) * bassLevel * 50;
                    const targetYWithBass = targetY + bassVibration;

                    // Smooth movement
                    point.vy += (targetYWithBass - point.y) * 0.1;
                    point.vy *= 0.9;
                    point.y += point.vy;
                });

                // Draw liquid surface
                this.ctx.beginPath();
                this.ctx.moveTo(0, this.height);

                for (let i = 0; i < this.liquidPoints.length; i++) {
                    const point = this.liquidPoints[i];

                    if (i === 0) {
                        this.ctx.lineTo(point.x, point.y);
                    } else {
                        const prevPoint = this.liquidPoints[i-1];
                        const cpX = (prevPoint.x + point.x) / 2;
                        const cpY = (prevPoint.y + point.y) / 2;

                        this.ctx.quadraticCurveTo(prevPoint.x, prevPoint.y, cpX, cpY);
                    }
                }

                this.ctx.lineTo(this.width, this.height);
                this.ctx.closePath();

                // Fill with gradient
                const gradient = this.ctx.createLinearGradient(0, 0, 0, this.height);
                gradient.addColorStop(0, this.currentColours.primary + '80');
                gradient.addColorStop(1, this.currentColours.secondary + '40');

                this.ctx.fillStyle = gradient;
                this.ctx.fill();

                // Draw surface line
                this.ctx.beginPath();
                this.ctx.moveTo(0, this.liquidPoints[0].y);

                for (let i = 1; i < this.liquidPoints.length; i++) {
                    const point = this.liquidPoints[i];
                    this.ctx.lineTo(point.x, point.y);
                }

                this.ctx.strokeStyle = this.currentColours.accent;
                this.ctx.lineWidth = 2 + midLevel * 3;
                this.ctx.stroke();
            }
        }

        // Initialise when page loads
        window.addEventListener('load', () => {
            new EnhancedAudioVisualiser();
        });
    </script>
</body>
</html>