← Mike Higgins Home
Custom Code Experiment •

Music: Ariomaniacs Part 2

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, user-scalable=no">
    <title>Music: Ariomaniacs Part 2</title>
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@200;300;400;500;600&display=swap" rel="stylesheet">
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@2.44.0/tabler-icons.min.css">
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
            user-select: none;
            -webkit-user-select: none;
        }

        body {
            overflow: hidden;
            background: #050508;
            font-family: 'Outfit', sans-serif;
        }

        canvas {
            display: block;
            width: 100%;
            height: 100%;
            cursor: crosshair;
            position: absolute;
            top: 0;
            left: 0;
            z-index: 1;
        }

        /* MORPH BUTTON — sits in top-right, subtle, out of the way */
        #morph-container {
            position: absolute;
            top: 24px;
            right: 28px;
            z-index: 20;
        }

        .morph-button {
            backdrop-filter: blur(16px) saturate(160%);
            -webkit-backdrop-filter: blur(16px) saturate(160%);
            background: rgba(20, 20, 40, 0.25);
            border: 1px solid rgba(255, 255, 255, 0.14);
            color: rgba(255, 255, 255, 0.55);
            padding: 8px 20px;
            border-radius: 60px;
            cursor: pointer;
            font-family: 'Outfit', sans-serif;
            font-size: 11px;
            font-weight: 400;
            letter-spacing: 2.5px;
            text-transform: uppercase;
            transition: all 0.3s ease;
            box-shadow: none;
            overflow: hidden;
            position: relative;
        }

        .morph-button span {
            position: relative;
            z-index: 2;
            display: inline-block;
        }

        .morph-button::before {
            content: '';
            position: absolute;
            top: 0;
            left: -100%;
            width: 100%;
            height: 100%;
            background: linear-gradient(90deg, transparent, rgba(255,255,255,0.1), transparent);
            transform: skewX(-15deg);
            transition: 0.5s;
            pointer-events: none;
        }

        .morph-button:hover {
            background: rgba(80, 90, 160, 0.35);
            border-color: rgba(255, 255, 255, 0.3);
            color: rgba(255, 255, 255, 0.9);
            letter-spacing: 3px;
        }

        .morph-button:hover::before {
            left: 150%;
        }

        .morph-button:active {
            transform: scale(0.97);
        }

        /* AUDIO PLAYER */
        #audio-controls {
            position: absolute;
            bottom: 0;
            left: 0;
            right: 0;
            z-index: 20;
            display: flex;
            flex-direction: column;
            align-items: center;
            gap: 8px;
            padding: 10px 0 14px;
            background: linear-gradient(to top, rgba(5,5,12,0.92) 0%, transparent 100%);
        }

        #track-info {
            text-align: center;
        }
        #track-title {
            font-size: 13px;
            font-weight: 400;
            color: rgba(180, 160, 255, 0.85);
            letter-spacing: 0.1em;
            font-family: 'Outfit', sans-serif;
            text-transform: uppercase;
        }
        #track-artist {
            font-size: 10px;
            color: rgba(180, 160, 255, 0.35);
            margin-top: 2px;
            font-family: 'Outfit', sans-serif;
            letter-spacing: 0.08em;
        }

        #progress-wrap {
            width: 88%;
            max-width: 580px;
            display: flex;
            align-items: center;
            gap: 10px;
        }

        #time-cur, #time-dur {
            font-size: 10px;
            color: rgba(180, 160, 255, 0.35);
            min-width: 34px;
            font-variant-numeric: tabular-nums;
            font-family: 'Outfit', sans-serif;
        }
        #time-dur { text-align: right; }

        #progress-bar {
            flex: 1;
            height: 2px;
            background: rgba(150, 120, 255, 0.15);
            border-radius: 2px;
            position: relative;
            cursor: pointer;
        }
        #progress-fill {
            height: 100%;
            width: 0%;
            border-radius: 2px;
            background: rgba(150, 120, 255, 0.6);
            pointer-events: none;
            transition: width 0.1s linear;
        }
        #progress-thumb {
            position: absolute;
            right: -5px;
            top: 50%;
            transform: translateY(-50%);
            width: 9px;
            height: 9px;
            border-radius: 50%;
            background: rgba(180, 160, 255, 0.9);
            pointer-events: none;
            opacity: 0;
            transition: opacity 0.2s;
        }
        #progress-bar:hover #progress-thumb { opacity: 1; }

        #btn-row {
            display: flex;
            align-items: center;
            gap: 18px;
        }

        #play-btn {
            width: 44px;
            height: 44px;
            border-radius: 50%;
            border: 1px solid rgba(150, 120, 255, 0.35);
            background: rgba(80, 60, 140, 0.15);
            cursor: pointer;
            display: flex;
            align-items: center;
            justify-content: center;
            font-size: 18px;
            color: rgba(180, 160, 255, 0.9);
            transition: transform 0.12s, background 0.15s, box-shadow 0.2s;
            flex-shrink: 0;
        }
        #play-btn:hover {
            background: rgba(100, 80, 180, 0.3);
            transform: scale(1.06);
            box-shadow: 0 0 14px rgba(130, 100, 255, 0.4);
        }
        #play-btn:active { transform: scale(0.95); }

        #vol-wrap {
            display: flex;
            align-items: center;
            gap: 8px;
        }
        #vol-icon {
            font-size: 14px;
            color: rgba(180, 160, 255, 0.35);
        }
        #vol-slider {
            -webkit-appearance: none;
            appearance: none;
            width: 68px;
            height: 2px;
            background: rgba(150, 120, 255, 0.2);
            border-radius: 2px;
            outline: none;
            cursor: pointer;
        }
        #vol-slider::-webkit-slider-thumb {
            -webkit-appearance: none;
            width: 10px;
            height: 10px;
            border-radius: 50%;
            background: rgba(180, 160, 255, 0.85);
            cursor: pointer;
        }
        #vol-slider::-moz-range-thumb {
            width: 10px;
            height: 10px;
            border-radius: 50%;
            background: rgba(180, 160, 255, 0.85);
            cursor: pointer;
            border: none;
        }

        /* Audio reactive indicator ring on play button */
        #play-btn.active-audio {
            box-shadow: 0 0 0 2px rgba(150, 120, 255, 0.25), 0 0 18px rgba(130, 100, 255, 0.5);
        }

        @media (max-width: 640px) {
            .morph-button {
                padding: 10px 28px;
                font-size: 15px;
                letter-spacing: 1.5px;
            }
            #morph-container {
                bottom: 95px;
            }
        }
    </style>
