How It Works — The Big Picture

This visualizer combines three systems that work together every frame:

The magic formula: y = sin(time × speed + offset) × height × frequency. When frequency is 0 (silent), all spheres sit at y=0. When frequency approaches 1 (loud), they bounce to their full height. The sine wave creates smooth oscillation, and the unique speed/offset per sphere ensures no two move identically.

Scene Architecture

The scene uses several techniques to look polished:

// Scene with fog for depth
scene = new THREE.Scene();
scene.fog = new THREE.Fog(0x0a0b0f, 20, 50); // color, near, far

// Three-light setup for rich illumination
scene.add(new THREE.AmbientLight(0xffffff, 0.6));       // Soft base
var dirLight = new THREE.DirectionalLight(0xffffff, 1.5); // Shadows
dirLight.position.set(10, 20, 10);
dirLight.castShadow = true;
scene.add(dirLight);
scene.add(new THREE.PointLight(0xffffff, 1.5, 100));    // Specular

Creating 500 Unique Spheres

Each sphere is created in a loop with carefully randomized properties that make the visualization feel organic rather than mechanical:

The userData pattern is key — it lets you store custom data on any Three.js object without subclassing. Each sphere carries its own animation parameters that the render loop reads every frame.

// Each sphere stores unique bounce parameters
mesh.userData = {
    baseHue: hue,                          // For color reactivity
    bounceHeight: 1 + Math.random() * 5,   // How high it bounces (1-6 units)
    bounceSpeed: 2 + Math.random() * 6,    // How fast it oscillates (2-8 Hz)
    bounceOffset: Math.random() * Math.PI * 2  // Phase offset (prevents sync)
};

Three.js Audio System — Deep Dive

Three.js wraps the Web Audio API into a clean interface. Here's the chain:

  1. AudioListener — attached to the camera. This is the "ears" of the scene. Only create ONE per scene.
  2. PositionalAudio — the sound source. Attached to a 3D object (the sphere group). Supports 3D spatialization.
  3. AudioLoader — loads the MP3 file asynchronously and returns a buffer.
  4. AudioAnalyser — wraps the Web Audio API's AnalyserNode. FFT size 128 gives 64 frequency bins updated every frame.

Critical gotcha: The AudioListener must only be added ONCE. If you add it on every play click, you'll stack multiple audio contexts and get distorted, doubled audio. Use a listenerAdded flag to prevent this.

// Only add listener ONCE (prevents audio stacking bug)
if (!listenerAdded) {
    var listener = new THREE.AudioListener();
    camera.add(listener);        // Attach "ears" to camera
    listenerAdded = true;
    sound = new THREE.PositionalAudio(listener);
    sphereGroup.add(sound);      // Sound source in the scene
}

// Load and play
new THREE.AudioLoader().load('music.mp3', function(buffer) {
    sound.setBuffer(buffer);
    sound.setLoop(true);
    sound.setVolume(0.9);
    sound.play();
    // Create analyser AFTER play starts
    analyser = new THREE.AudioAnalyser(sound, 128); // 64 frequency bins
});

The Animation Loop — Frame by Frame

Every frame (~60 times per second), the animation loop does three things:

  1. Read frequency data — get all 64 bins, sum them, normalize to 0-1
  2. Update positions — each sphere's Y = sin(time × speed + offset) × height × freq
  3. Update colors — lightness and emissive glow scale with frequency intensity

The normalization math: each frequency bin is 0-255, there are 64 bins, so max possible sum is 64×255 = 16,320. We divide the actual sum by this max to get a 0-1 value. In practice, music rarely hits all bins at max, so the value typically ranges 0.1-0.7.

function animate() {
    requestAnimationFrame(animate);

    // Step 1: Read audio frequency (0 when silent, ~0.5 during music)
    var freq = 0;
    if (analyser) {
        var data = analyser.getFrequencyData(); // Uint8Array of 64 values (0-255)
        var sum = 0;
        for (var j = 0; j < data.length; j++) sum += data[j];
        freq = Math.min(sum / (data.length * 255), 1); // Normalize to 0-1
    }

    // Step 2: Bounce each sphere based on its unique parameters × audio
    var t = Date.now() * 0.001; // Current time in seconds
    sphereGroup.children.forEach(function(s) {
        // sin() creates smooth oscillation, freq ties it to music
        s.position.y = Math.sin(t * s.userData.bounceSpeed + s.userData.bounceOffset)
                     * s.userData.bounceHeight * freq;

        // Step 3: Colors glow brighter during loud passages
        s.material.color.setHSL(s.userData.baseHue, 1, 0.45 + freq * 0.35);
        s.material.emissive.setHSL(s.userData.baseHue, 1, freq * 0.25);
    });

    controls.update();
    renderer.render(scene, camera);
}

Customization Ideas

Once you understand the pattern, there are endless ways to customize:

Key Takeaways

COMPLETE SOURCE CODE

Copy this code into an HTML file and open it in your browser to see it in action:

