Skip to main content

Animate 3D buildings based on ambient sounds

This example uses runtime styling with the Web Audio API to create a map where the 3D buildings dynamically change height to the rhythm of your ambient environment, giving the appearance of dancing.

It uses getUserMedia to access audio from the user's environment, then analyzes that sound for loudness and uses the loudness levels to adjust the fill-extrusion-height property of the fill-extrusion layer.

To optimize performance, it uses the ==, > and <= expression operators to filter buildings in the layer based on their height data.

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Animate 3D buildings based on ambient sounds</title>
<meta name="viewport" content="initial-scale=1,maximum-scale=1,user-scalable=no">
<link href="https://api.mapbox.com/mapbox-gl-js/v3.8.0/mapbox-gl.css" rel="stylesheet">
<script src="https://api.mapbox.com/mapbox-gl-js/v3.8.0/mapbox-gl.js"></script>
<style>
body { margin: 0; padding: 0; }
#map { position: absolute; top: 0; bottom: 0; width: 100%; }
</style>
</head>
<body>
<div id="map"></div>

<script>
// TO MAKE THE MAP APPEAR YOU MUST
// ADD YOUR ACCESS TOKEN FROM
// https://account.mapbox.com
mapboxgl.accessToken = 'YOUR_MAPBOX_ACCESS_TOKEN';
// Use a minimal variant of the Mapbox Dark style, with certain features removed.
const map = new mapboxgl.Map({
// Choose from Mapbox's core styles, or make your own style with Mapbox Studio
style: 'mapbox://styles/examples/cj68bstx01a3r2rndlud0pwpv',
center: {
lng: -74.0064,
lat: 40.7081
},
zoom: 15,
pitch: 55,
container: 'map',
antialias: true
});

map.addControl(new mapboxgl.FullscreenControl());

map.on('load', () => {
const bins = 16;
const maxHeight = 200;
const binWidth = maxHeight / bins;

// Divide the buildings into 16 bins based on their true height, using a layer filter.
for (let i = 0; i < bins; i++) {
map.addLayer({
'id': `3d-buildings-${i}`,
'source': 'composite',
'source-layer': 'building',
'filter': [
'all',
['==', 'extrude', 'true'],
['>', 'height', i * binWidth],
['<=', 'height', (i + 1) * binWidth]
],
'type': 'fill-extrusion',
'minzoom': 15,
'paint': {
'fill-extrusion-color': '#aaa',
'fill-extrusion-height-transition': {
duration: 0,
delay: 0
},
'fill-extrusion-opacity': 0.6
}
});
}

// Older browsers might not implement mediaDevices at all, so we set an empty object first
if (navigator.mediaDevices === undefined) {
navigator.mediaDevices = {};
}

// Some browsers partially implement mediaDevices. We can't just assign an object
// with getUserMedia as it would overwrite existing properties.
// Here, we will just add the getUserMedia property if it's missing.
if (navigator.mediaDevices.getUserMedia === undefined) {
navigator.mediaDevices.getUserMedia = (constraints) => {
// First get ahold of the legacy getUserMedia, if present
const getUserMedia =
navigator.webkitGetUserMedia || navigator.mozGetUserMedia;

// Some browsers just don't implement it - return a rejected promise with an error
// to keep a consistent interface
if (!getUserMedia) {
return Promise.reject(
new Error(
'getUserMedia is not implemented in this browser'
)
);
}

// Otherwise, wrap the call to the old navigator.getUserMedia with a Promise
return new Promise((resolve, reject) => {
getUserMedia.call(navigator, constraints, resolve, reject);
});
};
}

navigator.mediaDevices
.getUserMedia({ audio: true })
.then((stream) => {
// Set up a Web Audio AudioContext and AnalyzerNode, configured to return the
// same number of bins of audio frequency data.
const audioCtx = new (window.AudioContext ||
window.webkitAudioContext)();

const analyser = audioCtx.createAnalyser();
analyser.minDecibels = -90;
analyser.maxDecibels = -10;
analyser.smoothingTimeConstant = 0.85;

const source = audioCtx.createMediaStreamSource(stream);
source.connect(analyser);

analyser.fftSize = bins * 2;

const dataArray = new Uint8Array(bins);

function draw(now) {
analyser.getByteFrequencyData(dataArray);

// Use that data to drive updates to the fill-extrusion-height property.
let avg = 0;
for (let i = 0; i < bins; i++) {
avg += dataArray[i];
map.setPaintProperty(
`3d-buildings-${i}`,
'fill-extrusion-height',
10 + 4 * i + dataArray[i]
);
}
avg /= bins;

// Animate the map bearing and light color over time, and make the light more
// intense when the audio is louder.
map.setBearing(now / 500);
const hue = (now / 100) % 360;
const saturation = Math.min(50 + avg / 4, 100);
map.setLight({
color: `hsl(${hue},${saturation}%,50%)`,
intensity: Math.min(1, (avg / 256) * 10)
});

requestAnimationFrame(draw);
}

requestAnimationFrame(draw);
})
.catch((err) => {
console.log('The following gUM error occurred:', err);
});
});
</script>

</body>
</html>
This code snippet will not work as expected until you replace YOUR_MAPBOX_ACCESS_TOKEN with an access token from your Mapbox account.
Was this example helpful?