</head>
<body>

    <canvas id="neural-network-canvas"></canvas>

    <div id="morph-container">
        <button id="change-formation-btn" class="morph-button"><span>MORPH</span></button>
    </div>

    <!-- AUDIO PLAYER -->
    <div id="audio-controls">
        <div id="track-info">
            <div id="track-title">Ariomaniacs Part 2</div>
            <div id="track-artist">Mike H</div>
        </div>
        <div id="progress-wrap">
            <span id="time-cur">0:00</span>
            <div id="progress-bar"><div id="progress-fill"></div><div id="progress-thumb"></div></div>
            <span id="time-dur">0:00</span>
        </div>
        <div id="btn-row">
            <div id="vol-wrap">
                <i class="ti ti-volume" id="vol-icon"></i>
                <input type="range" id="vol-slider" min="0" max="1" step="0.01" value="0.8">
            </div>
            <button id="play-btn" aria-label="Play"><i class="ti ti-player-play" id="play-icon"></i></button>
            <div style="width:88px"></div>
        </div>
    </div>

    <script type="importmap">
        {
            "imports": {
                "three": "https://cdn.jsdelivr.net/npm/three@0.162.0/build/three.module.js",
                "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.162.0/examples/jsm/"
            }
        }
    </script>

    <script type="module">
        import * as THREE from 'three';
        import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
        import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
        import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
        import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
        import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';

        // =====================================================================
        //  AUDIO ENGINE
        // =====================================================================
        const AUDIO_URL = 'https://cdn.jsdelivr.net/gh/alaric-visi/music@main/ariomaniacs/02-ariomaniacs-part-2.mp3';

        const playBtn     = document.getElementById('play-btn');
        const playIcon    = document.getElementById('play-icon');
        const progressBar = document.getElementById('progress-bar');
        const progressFill= document.getElementById('progress-fill');
        const timeCur     = document.getElementById('time-cur');
        const timeDur     = document.getElementById('time-dur');
        const volSlider   = document.getElementById('vol-slider');
        const volIcon     = document.getElementById('vol-icon');

        let audioCtx, analyser, audioSource, gainNode;
        let audioBuffer = null, isPlaying = false, startTime = 0, pauseOffset = 0, audioDuration = 0;
        let freqData, waveData;
        let audioInitialized = false;

        // Smoothed audio bands
        let sBass = 0, sMid = 0, sHigh = 0, sEnergy = 0;

        function fmtTime(t) {
            if (!isFinite(t) || t < 0) t = 0;
            return `${Math.floor(t/60)}:${Math.floor(t%60).toString().padStart(2,'0')}`;
        }

        async function initAudio() {
            if (audioInitialized) return;
            audioInitialized = true;
            audioCtx = new (window.AudioContext || window.webkitAudioContext)();
            gainNode = audioCtx.createGain();
            gainNode.gain.value = parseFloat(volSlider.value);
            gainNode.connect(audioCtx.destination);
            analyser = audioCtx.createAnalyser();
            analyser.fftSize = 2048;
            analyser.smoothingTimeConstant = 0.80;
            analyser.connect(gainNode);
            freqData = new Uint8Array(analyser.frequencyBinCount);
            waveData = new Uint8Array(analyser.fftSize);
            try {
                const res = await fetch(AUDIO_URL);
                const ab  = await res.arrayBuffer();
                audioBuffer = await audioCtx.decodeAudioData(ab);
                audioDuration = audioBuffer.duration;
                timeDur.textContent = fmtTime(audioDuration);
                playAudio();
            } catch(e) {
                audioInitialized = false;
                console.warn('Audio load failed:', e);
            }
        }

        function playAudio() {
            if (!audioBuffer) return;
            if (audioCtx.state === 'suspended') audioCtx.resume();
            audioSource = audioCtx.createBufferSource();
            audioSource.buffer = audioBuffer;
            audioSource.connect(analyser);
            audioSource.start(0, pauseOffset);
            startTime = audioCtx.currentTime - pauseOffset;
            isPlaying = true;
            playIcon.className = 'ti ti-player-pause';
            playBtn.classList.add('active-audio');
            audioSource.onended = () => {
                if (isPlaying) {
                    pauseOffset = 0; isPlaying = false;
                    playIcon.className = 'ti ti-player-play';
                    playBtn.classList.remove('active-audio');
                }
            };
        }

        function pauseAudio() {
            if (audioSource) { audioSource.onended = null; audioSource.stop(); }
            pauseOffset = Math.max(0, audioCtx.currentTime - startTime);
            isPlaying = false;
            playIcon.className = 'ti ti-player-play';
            playBtn.classList.remove('active-audio');
        }

        playBtn.addEventListener('click', async () => {
            if (!audioInitialized) { await initAudio(); return; }
            if (!audioBuffer) return;
            isPlaying ? pauseAudio() : playAudio();
        });

        volSlider.addEventListener('input', () => {
            if (gainNode) gainNode.gain.value = parseFloat(volSlider.value);
            volIcon.className = parseFloat(volSlider.value) < 0.01 ? 'ti ti-volume-off' : 'ti ti-volume';
        });

        progressBar.addEventListener('click', (e) => {
            if (!audioBuffer) return;
            const rect = progressBar.getBoundingClientRect();
            const frac = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
            pauseOffset = frac * audioDuration;
            if (isPlaying) { if (audioSource) { audioSource.onended=null; audioSource.stop(); } playAudio(); }
            else { timeCur.textContent=fmtTime(pauseOffset); progressFill.style.width=(frac*100)+'%'; }
        });

        // Returns smoothed audio bands {bass, mid, high, energy}
        // Falls back to gentle idle animation when not playing
        function getAudio(elapsedTime) {
            let energy=0, bass=0, mid=0, high=0;
            if (analyser && isPlaying) {
                analyser.getByteFrequencyData(freqData);
                for (let i=0; i<freqData.length; i++) energy += freqData[i];
                energy /= freqData.length * 255;
                for (let i=0;  i<8;   i++) bass += freqData[i]; bass  /= 8*255;
                for (let i=8;  i<80;  i++) mid  += freqData[i]; mid   /= 72*255;
                for (let i=80; i<200; i++) high += freqData[i]; high  /= 120*255;
            } else {
                const idle = (Math.sin(elapsedTime * 0.8) * 0.5 + 0.5) * 0.03;
                energy=idle; bass=idle*0.5; mid=idle*0.3; high=idle*0.1;
            }
            const rise = 0.45, fall = 0.14;
            sBass   += (bass   - sBass)   * (bass   > sBass   ? rise : fall);
            sMid    += (mid    - sMid)    * (mid    > sMid    ? rise : fall);
            sHigh   += (high   - sHigh)   * (high   > sHigh   ? rise : fall);
            sEnergy += (energy - sEnergy) * (energy > sEnergy ? rise : fall);
            return { bass: sBass, mid: sMid, high: sHigh, energy: sEnergy };
        }

        // =====================================================================
        //  CONFIG
        // =====================================================================
        const config = {
            paused: false,
            activePaletteIndex: 0,
            currentFormation: 0,
            numFormations: 3,
            densityFactor: 1.0
        };

        const colorPalettes = [
            [
                new THREE.Color(0x667eea),
                new THREE.Color(0x764ba2),
                new THREE.Color(0xf093fb),
                new THREE.Color(0x9d50bb),
                new THREE.Color(0x6e48aa)
            ],
            [
                new THREE.Color(0xf857a6),
                new THREE.Color(0xff5858),
                new THREE.Color(0xfeca57),
                new THREE.Color(0xff6348),
                new THREE.Color(0xff9068)
            ],
            [
                new THREE.Color(0x4facfe),
                new THREE.Color(0x00f2fe),
                new THREE.Color(0x43e97b),
                new THREE.Color(0x38f9d7),
                new THREE.Color(0x4484ce)
            ]
        ];

        // =====================================================================
        //  THREE.JS SETUP
        // =====================================================================
        const scene = new THREE.Scene();
        scene.fog = new THREE.FogExp2(0x000000, 0.002);

        const camera = new THREE.PerspectiveCamera(65, window.innerWidth / window.innerHeight, 0.1, 1000);
        camera.position.set(0, 8, 28);

        const canvasElement = document.getElementById('neural-network-canvas');
        const renderer = new THREE.WebGLRenderer({
            canvas: canvasElement,
            antialias: true,
            powerPreference: "high-performance"
        });
        renderer.setSize(window.innerWidth, window.innerHeight);
        renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
        renderer.setClearColor(0x000000);
        renderer.outputColorSpace = THREE.SRGBColorSpace;

        // Starfield
        function createStarfield() {
            const count = 8000;
            const positions = [], colors = [], sizes = [];
            for (let i = 0; i < count; i++) {
                const r = THREE.MathUtils.randFloat(50, 150);
                const phi = Math.acos(THREE.MathUtils.randFloatSpread(2));
                const theta = THREE.MathUtils.randFloat(0, Math.PI * 2);
                positions.push(r*Math.sin(phi)*Math.cos(theta), r*Math.sin(phi)*Math.sin(theta), r*Math.cos(phi));
                const c = Math.random();
                if (c < 0.7)       colors.push(1, 1, 1);
                else if (c < 0.85) colors.push(0.7, 0.8, 1);
                else               colors.push(1, 0.9, 0.8);
                sizes.push(THREE.MathUtils.randFloat(0.1, 0.3));
            }
            const geo = new THREE.BufferGeometry();
            geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
            geo.setAttribute('color',    new THREE.Float32BufferAttribute(colors, 3));
            geo.setAttribute('size',     new THREE.Float32BufferAttribute(sizes, 1));
            const mat = new THREE.ShaderMaterial({
                uniforms: { uTime: { value: 0 }, uHighFreq: { value: 0 } },
                vertexShader: `
                    attribute float size;
                    attribute vec3 color;
                    varying vec3 vColor;
                    uniform float uTime;
                    uniform float uHighFreq;
                    void main() {
                        vColor = color;
                        vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
                        // High-frequency audio makes stars twinkle faster & brighter
                        float twinkleSpeed = 2.0 + uHighFreq * 12.0;
                        float twinkleAmp   = 0.3  + uHighFreq * 0.7;
                        float twinkle = sin(uTime * twinkleSpeed + position.x * 100.0) * twinkleAmp + (1.0 - twinkleAmp * 0.5);
                        gl_PointSize = size * twinkle * (1.0 + uHighFreq * 1.5) * (300.0 / -mvPosition.z);
                        gl_Position = projectionMatrix * mvPosition;
                    }
                `,
                fragmentShader: `
                    varying vec3 vColor;
                    void main() {
                        vec2 center = gl_PointCoord - 0.5;
                        float dist = length(center);
                        if (dist > 0.5) discard;
                        float alpha = 1.0 - smoothstep(0.0, 0.5, dist);
                        gl_FragColor = vec4(vColor, alpha * 0.8);
                    }
                `,
                transparent: true,
                depthWrite: false,
                blending: THREE.AdditiveBlending
            });
            return new THREE.Points(geo, mat);
        }
        const starField = createStarfield();
        scene.add(starField);

        // OrbitControls
        const controls = new OrbitControls(camera, renderer.domElement);
        controls.enableDamping   = true;
        controls.dampingFactor   = 0.05;
        controls.rotateSpeed     = 0.7;
        controls.minDistance     = 8;
        controls.maxDistance     = 80;
        controls.autoRotate      = true;
        controls.autoRotateSpeed = 0.22;
        controls.enablePan       = false;

        // Post-processing
        const composer = new EffectComposer(renderer);
        composer.addPass(new RenderPass(scene, camera));
        const bloomPass = new UnrealBloomPass(
            new THREE.Vector2(window.innerWidth, window.innerHeight),
            1.8, 0.6, 0.7
        );
        composer.addPass(bloomPass);
        composer.addPass(new OutputPass());

        // =====================================================================
        //  SHADER UNIFORMS
        // =====================================================================
        const pulseUniforms = {
            uTime:           { value: 0.0 },
            uPulsePositions: { value: [new THREE.Vector3(1e3,1e3,1e3), new THREE.Vector3(1e3,1e3,1e3), new THREE.Vector3(1e3,1e3,1e3)] },
            uPulseTimes:     { value: [-1e3, -1e3, -1e3] },
            uPulseColors:    { value: [new THREE.Color(1,1,1), new THREE.Color(1,1,1), new THREE.Color(1,1,1)] },
            uPulseSpeed:     { value: 18.0 },
            uBaseNodeSize:   { value: 0.6 },
            // Audio uniforms — each band is a separate value so the shader
            // can pick the right one per-node based on the freqBand attribute
            uBass:           { value: 0.0 },
            uMid:            { value: 0.0 },
            uHigh:           { value: 0.0 },
            uEnergy:         { value: 0.0 },
        };

        // =====================================================================
        //  NOISE & SHADERS
        // =====================================================================
        const noiseFunctions = `
        vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
        vec4 mod289(vec4 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
        vec4 permute(vec4 x) { return mod289(((x * 34.0) + 1.0) * x); }
        vec4 taylorInvSqrt(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; }
        float snoise(vec3 v) {
            const vec2 C = vec2(1.0/6.0, 1.0/3.0);
            const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);
            vec3 i = floor(v + dot(v, C.yyy));
            vec3 x0 = v - i + dot(i, C.xxx);
            vec3 g = step(x0.yzx, x0.xyz);
            vec3 l = 1.0 - g;
            vec3 i1 = min(g.xyz, l.zxy);
            vec3 i2 = max(g.xyz, l.zxy);
            vec3 x1 = x0 - i1 + C.xxx;
            vec3 x2 = x0 - i2 + C.yyy;
            vec3 x3 = x0 - D.yyy;
            i = mod289(i);
            vec4 p = permute(permute(permute(i.z + vec4(0.0, i1.z, i2.z, 1.0)) + i.y + vec4(0.0, i1.y, i2.y, 1.0)) + i.x + vec4(0.0, i1.x, i2.x, 1.0));
            float n_ = 0.142857142857;
            vec3 ns = n_ * D.wyz - D.xzx;
            vec4 j = p - 49.0 * floor(p * ns.z * ns.z);
            vec4 x_ = floor(j * ns.z);
            vec4 y_ = floor(j - 7.0 * x_);
            vec4 x = x_ * ns.x + ns.yyyy;
            vec4 y = y_ * ns.x + ns.yyyy;
            vec4 h = 1.0 - abs(x) - abs(y);
            vec4 b0 = vec4(x.xy, y.xy);
            vec4 b1 = vec4(x.zw, y.zw);
            vec4 s0 = floor(b0) * 2.0 + 1.0;
            vec4 s1 = floor(b1) * 2.0 + 1.0;
            vec4 sh = -step(h, vec4(0.0));
            vec4 a0 = b0.xzyw + s0.xzyw * sh.xxyy;
            vec4 a1 = b1.xzyw + s1.xzyw * sh.zzww;
            vec3 p0 = vec3(a0.xy, h.x);
            vec3 p1 = vec3(a0.zw, h.y);
            vec3 p2 = vec3(a1.xy, h.z);
            vec3 p3 = vec3(a1.zw, h.w);
            vec4 norm = taylorInvSqrt(vec4(dot(p0,p0), dot(p1,p1), dot(p2,p2), dot(p3,p3)));
            p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w;
            vec4 m = max(0.6 - vec4(dot(x0,x0), dot(x1,x1), dot(x2,x2), dot(x3,x3)), 0.0);
            m = m * m;
            return 42.0 * dot(m * m, vec4(dot(p0,x0), dot(p1,x1), dot(p2,x2), dot(p3,x3)));
        }`;

        const nodeShader = {
            vertexShader: `${noiseFunctions}
            attribute float nodeSize;
            attribute float nodeType;
            attribute vec3 nodeColor;
            attribute float distanceFromRoot;
            // freqBand: 0 = bass, 1 = mid, 2 = high — assigned per-node at build time
            attribute float freqBand;
            uniform float uTime;
            uniform vec3 uPulsePositions[3];
            uniform float uPulseTimes[3];
            uniform float uPulseSpeed;
            uniform float uBaseNodeSize;
            uniform float uBass;
            uniform float uMid;
            uniform float uHigh;
            uniform float uEnergy;
            varying vec3 vColor;
            varying float vNodeType;
            varying vec3 vPosition;
            varying float vPulseIntensity;
            varying float vDistanceFromRoot;
            varying float vGlow;
            // Pass per-node audio reactivity to fragment shader
            varying float vNodeAudio;
            float getPulseIntensity(vec3 worldPos, vec3 pulsePos, float pulseTime) {
                if (pulseTime < 0.0) return 0.0;
                float timeSinceClick = uTime - pulseTime;
                if (timeSinceClick < 0.0 || timeSinceClick > 4.0) return 0.0;
                float pulseRadius = timeSinceClick * uPulseSpeed;
                float distToClick = distance(worldPos, pulsePos);
                float waveProximity = abs(distToClick - pulseRadius);
                return smoothstep(3.0, 0.0, waveProximity) * smoothstep(4.0, 0.0, timeSinceClick);
            }
            void main() {
                vNodeType = nodeType;
                vColor = nodeColor;
                vDistanceFromRoot = distanceFromRoot;
                vec3 worldPos = (modelMatrix * vec4(position, 1.0)).xyz;
                vPosition = worldPos;
                float totalPulseIntensity = 0.0;
                for (int i = 0; i < 3; i++) {
                    totalPulseIntensity += getPulseIntensity(worldPos, uPulsePositions[i], uPulseTimes[i]);
                }
                vPulseIntensity = min(totalPulseIntensity, 1.0);

                // Each node has a PRIMARY band it reacts to strongly,
                // plus BLEED from the other bands so it's never completely silent.
                // freqBand: 0=bass, 1=mid, 2=high
                float primary   = freqBand < 0.5 ? uBass :
                                  freqBand < 1.5 ? uMid  : uHigh;
                float secondary = freqBand < 0.5 ? uMid  :
                                  freqBand < 1.5 ? uBass : uMid;
                float tertiary  = freqBand < 0.5 ? uHigh :
                                  freqBand < 1.5 ? uHigh : uBass;
                // Noise floor: every node always has at least a little movement
                float noiseFloor = 0.04;
                float myAudio = max(noiseFloor, primary * 0.70 + secondary * 0.20 + tertiary * 0.10);
                vNodeAudio = myAudio;

                // Size breathe — primary band drives it, with real amplitude
                float breathe = sin(uTime * 0.7 + distanceFromRoot * 0.15) * 0.12 + 0.88;
                float audioSwell = myAudio * 1.4;
                float baseSize = nodeSize * (breathe + audioSwell);
                float pulseSize = baseSize * (1.0 + vPulseIntensity * 2.0);

                vGlow = 0.4 + 0.4 * sin(uTime * 0.5 + distanceFromRoot * 0.2) + myAudio * 0.5;

                vec3 modifiedPosition = position;
                if (nodeType > 0.5) {
                    // Leaf nodes ripple with their own band, not global bass
                    float noiseSpeed = 0.08 + myAudio * 0.18;
                    float noiseAmp   = 0.12 + myAudio * 0.28;   // was 0.55 — calmer
                    float noise = snoise(position * 0.08 + uTime * noiseSpeed);
                    modifiedPosition += normal * noise * noiseAmp;
                }
                vec4 mvPosition = modelViewMatrix * vec4(modifiedPosition, 1.0);
                gl_PointSize = pulseSize * uBaseNodeSize * (1000.0 / -mvPosition.z);
                gl_Position = projectionMatrix * mvPosition;
            }`,
            fragmentShader: `
            uniform float uTime;
            uniform vec3 uPulseColors[3];
            uniform float uEnergy;
            varying vec3 vColor;
            varying float vNodeType;
            varying vec3 vPosition;
            varying float vPulseIntensity;
            varying float vDistanceFromRoot;
            varying float vGlow;
            varying float vNodeAudio;
            void main() {
                vec2 center = 2.0 * gl_PointCoord - 1.0;
                float dist = length(center);
                if (dist > 1.0) discard;
                float glow1 = 1.0 - smoothstep(0.0, 0.5, dist);
                float glow2 = 1.0 - smoothstep(0.0, 1.0, dist);
                float glowStrength = pow(glow1, 1.2) + glow2 * 0.25;

                float breatheColor = 0.92 + 0.08 * sin(uTime * 0.6 + vDistanceFromRoot * 0.25);
                vec3 baseColor = vColor * breatheColor;

                // Colour lifts toward a brighter version of its own hue on its band —
                // capped so it never blows to white, but clearly visible
                baseColor = mix(baseColor, baseColor * 1.5, vNodeAudio * 0.8);

                vec3 finalColor = baseColor;
                if (vPulseIntensity > 0.0) {
                    vec3 pulseColor = mix(vec3(1.0), uPulseColors[0], 0.5);
                    finalColor = mix(baseColor, pulseColor, vPulseIntensity * 0.32);
                    finalColor *= (1.0 + vPulseIntensity * 0.35);
                    glowStrength *= (1.0 + vPulseIntensity * 0.4);
                }
                // Core highlight scales with audio
                float coreBrightness = smoothstep(0.4, 0.0, dist);
                finalColor += vec3(1.0) * coreBrightness * (0.18 + vNodeAudio * 0.28);

                float alpha = glowStrength * (0.92 - 0.28 * dist);
                float camDistance = length(vPosition - cameraPosition);
                float distanceFade = smoothstep(100.0, 15.0, camDistance);
                finalColor *= (1.0 + vGlow * 0.08);
                gl_FragColor = vec4(finalColor, alpha * distanceFade);
            }`
        };

        const connectionShader = {
            vertexShader: `${noiseFunctions}
            attribute vec3 startPoint;
            attribute vec3 endPoint;
            attribute float connectionStrength;
            attribute float pathIndex;
            attribute vec3 connectionColor;
            // freqBand for this connection (averaged from its two nodes)
            attribute float freqBand;
            uniform float uTime;
            uniform vec3 uPulsePositions[3];
            uniform float uPulseTimes[3];
            uniform float uPulseSpeed;
            uniform float uBass;
            uniform float uMid;
            uniform float uHigh;
            varying vec3 vColor;
            varying float vConnectionStrength;
            varying float vPulseIntensity;
            varying float vPathPosition;
            varying float vDistanceFromCamera;
            varying float vConnAudio;
            float getPulseIntensity(vec3 worldPos, vec3 pulsePos, float pulseTime) {
                if (pulseTime < 0.0) return 0.0;
                float timeSinceClick = uTime - pulseTime;
                if (timeSinceClick < 0.0 || timeSinceClick > 4.0) return 0.0;
                float pulseRadius = timeSinceClick * uPulseSpeed;
                float distToClick = distance(worldPos, pulsePos);
                float waveProximity = abs(distToClick - pulseRadius);
                return smoothstep(3.0, 0.0, waveProximity) * smoothstep(4.0, 0.0, timeSinceClick);
            }
            void main() {
                float t = position.x;
                vPathPosition = t;
                vec3 midPoint = mix(startPoint, endPoint, 0.5);
                float pathOffset = sin(t * 3.14159) * 0.15;
                vec3 perpendicular = normalize(cross(normalize(endPoint - startPoint), vec3(0.0, 1.0, 0.0)));
                if (length(perpendicular) < 0.1) perpendicular = vec3(1.0, 0.0, 0.0);
                midPoint += perpendicular * pathOffset;
                vec3 p0 = mix(startPoint, midPoint, t);
                vec3 p1 = mix(midPoint, endPoint, t);
                vec3 finalPos = mix(p0, p1, t);

                // Connection listens primarily to its own band, with bleed from others
                float primary   = freqBand < 0.5 ? uBass :
                                  freqBand < 1.5 ? uMid  : uHigh;
                float secondary = freqBand < 0.5 ? uMid  :
                                  freqBand < 1.5 ? uBass : uMid;
                float noiseFloor = 0.04;
                float myAudio = max(noiseFloor, primary * 0.75 + secondary * 0.25);
                vConnAudio = myAudio;

                // Path ripple driven by this connection's own audio band
                float noiseTime = uTime * (0.12 + myAudio * 0.35);
                float noiseAmp  = 0.10 + myAudio * 0.30;
                float noise = snoise(vec3(pathIndex * 0.08, t * 0.6, noiseTime));
                finalPos += perpendicular * noise * noiseAmp;

                vec3 worldPos = (modelMatrix * vec4(finalPos, 1.0)).xyz;
                float totalPulseIntensity = 0.0;
                for (int i = 0; i < 3; i++) totalPulseIntensity += getPulseIntensity(worldPos, uPulsePositions[i], uPulseTimes[i]);
                vPulseIntensity = min(totalPulseIntensity, 1.0);
                vColor = connectionColor;
                vConnectionStrength = connectionStrength;
                vDistanceFromCamera = length(worldPos - cameraPosition);
                gl_Position = projectionMatrix * modelViewMatrix * vec4(finalPos, 1.0);
            }`,
            fragmentShader: `
            uniform float uTime;
            uniform vec3 uPulseColors[3];
            varying vec3 vColor;
            varying float vConnectionStrength;
            varying float vPulseIntensity;
            varying float vPathPosition;
            varying float vDistanceFromCamera;
            varying float vConnAudio;
            void main() {
                // Flow speed scales with this connection's own band — clearly visible
                float flowSpeed1 = 3.5  + vConnAudio * 16.0;
                float flowSpeed2 = 2.0  + vConnAudio * 9.0;
                float flowPattern1 = sin(vPathPosition * 25.0 - uTime * flowSpeed1) * 0.5 + 0.5;
                float flowPattern2 = sin(vPathPosition * 15.0 - uTime * flowSpeed2 + 1.57) * 0.5 + 0.5;
                float combinedFlow = (flowPattern1 + flowPattern2 * 0.5) / 1.5;

                vec3 baseColor = vColor * (0.82 + 0.18 * sin(uTime * 0.6 + vPathPosition * 12.0));
                // Visible colour lift on beat, capped to avoid blowout
                baseColor = mix(baseColor, baseColor * 1.45, vConnAudio * 0.75);

                float flowIntensity = 0.35 * combinedFlow * vConnectionStrength + vConnAudio * 0.35;
                vec3 finalColor = baseColor;
                if (vPulseIntensity > 0.0) {
                    vec3 pulseColor = mix(vec3(1.0), uPulseColors[0], 0.4);
                    finalColor = mix(baseColor, pulseColor * 1.1, vPulseIntensity * 0.27);
                    flowIntensity += vPulseIntensity * 0.25;
                }
                finalColor *= (0.72 + flowIntensity + vConnectionStrength * 0.4);
                float baseAlpha = (0.65 + vConnAudio * 0.3) * vConnectionStrength;
                float flowAlpha = combinedFlow * (0.25 + vConnAudio * 0.2);
                float alpha = baseAlpha + flowAlpha;
                alpha = mix(alpha, min(1.0, alpha * 2.0), vPulseIntensity);
                float distanceFade = smoothstep(100.0, 15.0, vDistanceFromCamera);
                gl_FragColor = vec4(finalColor, alpha * distanceFade);
            }`
        };

        // =====================================================================
        //  NETWORK GENERATION (unchanged logic)
        // =====================================================================
        class Node {
            constructor(position, level = 0, type = 0) {
                this.position = position;
                this.connections = [];
                this.level = level;
                this.type = type;
                this.size = type === 0 ? THREE.MathUtils.randFloat(0.8, 1.4) : THREE.MathUtils.randFloat(0.5, 1.0);
                this.distanceFromRoot = 0;
            }
            addConnection(node, strength = 1.0) {
                if (!this.isConnectedTo(node)) {
                    this.connections.push({ node, strength });
                    node.connections.push({ node: this, strength });
                }
            }
            isConnectedTo(node) { return this.connections.some(c => c.node === node); }
        }

        function generateNeuralNetwork(formationIndex, densityFactor = 1.0) {
            let nodes = [], rootNode;
            const palette = colorPalettes[config.activePaletteIndex];

            function generateCrystallineSphere() {
                rootNode = new Node(new THREE.Vector3(0,0,0), 0, 0); rootNode.size = 2.0; nodes.push(rootNode);
                const layers = 5, goldenRatio = (1 + Math.sqrt(5)) / 2;
                for (let layer = 1; layer <= layers; layer++) {
                    const radius = layer * 4;
                    const numPoints = Math.floor(layer * 12 * densityFactor);
                    for (let i = 0; i < numPoints; i++) {
                        const phi = Math.acos(1 - 2*(i+0.5)/numPoints);
                        const theta = 2*Math.PI*i/goldenRatio;
                        const pos = new THREE.Vector3(radius*Math.sin(phi)*Math.cos(theta), radius*Math.sin(phi)*Math.sin(theta), radius*Math.cos(phi));
                        const isLeaf = layer === layers || Math.random() < 0.3;
                        const node = new Node(pos, layer, isLeaf ? 1 : 0);
                        node.distanceFromRoot = radius; nodes.push(node);
                        if (layer > 1) {
                            const prev = nodes.filter(n => n.level === layer-1 && n !== rootNode);
                            prev.sort((a,b) => pos.distanceTo(a.position) - pos.distanceTo(b.position));
                            for (let j = 0; j < Math.min(3, prev.length); j++) {
                                const dist = pos.distanceTo(prev[j].position);
                                node.addConnection(prev[j], Math.max(0.3, 1.0-(dist/(radius*2))));
                            }
                        } else { rootNode.addConnection(node, 0.9); }
                    }
                    const layerNodes = nodes.filter(n => n.level === layer && n !== rootNode);
                    for (let i = 0; i < layerNodes.length; i++) {
                        const nearby = layerNodes.filter(n => n !== layerNodes[i]).sort((a,b) => layerNodes[i].position.distanceTo(a.position)-layerNodes[i].position.distanceTo(b.position)).slice(0,5);
                        for (const nn of nearby) {
                            if (layerNodes[i].position.distanceTo(nn.position) < radius*0.8 && !layerNodes[i].isConnectedTo(nn))
                                layerNodes[i].addConnection(nn, 0.6);
                        }
                    }
                }
            }

            function generateHelixLattice() {
                rootNode = new Node(new THREE.Vector3(0,0,0), 0, 0); rootNode.size = 1.8; nodes.push(rootNode);
                const numHelices=4, height=30, maxRadius=12, npH=Math.floor(50*densityFactor);
                let helixArrays=[];
                for (let h=0; h<numHelices; h++) {
                    const hPhase=(h/numHelices)*Math.PI*2; let hNodes=[];
                    for (let i=0; i<npH; i++) {
                        const t=i/(npH-1), y=(t-0.5)*height, rScale=Math.sin(t*Math.PI)*0.7+0.3, r=maxRadius*rScale, angle=hPhase+t*Math.PI*6;
                        const pos=new THREE.Vector3(r*Math.cos(angle),y,r*Math.sin(angle));
                        const node=new Node(pos,Math.ceil(t*5),(i>npH-5||Math.random()<0.25)?1:0);
                        node.distanceFromRoot=Math.sqrt(r*r+y*y); node.helixT=t; node.helixIndex=h;
                        nodes.push(node); hNodes.push(node);
                    }
                    helixArrays.push(hNodes); rootNode.addConnection(hNodes[0],1.0);
                    for (let i=0;i<hNodes.length-1;i++) hNodes[i].addConnection(hNodes[i+1],0.85);
                }
                for (let h=0;h<numHelices;h++) {
                    const cur=helixArrays[h], nxt=helixArrays[(h+1)%numHelices];
                    for (let i=0;i<cur.length;i+=5) { const t=cur[i].helixT, idx=Math.round(t*(nxt.length-1)); if(idx<nxt.length) cur[i].addConnection(nxt[idx],0.7); }
                }
            }

            function generateFractalWeb() {
                rootNode = new Node(new THREE.Vector3(0,0,0), 0, 0); rootNode.size = 1.6; nodes.push(rootNode);
                const branches=6, maxDepth=4;
                const createBranch=(startNode,direction,depth,strength,scale)=>{
                    if(depth>maxDepth) return;
                    const endPos=startNode.position.clone().add(direction.clone().multiplyScalar(5*scale));
                    const newNode=new Node(endPos,depth,depth===maxDepth||Math.random()<0.3?1:0);
                    newNode.distanceFromRoot=rootNode.position.distanceTo(endPos); nodes.push(newNode);
                    startNode.addConnection(newNode,strength);
                    if(depth<maxDepth) for(let i=0;i<3;i++){
                        const angle=(i/3)*Math.PI*2;
                        const perp1=new THREE.Vector3(-direction.y,direction.x,0).normalize();
                        const perp2=direction.clone().cross(perp1).normalize();
                        const newDir=direction.clone().add(perp1.clone().multiplyScalar(Math.cos(angle)*0.7)).add(perp2.clone().multiplyScalar(Math.sin(angle)*0.7)).normalize();
                        createBranch(newNode,newDir,depth+1,strength*0.7,scale*0.75);
                    }
                };
                for(let i=0;i<branches;i++){
                    const phi=Math.acos(1-2*(i+0.5)/branches), theta=Math.PI*(1+Math.sqrt(5))*i;
                    const dir=new THREE.Vector3(Math.sin(phi)*Math.cos(theta),Math.sin(phi)*Math.sin(theta),Math.cos(phi)).normalize();
                    createBranch(rootNode,dir,1,0.9,1.0);
                }
            }

            switch(formationIndex%3) {
                case 0: generateCrystallineSphere(); break;
                case 1: generateHelixLattice(); break;
                case 2: generateFractalWeb(); break;
            }
            return { nodes, rootNode };
        }

        let nodesMesh = null, connectionsMesh = null, neuralNetwork = null;

        function createNetworkVisualization(formationIndex, densityFactor = 1.0) {
            if (nodesMesh)       { scene.remove(nodesMesh);       nodesMesh.geometry.dispose();       nodesMesh.material.dispose(); }
            if (connectionsMesh) { scene.remove(connectionsMesh); connectionsMesh.geometry.dispose(); connectionsMesh.material.dispose(); }
            neuralNetwork = generateNeuralNetwork(formationIndex, densityFactor);
            const palette = colorPalettes[config.activePaletteIndex];

            // Nodes
            const nodesGeo = new THREE.BufferGeometry();
            const nodePositions=[], nodeTypes=[], nodeSizes=[], nodeColors=[], distancesRoot=[], nodeFreqBands=[];
            neuralNetwork.nodes.forEach(node => {
                nodePositions.push(node.position.x, node.position.y, node.position.z);
                nodeTypes.push(node.type); nodeSizes.push(node.size); distancesRoot.push(node.distanceFromRoot);
                const colorIdx = Math.min(node.level, palette.length-1);
                const baseColor = palette[colorIdx].clone();
                baseColor.offsetHSL(THREE.MathUtils.randFloatSpread(0.03), THREE.MathUtils.randFloatSpread(0.08), THREE.MathUtils.randFloatSpread(0.08));
                nodeColors.push(baseColor.r, baseColor.g, baseColor.b);
                // Assign frequency band by layer:
                //   level 0-1 (core/inner) → bass (0)
                //   level 2-3 (middle)     → mid  (1)
                //   level 4+  (outer/leaf) → high (2)
                const band = node.level <= 1 ? 0 : node.level <= 3 ? 1 : 2;
                nodeFreqBands.push(band);
            });
            nodesGeo.setAttribute('position',         new THREE.Float32BufferAttribute(nodePositions, 3));
            nodesGeo.setAttribute('nodeType',          new THREE.Float32BufferAttribute(nodeTypes, 1));
            nodesGeo.setAttribute('nodeSize',          new THREE.Float32BufferAttribute(nodeSizes, 1));
            nodesGeo.setAttribute('nodeColor',         new THREE.Float32BufferAttribute(nodeColors, 3));
            nodesGeo.setAttribute('distanceFromRoot',  new THREE.Float32BufferAttribute(distancesRoot, 1));
            nodesGeo.setAttribute('freqBand',          new THREE.Float32BufferAttribute(nodeFreqBands, 1));
            const nodeMat = new THREE.ShaderMaterial({
                uniforms: THREE.UniformsUtils.clone(pulseUniforms),
                vertexShader: nodeShader.vertexShader,
                fragmentShader: nodeShader.fragmentShader,
                transparent: true, depthWrite: false, blending: THREE.AdditiveBlending
            });
            nodesMesh = new THREE.Points(nodesGeo, nodeMat);
            scene.add(nodesMesh);

            // Connections
            const connGeo = new THREE.BufferGeometry();
            const startPts=[], endPts=[], connStrengths=[], connColorsArr=[], pathIndices=[], positions=[], connFreqBands=[];
            let pIdx=0; const processed=new Set();
            neuralNetwork.nodes.forEach((node,i) => {
                node.connections.forEach(conn => {
                    const j=neuralNetwork.nodes.indexOf(conn.node); if(j===-1) return;
                    const key=[Math.min(i,j),Math.max(i,j)].join('-');
                    if(!processed.has(key)){
                        processed.add(key);
                        // Connection's band = average of the two endpoint bands
                        const bandA = node.level <= 1 ? 0 : node.level <= 3 ? 1 : 2;
                        const bandB = conn.node.level <= 1 ? 0 : conn.node.level <= 3 ? 1 : 2;
                        const connBand = Math.round((bandA + bandB) / 2);
                        for(let s=0;s<20;s++){
                            const t=s/19;
                            positions.push(t,0,0);
                            startPts.push(node.position.x,node.position.y,node.position.z);
                            endPts.push(conn.node.position.x,conn.node.position.y,conn.node.position.z);
                            pathIndices.push(pIdx); connStrengths.push(conn.strength);
                            connFreqBands.push(connBand);
                            const avgLvl=Math.min(Math.floor((node.level+conn.node.level)/2),palette.length-1);
                            const col=palette[avgLvl].clone(); col.offsetHSL(THREE.MathUtils.randFloatSpread(0.03),THREE.MathUtils.randFloatSpread(0.08),THREE.MathUtils.randFloatSpread(0.08));
                            connColorsArr.push(col.r,col.g,col.b);
                        }
                        pIdx++;
                    }
                });
            });
            connGeo.setAttribute('position',          new THREE.Float32BufferAttribute(positions, 3));
            connGeo.setAttribute('startPoint',         new THREE.Float32BufferAttribute(startPts, 3));
            connGeo.setAttribute('endPoint',           new THREE.Float32BufferAttribute(endPts, 3));
            connGeo.setAttribute('connectionStrength', new THREE.Float32BufferAttribute(connStrengths, 1));
            connGeo.setAttribute('connectionColor',    new THREE.Float32BufferAttribute(connColorsArr, 3));
            connGeo.setAttribute('pathIndex',          new THREE.Float32BufferAttribute(pathIndices, 1));
            connGeo.setAttribute('freqBand',           new THREE.Float32BufferAttribute(connFreqBands, 1));
            const connMat = new THREE.ShaderMaterial({
                uniforms: THREE.UniformsUtils.clone(pulseUniforms),
                vertexShader: connectionShader.vertexShader,
                fragmentShader: connectionShader.fragmentShader,
                transparent: true, depthWrite: false, blending: THREE.AdditiveBlending
            });
            connectionsMesh = new THREE.LineSegments(connGeo, connMat);
            scene.add(connectionsMesh);

            // Seed pulse colours from palette
            [nodeMat, connMat].forEach(m => {
                palette.slice(0,3).forEach((c,i) => { m.uniforms.uPulseColors.value[i].copy(c); });
            });
        }

        // =====================================================================
        //  PULSE ON CLICK
        // =====================================================================
        const raycaster = new THREE.Raycaster(), pointer = new THREE.Vector2();
        const interactionPlane = new THREE.Plane(new THREE.Vector3(0,0,1), 0);
        const interactionPoint = new THREE.Vector3();
        let lastPulseIndex = 0;

        function triggerPulse(clientX, clientY) {
            pointer.x = (clientX/window.innerWidth)*2-1;
            pointer.y = -(clientY/window.innerHeight)*2+1;
            raycaster.setFromCamera(pointer, camera);
            interactionPlane.normal.copy(camera.position).normalize();
            interactionPlane.constant = -interactionPlane.normal.dot(camera.position) + camera.position.length()*0.5;
            if (raycaster.ray.intersectPlane(interactionPlane, interactionPoint) && nodesMesh && connectionsMesh) {
                lastPulseIndex = (lastPulseIndex+1)%3;
                const t = clock.getElapsedTime();
                nodesMesh.material.uniforms.uPulsePositions.value[lastPulseIndex].copy(interactionPoint);
                nodesMesh.material.uniforms.uPulseTimes.value[lastPulseIndex] = t;
                connectionsMesh.material.uniforms.uPulsePositions.value[lastPulseIndex].copy(interactionPoint);
                connectionsMesh.material.uniforms.uPulseTimes.value[lastPulseIndex] = t;
                const randCol = colorPalettes[config.activePaletteIndex][Math.floor(Math.random()*colorPalettes[config.activePaletteIndex].length)];
                nodesMesh.material.uniforms.uPulseColors.value[lastPulseIndex].copy(randCol);
                connectionsMesh.material.uniforms.uPulseColors.value[lastPulseIndex].copy(randCol);
            }
        }

        renderer.domElement.addEventListener('click', (e) => {
            if (e.target.closest('.morph-button') || e.target.closest('#audio-controls')) return;
            triggerPulse(e.clientX, e.clientY);
        });
        renderer.domElement.addEventListener('touchstart', (e) => {
            if (e.target.closest('.morph-button') || e.target.closest('#audio-controls')) return;
            if (e.touches.length) triggerPulse(e.touches[0].clientX, e.touches[0].clientY);
        }, { passive: false });

        // Morph button
        document.getElementById('change-formation-btn').addEventListener('click', () => {
            config.currentFormation = (config.currentFormation+1) % config.numFormations;
            createNetworkVisualization(config.currentFormation, config.densityFactor);
            controls.autoRotate = false;
            setTimeout(() => { controls.autoRotate = true; }, 2500);
        });

        // =====================================================================
        //  BEAT DETECTION STATE
        // =====================================================================
        let beatPrevBass = 0, beatPrevHigh = 0;

        // Fire a pulse from an exact world-space position (used by beat detection)
        function triggerPulseAt(worldPos, bandHint) {
            if (!nodesMesh || !connectionsMesh) return;
            lastPulseIndex = (lastPulseIndex + 1) % 3;
            const t = clock.getElapsedTime();
            nodesMesh.material.uniforms.uPulsePositions.value[lastPulseIndex].copy(worldPos);
            nodesMesh.material.uniforms.uPulseTimes.value[lastPulseIndex] = t;
            connectionsMesh.material.uniforms.uPulsePositions.value[lastPulseIndex].copy(worldPos);
            connectionsMesh.material.uniforms.uPulseTimes.value[lastPulseIndex] = t;
            // Colour: bass pulses → warm purple, high pulses → cool blue-white
            const palette = colorPalettes[config.activePaletteIndex];
            const col = bandHint === 0
                ? palette[0]   // bass = deep purple/blue from palette
                : palette[2];  // high = mid-palette colour
            nodesMesh.material.uniforms.uPulseColors.value[lastPulseIndex].copy(col);
            connectionsMesh.material.uniforms.uPulseColors.value[lastPulseIndex].copy(col);
        }

        // =====================================================================
        //  ANIMATION LOOP
        // =====================================================================
        const clock = new THREE.Clock();

        function animate() {
            requestAnimationFrame(animate);
            const t = clock.getElapsedTime();

            // Get audio data
            const audio = getAudio(t);

            // Update progress bar
            if (isPlaying && audioCtx) {
                const cur = Math.min(audioCtx.currentTime - startTime, audioDuration);
                progressFill.style.width = (audioDuration > 0 ? (cur/audioDuration)*100 : 0) + '%';
                timeCur.textContent = fmtTime(Math.max(0, cur));
            }

            // Push audio to both meshes' uniforms
            if (nodesMesh) {
                const u = nodesMesh.material.uniforms;
                u.uTime.value   = t;
                u.uBass.value   = audio.bass;
                u.uMid.value    = audio.mid;
                u.uHigh.value   = audio.high;
                u.uEnergy.value = audio.energy;
                // Subtle network-wide rotation breathe on bass
                nodesMesh.rotation.y = Math.sin(t * 0.04) * 0.05 + audio.bass * 0.04;
            }
            if (connectionsMesh) {
                const u = connectionsMesh.material.uniforms;
                u.uTime.value   = t;
                u.uBass.value   = audio.bass;
                u.uMid.value    = audio.mid;
                u.uHigh.value   = audio.high;
                u.uEnergy.value = audio.energy;
                connectionsMesh.rotation.y = Math.sin(t * 0.04) * 0.05 + audio.bass * 0.04;
            }

            // Bloom: more responsive now — band-separated reactivity keeps detail
            bloomPass.strength  = 1.6 + audio.bass * 0.8 + audio.energy * 0.3;
            bloomPass.threshold = Math.max(0.5, 0.72 - audio.energy * 0.2);

            // ---- BEAT-TRIGGERED AUTO PULSES ----
            // Fire a pulse from a random point in the net whenever a transient
            // is detected on bass or high frequencies.
            if (isPlaying && nodesMesh && connectionsMesh && neuralNetwork) {
                // Bass transient: smoothed value jumped significantly vs last frame
                if (sBass > beatPrevBass + 0.042 && sBass > 0.07) {
                    // Pick a random inner node (bass zone) as origin
                    const innerNodes = neuralNetwork.nodes.filter(n => n.level <= 1);
                    const origin = innerNodes[Math.floor(Math.random() * innerNodes.length)];
                    if (origin) triggerPulseAt(origin.position, 0);
                }
                // High transient: shimmer pulse from a random outer node
                if (sHigh > beatPrevHigh + 0.028 && sHigh > 0.05) {
                    const outerNodes = neuralNetwork.nodes.filter(n => n.level >= 4);
                    if (!outerNodes.length) outerNodes.push(...neuralNetwork.nodes.filter(n => n.level >= 3));
                    const origin = outerNodes[Math.floor(Math.random() * outerNodes.length)];
                    if (origin) triggerPulseAt(origin.position, 2);
                }
                beatPrevBass = sBass;
                beatPrevHigh = sHigh;
            }

            // Starfield: high freq drives twinkle, bass spins faster
            starField.rotation.y += 0.0002 + audio.bass * 0.003;
            starField.material.uniforms.uTime.value    = t;
            starField.material.uniforms.uHighFreq.value = audio.high;

            // Auto-rotate speed gently scales with energy
            controls.autoRotateSpeed = 0.22 + audio.energy * 1.5;

            controls.update();
            composer.render();
        }

        function onWindowResize() {
            camera.aspect = window.innerWidth / window.innerHeight;
            camera.updateProjectionMatrix();
            renderer.setSize(window.innerWidth, window.innerHeight);
            composer.setSize(window.innerWidth, window.innerHeight);
            bloomPass.resolution.set(window.innerWidth, window.innerHeight);
        }
        window.addEventListener('resize', onWindowResize);

        createNetworkVisualization(config.currentFormation, config.densityFactor);
        animate();
    </script>
</body>
</html>