HTML / Three.js
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>Interactive Audio Driven Sphere Particles - Three.js</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <style>
        /* Full-screen dark background, no margins */
        body { margin: 0; overflow: hidden; background: #0a0b0f; }

        /* Play/Stop buttons — fixed at top center of screen */
        #startButton, #stopButton {
            position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
            z-index: 100; padding: 14px 32px; background: #2ad1d6; color: #000;
            font-family: Arial, sans-serif; font-size: 16px; font-weight: bold;
            border: none; border-radius: 50px; cursor: pointer;
            box-shadow: 0 0 25px rgba(42,209,214,0.6);
        }
    </style>
</head>
<body>
    <!-- Audio control buttons -->
    <button id="startButton">&#9654; Play Audio</button>
    <button id="stopButton" style="display:none;">&#9632; Stop Audio</button>

    <!-- Load Three.js and OrbitControls from CDN -->
    <script src="https://cdn.jsdelivr.net/npm/three@latest/build/three.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/three@latest/examples/js/controls/OrbitControls.js"></script>

    <script>
        // ============================================================
        // CONFIGURATION
        // Adjust these to change the visualization behavior
        // ============================================================
        var SPHERE_COUNT = 500;  // Number of bouncing spheres (try 100 for mobile, 2000 for desktop)
        var AREA = 14;           // Size of the grid area (spheres spread across AREA x AREA units)

        // ============================================================
        // SCENE SETUP
        // Scene with fog for depth, camera positioned above looking down
        // ============================================================
        var scene = new THREE.Scene();
        scene.fog = new THREE.Fog(0x0a0b0f, 20, 50); // Fog fades distant objects into background

        var camera = new THREE.PerspectiveCamera(75, innerWidth / innerHeight, 0.1, 1000);

        var renderer = new THREE.WebGLRenderer({ antialias: true });
        renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); // Cap at 2x for performance
        renderer.setSize(innerWidth, innerHeight);
        renderer.setClearColor(0x0a0b0f, 1);    // Match fog color for seamless fade
        renderer.shadowMap.enabled = true;       // Enable shadow rendering
        document.body.appendChild(renderer.domElement);

        // ============================================================
        // LIGHTING — Three lights for rich illumination
        // Ambient: soft overall light (no direction, no shadows)
        // Directional: simulates sun, casts shadows from above
        // Point: adds specular highlights on glossy sphere surfaces
        // ============================================================
        scene.add(new THREE.AmbientLight(0xffffff, 0.6));

        var dirLight = new THREE.DirectionalLight(0xffffff, 1.5);
        dirLight.position.set(10, 20, 10);  // Upper-right position
        dirLight.castShadow = true;          // This light creates shadows
        scene.add(dirLight);

        var pointLight = new THREE.PointLight(0xffffff, 1.5, 100);
        pointLight.position.set(8, 20, 10);
        scene.add(pointLight);

        // Semi-transparent grid on the floor (visual ground reference)
        var grid = new THREE.GridHelper(AREA, AREA);
        grid.material.opacity = 0.18;
        grid.material.transparent = true;
        scene.add(grid);

        // ============================================================
        // CREATE 500 SPHERES
        // Each sphere gets:
        //   - Rainbow color (HSL hue evenly distributed)
        //   - Random size (0.1 to 0.25 radius)
        //   - Random position on the grid
        //   - Unique bounce parameters stored in userData
        // ============================================================
        var sphereGroup = new THREE.Group();

        for (var i = 0; i < SPHERE_COUNT; i++) {
            var radius = 0.1 + Math.random() * 0.15;
            var hue = i / SPHERE_COUNT; // 0 to 1 = red through violet

            // MeshPhongMaterial: shiny with specular highlights + emissive glow
            var mat = new THREE.MeshPhongMaterial({
                color: new THREE.Color().setHSL(hue, 1, 0.55),
                emissive: new THREE.Color().setHSL(hue, 1, 0.1), // Subtle inner glow
                shininess: 100  // Very glossy (0=matte, 100=mirror-like)
            });

            var mesh = new THREE.Mesh(new THREE.SphereGeometry(radius, 32, 32), mat);

            // Random position scattered across the grid
            mesh.position.set(
                Math.random() * AREA - AREA / 2,  // X: -7 to +7
                0,                                 // Y: starts on ground
                Math.random() * AREA - AREA / 2   // Z: -7 to +7
            );

            // Store unique animation parameters on this sphere
            mesh.userData = {
                baseHue: hue,                              // For color reactivity
                bounceHeight: 1 + Math.random() * 5,       // Max bounce height (1-6 units)
                bounceSpeed: 2 + Math.random() * 6,        // Oscillation speed (2-8 cycles/sec)
                bounceOffset: Math.random() * Math.PI * 2  // Phase offset (prevents all syncing)
            };

            sphereGroup.add(mesh);
        }
        scene.add(sphereGroup);

        // ============================================================
        // ORBIT CONTROLS — drag to rotate, auto-rotates slowly
        // ============================================================
        var controls = new THREE.OrbitControls(camera, renderer.domElement);
        controls.enableDamping = true;      // Smooth deceleration after drag
        controls.dampingFactor = 0.12;      // How quickly rotation slows
        controls.autoRotate = true;         // Slowly spin when not interacting
        controls.autoRotateSpeed = 0.5;     // Degrees per second

        // Position camera above and angled down at the sphere field
        camera.position.set(0, 5, 10);
        controls.target.set(0, 2, 0);       // Look at center, slightly above ground
        controls.update();

        // ============================================================
        // AUDIO SYSTEM
        // Three.js wraps the Web Audio API into a clean interface:
        //   AudioListener = "ears" (attach to camera, create ONCE)
        //   PositionalAudio = sound source (attach to 3D object)
        //   AudioLoader = loads MP3 files asynchronously
        //   AudioAnalyser = reads frequency data every frame
        // ============================================================
        var analyser = null;   // Will hold the AudioAnalyser after play
        var sound = null;      // Will hold the PositionalAudio object
        var listenerAdded = false; // CRITICAL: only add listener once!

        // PLAY button — loads audio and starts playback
        document.getElementById('startButton').addEventListener('click', function() {
            document.getElementById('startButton').style.display = 'none';
            document.getElementById('stopButton').style.display = 'inline-block';

            // Only create the listener ONCE (prevents audio stacking bug)
            if (!listenerAdded) {
                var listener = new THREE.AudioListener();
                camera.add(listener);        // Attach "ears" to camera
                listenerAdded = true;

                sound = new THREE.PositionalAudio(listener);
                sphereGroup.add(sound);      // Sound source lives in the scene
            }

            // Load the MP3 and start playing
            new THREE.AudioLoader().load(
                'https://www.shanebrumback.com/sounds/threejs-drum-particles-001.mp3',
                function(buffer) {
                    sound.setBuffer(buffer);
                    sound.setLoop(true);     // Loop forever
                    sound.setVolume(0.9);    // 90% volume
                    sound.play();

                    // Create analyser AFTER playback starts
                    // FFT size 128 = 64 frequency bins (good balance of detail vs performance)
                    analyser = new THREE.AudioAnalyser(sound, 128);
                }
            );
        });

        // STOP button — stops audio, resets analyser
        document.getElementById('stopButton').addEventListener('click', function() {
            if (sound) sound.stop();
            analyser = null; // Stops frequency reading, spheres settle to y=0
            document.getElementById('stopButton').style.display = 'none';
            document.getElementById('startButton').style.display = 'inline-block';
        });

        // ============================================================
        // ANIMATION LOOP — runs every frame (~60fps)
        // 1. Read audio frequency data (0 when silent, ~0.5 during music)
        // 2. Bounce each sphere using: sin(time * speed + offset) * height * freq
        // 3. Update colors to glow brighter during loud passages
        // ============================================================
        function animate() {
            requestAnimationFrame(animate);

            // Step 1: Get normalized audio intensity (0 to 1)
            var freq = 0;
            if (analyser) {
                var data = analyser.getFrequencyData(); // Uint8Array, 64 values (0-255 each)
                var sum = 0;
                for (var j = 0; j < data.length; j++) sum += data[j];
                freq = Math.min(sum / (data.length * 255), 1); // Normalize: 0=silent, 1=max
            }

            // Step 2: Update each sphere's position and color
            var t = Date.now() * 0.001; // Current time in seconds (for sine wave)

            sphereGroup.children.forEach(function(s) {
                if (!s.geometry) return; // Skip non-mesh children (like the sound object)

                // Bounce formula: sin creates oscillation, freq ties it to music
                // When freq=0 (silent): position.y = 0 (all spheres on ground)
                // When freq=0.5 (music): spheres bounce to half their max height
                s.position.y = Math.sin(t * s.userData.bounceSpeed + s.userData.bounceOffset)
                             * s.userData.bounceHeight * freq;

                // Step 3: Colors react — brighter during loud passages
                // Lightness goes from 0.45 (dim) to 0.80 (bright) based on freq
                s.material.color.setHSL(s.userData.baseHue, 1, 0.45 + freq * 0.35);

                // Emissive glow intensifies with audio (0 when silent, 0.25 at max)
                s.material.emissive.setHSL(s.userData.baseHue, 1, freq * 0.25);
            });

            controls.update();                  // Update orbit controls (damping + auto-rotate)
            renderer.render(scene, camera);     // Draw the frame
        }
        animate(); // Start the loop!

        // ============================================================
        // WINDOW RESIZE — keep scene responsive
        // ============================================================
        window.addEventListener('resize', function() {
            camera.aspect = innerWidth / innerHeight;
            camera.updateProjectionMatrix();
            renderer.setSize(innerWidth, innerHeight);
        });

    </script>
</body>
</html>

🎓 KNOWLEDGE CHECK

Test your understanding of this tutorial. Score 5/7 or higher to earn the 🎵 Audio Spheres badge!

1. What does THREE.AudioAnalyser return each frame?

2. Why use MeshPhongMaterial instead of MeshBasicMaterial?

3. What happens if you add AudioListener on every play click?

4. What does the bounce formula produce when freq = 0 (silent)?

5. What FFT size gives 64 frequency bins?

6. Why is scene.fog used in this visualization?

7. What does the userData pattern let you do?