This visualizer combines three systems that work together every frame:
userDataThe 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.
The scene uses several techniques to look polished:
THREE.Fog(0x0a0b0f, 20, 50) fades distant spheres into the background, adding depthrenderer.shadowMap.enabled = true with PCFSoftShadowMap for soft shadow edges// 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
Each sphere is created in a loop with carefully randomized properties that make the visualization feel organic rather than mechanical:
hue = i / SPHERE_COUNT creates a full spectrum from red through violetThe 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 wraps the Web Audio API into a clean interface. Here's the chain:
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
});
Every frame (~60 times per second), the animation loop does three things:
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);
}
Once you understand the pattern, there are endless ways to customize:
mesh.scale.setScalar(1 + freq * 2) instead of position.y for a pulsing effectmesh.rotation.y += freq * 0.1 for spinning spheres during loud passagesgetFrequencyData() every frame.Copy this code into an HTML file and open it in your browser to see it in action:
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?