0
点赞
收藏
分享

微信扫一扫

用 Three.js 和 AudioContext 实现音乐频谱的 3D 可视化

最近听了一首很好听的歌《一路生花》,于是就想用 Three.js 做个音乐频谱的可视化,最终效果是这样的:

用 Three.js 和 AudioContext 实现音乐频谱的 3D 可视化_数据

代码地址在这里:​​github.com/QuarkGluonP…​​

这个效果的实现能学到两方面的内容:

  • AudioContext 对音频解码和各种处理
  • Three.js 的 3d 场景绘制

那还等什么,我们开始吧。

思路分析

要做音乐频谱可视化,首先要获取频谱数据,这个用 AudioContext 的 api。

AudioContext 的 api 可以对音频解码并对它做一系列处理,每一个处理步骤叫做一个 Node。

我们这里需要解码之后用 analyser 来拿到频谱数据,然后传递给 audioContext 做播放。所以有三个处理节点: Source、Analyser、Destination

context audioCtx = new AudioContext();

const source = audioCtx.createBufferSource();
const analyser = audioCtx.createAnalyser();

audioCtx.decodeAudioData(音频二进制数据, function(decodedData) {
source.buffer = decodedData;
source.connect(analyser);
analyser.connect(audioCtx.destination);
});
复制代码

先对音频解码,创建 BufferSource 的节点来保存解码后的数据,然后传入 Analyser 获取频谱数据,最后传递给 Destination 来播放。

调用 source.start() 开始传递音频数据,这样 analyser 就能够拿到音乐频谱的数据了,Destination 也能正常的播放。

analyser 拿到音频频谱数据的 api 是这样的:

const frequencyData = new Uint8Array(analyser.frequencyBinCount);
analyser.getByteFrequencyData(frequencyData);

每一次能拿到的 frequencyData 有 1024 个元素,可以按 50 个分为一份,算下平均值,这样只会有 1024/50 = 21 个频谱单元数据。

之后就可以用 Three.js 把这些频谱数据画出来了。

21 个数值,可以绘制成 21 个 立方体 BoxGeometry,材质的话,用 MeshPhongMaterial(因为这个反光的计算方式是一个姓冯的人提出来的,所以叫 Phong),它的特点是表面可以反光,如果用 MeshBasicMaterial,是不反光的。

之后加入花瓣雨效果,这个我们之前实现过,就是用 Sprite (永远面向相机的一个平面)做贴图,然后一帧帧做位置的改变。

​​通过“漫天花雨”来入门 Three.js​​

之后分别设置灯光、相机就可以了:

灯光我们用点光源 PointLight,从一个位置去照射,配合 Phong 的材质可以做到反光的效果。

相机用透视相机 PerspectiveCamera,它的特点是从一个点去看,会有近大远小的效果,比较有空间感。而正交相机 OrthographicCamera 因为是平行投影,就没有近大远小的效果,不管距离多远的物体都是一样大。

之后通过 Renderer 渲染出来,然后用 requestAnimationFrame 来一帧帧的刷新就可以了。

接下来我们具体写下代码:

代码实现

我们先通过 fetch 拿到服务器上的音频文件,转成 ArrayBuffer。

ArrayBuffer 是 JS 语言提供的用于存储二进制数据的 api,和它类似的还有 Blob 和 Buffer,区别如下:

  • ArrayBuffer 是 JS 语言本身提供的用于存储二进制数据的通用 API
  • Blob 是浏览器提供的 API,用于文件处理
  • Buffer 是 Node.js 提供的 API,用于 IO 操作

这里,我们毫无疑问要用 ArrayBuffer 来存储音频的二进制数据。

fetch('./music/一路生花.mp3')
.then(function(response) {
if (!response.ok) {
throw new Error("HTTP error, status = " + response.status);
}
return response.arrayBuffer();
})
.then(function(arrayBuffer) {
});

然后用 AudioContext 的 api 做解码和后续处理,分为 Source、Analyser、Destination 3个处理节点:

let audioCtx = new AudioContext();
let source, analyser;

function getData() {
source = audioCtx.createBufferSource();
analyser = audioCtx.createAnalyser();

return fetch('./music/一路生花.mp3')
.then(function(response) {
if (!response.ok) {
throw new Error("HTTP error, status = " + response.status);
}
return response.arrayBuffer();
})
.then(function(arrayBuffer) {
audioCtx.decodeAudioData(arrayBuffer, function(decodedData) {
source.buffer = decodedData;
source.connect(analyser);
analyser.connect(audioCtx.destination);
});
});
};

获取音频,用 AudioContext 处理之后,并不能直接播放,因为浏览器做了限制。必须得用户主动做了一些操作之后,才能播放音频。

为了绕过这个限制,我们监听 mousedown 事件,用户点击之后,就可以播放了。

function triggerHandler() {
getData().then(function() {
source.start(0); // 从 0 的位置开始播放

create(); // 创建 Three.js 的各种物体
render(); // 渲染
});
document.removeEventListener('mousedown', triggerHandler)
}
document.addEventListener('mousedown', triggerHandler);

之后可以创建 3D 场景中的各种物体:

创建立方体:

因为频谱为 1024 个数据,我们 50个分为一组,就只需要渲染 21 个立方体:

const cubes = new THREE.Group();

const STEP = 50;
const CUBE_NUM = Math.ceil(1024 / STEP);

for (let i = 0; i < CUBE_NUM; i ++ ) {
const geometry = new THREE.BoxGeometry( 10, 10, 10 );
const material = new THREE.MeshPhongMaterial({color: 'yellowgreen'});
const cube = new THREE.Mesh( geometry, material );

cube.translateX((10 + 10) * i);

cubes.add(cube);
}
cubes.translateX(- (10 +10) * CUBE_NUM / 2);

scene.add(cubes);

立方体的物体 Mesh,分别设置几何体是 BoxGeometry,长宽高都是 10 ,材质是 MeshPhongMaterial,颜色是黄绿色。

每个立方体要做下 x 轴的位移,最后整体的分组再做下位移,移动整体宽度的一半,达到居中的目的。

频谱就可以通过这些立方体来做可视化。

之后是花瓣,用 Sprite 创建,因为 Sprite 是永远面向相机的平面。贴上随机的纹理贴图,设置随机的位置。

const FLOWER_NUM = 400;
/**
* 花瓣分组
*/
const petal = new THREE.Group();

var flowerTexture1 = new THREE.TextureLoader().load("img/flower1.png");
var flowerTexture2 = new THREE.TextureLoader().load("img/flower2.png");
var flowerTexture3 = new THREE.TextureLoader().load("img/flower3.png");
var flowerTexture4 = new THREE.TextureLoader().load("img/flower4.png");
var flowerTexture5 = new THREE.TextureLoader().load("img/flower5.png");
var imageList = [flowerTexture1, flowerTexture2, flowerTexture3, flowerTexture4, flowerTexture5];

for (let i = 0; i < FLOWER_NUM; i++) {
var spriteMaterial = new THREE.SpriteMaterial({
map: imageList[Math.floor(Math.random() * imageList.length)],
});
var sprite = new THREE.Sprite(spriteMaterial);
petal.add(sprite);

sprite.scale.set(40, 50, 1);
sprite.position.set(2000 * (Math.random() - 0.5), 500 * Math.random(), 2000 * (Math.random() - 0.5))
}

scene.add(petal);

分别把频谱的立方体和一堆花瓣加到场景中之后,就完成了物体的创建。

然后设置下相机,我们是使用透视相机,要分别指定视角的角度,最近和最远的距离,还有视区的宽高比。

用 Three.js 和 AudioContext 实现音乐频谱的 3D 可视化_three.js_02

const width = window.innerWidth;
const height = window.innerHeight;

const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000);
camera.position.set(0,300, 400);
camera.lookAt(scene.position);

之后设置下灯光,用点光源:

const pointLight = new THREE.PointLight( 0xffffff );
pointLight.position.set(0, 300, 40);
scene.add(pointLight);

然后就可以用 renderer 来做渲染了,结合 requestAnimationFrame 做一帧帧的渲染。

const renderer = new THREE.WebGLRenderer();

function render() {
renderer.render(scene, camera);
requestAnimationFrame(render);
}
render();

在渲染的时候,每帧都要计算花瓣的位置,和频谱立方体的高度。

花瓣的位置就是不断下降,到了一定的高度就回到上面:

petal.children.forEach(sprite => {
sprite.position.y -= 5;
sprite.position.x += 0.5;
if (sprite.position.y < - height / 2) {
sprite.position.y = height / 2;
}
if (sprite.position.x > 1000) {
sprite.position.x = -1000;
}
});

频谱立方体的话,要用 analyser 获取最新频谱数据,计算每个分组的平均值,然后设置到立方体的 scaleY 上。

// 获取频谱数据
const frequencyData = new Uint8Array(analyser.frequencyBinCount);
analyser.getByteFrequencyData(frequencyData);

// 计算每个分组的平均频谱数据
const averageFrequencyData = [];
for (let i = 0; i< frequencyData.length; i += STEP) {
let sum = 0;
for(let j = i; j < i + STEP; j++) {
sum += frequencyData[j];
}
averageFrequencyData.push(sum / STEP);
}
// 设置立方体的 scaleY
for (let i = 0; i < averageFrequencyData.length; i++) {
cubes.children[i].scale.y = Math.floor(averageFrequencyData[i] * 0.4);
}

还可以做下场景围绕 X 轴的渲染,每帧转一定的角度。

scene.rotateX(0.005);

最后,加入轨道控制器就可以了,它的作用是可以用鼠标来调整相机的位置,调整看到的东西的远近、角度等。

const controls = new THREE.OrbitControls(camera);

最终效果就是这样的:花瓣纷飞,频谱立方体随音乐跳动。

用 Three.js 和 AudioContext 实现音乐频谱的 3D 可视化_前端_03

完整代码提交到了 github:

​​github.com/QuarkGluonP…​​

也在这里贴一份:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>音乐频谱可视化</title>
<style>
body {
margin: 0;
overflow: hidden;
}
</style>
<script src="./js/three.js"></script>
<script src="./js/OrbitControls.js"></script>
</head>
<body>
<script>
let audioCtx = new AudioContext();
let source, analyser;

function getData() {
source = audioCtx.createBufferSource();
analyser = audioCtx.createAnalyser();

return fetch('./music/一路生花.mp3')
.then(function(response) {
if (!response.ok) {
throw new Error("HTTP error, status = " + response.status);
}
return response.arrayBuffer();
})
.then(function(arrayBuffer) {
audioCtx.decodeAudioData(arrayBuffer, function(decodedData) {
source.buffer = decodedData;
source.connect(analyser);
analyser.connect(audioCtx.destination);
});
});
};

function triggerHandler() {
getData().then(function() {
source.start(0);
create();
render();
});
document.removeEventListener('mousedown', triggerHandler)
}
document.addEventListener('mousedown', triggerHandler);

const STEP = 50;
const CUBE_NUM = Math.ceil(1024 / STEP);
const FLOWER_NUM = 400;

const width = window.innerWidth;
const height = window.innerHeight;

const scene = new THREE.Scene();

const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000);

const renderer = new THREE.WebGLRenderer();
/**
* 花瓣分组
*/
const petal = new THREE.Group();

/**
* 频谱立方体
*/
const cubes = new THREE.Group();

function create() {
const pointLight = new THREE.PointLight( 0xffffff );
pointLight.position.set(0, 300, 40);
scene.add(pointLight);


camera.position.set(0,300, 400);
camera.lookAt(scene.position);

renderer.setSize(width, height);
document.body.appendChild(renderer.domElement)

renderer.render(scene, camera)

for (let i = 0; i < CUBE_NUM; i ++ ) {
const geometry = new THREE.BoxGeometry( 10, 10, 10 );
const material = new THREE.MeshPhongMaterial({color: 'yellowgreen'});
const cube = new THREE.Mesh( geometry, material );
cube.translateX((10 + 10) * i);
cube.translateY(1);

cubes.add(cube);
}
cubes.translateX(- (10 +10) * CUBE_NUM / 2);


var flowerTexture1 = new THREE.TextureLoader().load("img/flower1.png");
var flowerTexture2 = new THREE.TextureLoader().load("img/flower2.png");
var flowerTexture3 = new THREE.TextureLoader().load("img/flower3.png");
var flowerTexture4 = new THREE.TextureLoader().load("img/flower4.png");
var flowerTexture5 = new THREE.TextureLoader().load("img/flower5.png");
var imageList = [flowerTexture1, flowerTexture2, flowerTexture3, flowerTexture4, flowerTexture5];

for (let i = 0; i < FLOWER_NUM; i++) {
var spriteMaterial = new THREE.SpriteMaterial({
map: imageList[Math.floor(Math.random() * imageList.length)],
});
var sprite = new THREE.Sprite(spriteMaterial);
petal.add(sprite);

sprite.scale.set(40, 50, 1);
sprite.position.set(2000 * (Math.random() - 0.5), 500 * Math.random(), 2000 * (Math.random() - 0.5))
}

scene.add(cubes);
scene.add(petal);
}

function render() {
petal.children.forEach(sprite => {
sprite.position.y -= 5;
sprite.position.x += 0.5;
if (sprite.position.y < - height / 2) {
sprite.position.y = height / 2;
}
if (sprite.position.x > 1000) {
sprite.position.x = -1000;
}
});

const frequencyData = new Uint8Array(analyser.frequencyBinCount);
analyser.getByteFrequencyData(frequencyData);

const averageFrequencyData = [];
for (let i = 0; i< frequencyData.length; i += STEP) {
let sum = 0;
for(let j = i; j < i + STEP; j++) {
sum += frequencyData[j];
}
averageFrequencyData.push(sum / STEP);
}
for (let i = 0; i < averageFrequencyData.length; i++) {
cubes.children[i].scale.y = Math.floor(averageFrequencyData[i] * 0.4);
}

scene.rotateX(0.005);
renderer.render(scene, camera);

requestAnimationFrame(render);
}

const controls = new THREE.OrbitControls(camera);

</script>
</body>
</html>
<!DOCTYPE html><html lang="en"><head>    <meta charset="UTF-8">    <title>音乐频谱可视化</title>    <style>        body {            margin: 0;            overflow: hidden;        }    </style>    <script src="./js/three.js"></script>    <script src="./js/OrbitControls.js"></script></head><body><script>    let audioCtx = new AudioContext();    let source, analyser;    function getData() {        source = audioCtx.createBufferSource();        analyser = audioCtx.createAnalyser();        return fetch('./music/一路生花.mp3')            .then(function(response) {                if (!response.ok) {                    throw new Error("HTTP error, status = " + response.status);                }                return response.arrayBuffer();            })            .then(function(arrayBuffer) {                audioCtx.decodeAudioData(arrayBuffer, function(decodedData) {                    source.buffer = decodedData;                    source.connect(analyser);                    analyser.connect(audioCtx.destination);                });            });    };    function triggerHandler() {        getData().then(function() {            source.start(0);            create();            render();        });        document.removeEventListener('mousedown', triggerHandler)    }    document.addEventListener('mousedown', triggerHandler);    const STEP = 50;    const CUBE_NUM = Math.ceil(1024 / STEP);    const FLOWER_NUM = 400;    const width = window.innerWidth;    const height = window.innerHeight;    const scene = new THREE.Scene();    const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000);    const renderer = new THREE.WebGLRenderer();    /**     * 花瓣分组     */    const petal = new THREE.Group();    /**     * 频谱立方体     */    const cubes = new THREE.Group();    function create() {        const pointLight = new THREE.PointLight( 0xffffff );        pointLight.position.set(0, 300, 40);        scene.add(pointLight);        camera.position.set(0,300, 400);        camera.lookAt(scene.position);        renderer.setSize(width, height);        document.body.appendChild(renderer.domElement)        renderer.render(scene, camera)        for (let i = 0; i < CUBE_NUM; i ++ ) {            const geometry = new THREE.BoxGeometry( 10, 10, 10 );            const material = new THREE.MeshPhongMaterial({color: 'yellowgreen'});            const cube = new THREE.Mesh( geometry, material );            cube.translateX((10 + 10) * i);            cube.translateY(1);            cubes.add(cube);        }        cubes.translateX(- (10 +10) * CUBE_NUM / 2);        var flowerTexture1 = new THREE.TextureLoader().load("img/flower1.png");        var flowerTexture2 = new THREE.TextureLoader().load("img/flower2.png");        var flowerTexture3 = new THREE.TextureLoader().load("img/flower3.png");        var flowerTexture4 = new THREE.TextureLoader().load("img/flower4.png");        var flowerTexture5 = new THREE.TextureLoader().load("img/flower5.png");        var imageList = [flowerTexture1, flowerTexture2, flowerTexture3, flowerTexture4, flowerTexture5];        for (let i = 0; i < FLOWER_NUM; i++) {            var spriteMaterial = new THREE.SpriteMaterial({                map: imageList[Math.floor(Math.random() * imageList.length)],            });            var sprite = new THREE.Sprite(spriteMaterial);            petal.add(sprite);            sprite.scale.set(40, 50, 1);            sprite.position.set(2000 * (Math.random() - 0.5), 500 * Math.random(), 2000 * (Math.random() - 0.5))        }        scene.add(cubes);        scene.add(petal);    }    function render() {        petal.children.forEach(sprite => {            sprite.position.y -= 5;            sprite.position.x += 0.5;            if (sprite.position.y < - height / 2) {                sprite.position.y = height / 2;            }            if (sprite.position.x > 1000) {                sprite.position.x = -1000;            }        });        const frequencyData = new Uint8Array(analyser.frequencyBinCount);        analyser.getByteFrequencyData(frequencyData);        const averageFrequencyData = [];        for (let i = 0; i< frequencyData.length; i += STEP) {            let sum = 0;            for(let j = i; j < i + STEP; j++) {                sum += frequencyData[j];            }            averageFrequencyData.push(sum / STEP);        }        for (let i = 0; i < averageFrequencyData.length; i++) {            cubes.children[i].scale.y = Math.floor(averageFrequencyData[i] * 0.4);        }        scene.rotateX(0.005);        renderer.render(scene, camera);        requestAnimationFrame(render);    }    const controls = new THREE.OrbitControls(camera);</script></body></html>复制代码

总结

本文我们学习了如何做音频的频谱可视化。

首先,通过 fetch 获取音频数据,用 ArrayBuffer 来保存,它是 JS 的标准的存储二进制数据的 api。其他的类似的 api 有 Blob 和 Buffer。Blob 是 浏览器里的保存文件二进制数据的 API,Buffer 是 Node.js 里的用于保存 IO 数据 api,。

然后使用 AudioContext 的 api 来获取频谱数据和播放音频,它是由一系列 Node 组成的,我们这里通过 Source 保存音频数据,然后传递给 Analyser 获取频谱数据,最后传入 Destination。

之后是 3D 场景的绘制,分别绘制了频谱立方体和花瓣雨,用 Mesh 和 Sprite 两种物体,Mesh 是一中由几何体和材质构成的物体,这里使用 BoxGeometry 和 MeshPhongMaterial(可反光)。Sprite 是永远面向相机的平面,用来展示花瓣。

然后设置了点光源,配合 Phong 的材质能达到反光效果。

使用了透视相机,可以做到近大远小的 3D 透视效果,而正交相机就做不到这种效果,它是平面投影,多远都一样大小。

然后在每帧的渲染中,改变花瓣的位置和获取频谱数据改变立方体的 scaleY 就可以了。

本文我们既学了 AudioContext 获取音频频谱数据,又学了用 Three.js 做 3D 的绘制,数据和绘制的结合,这就是可视化做的事情:通过一种合适的显示方式,更好的展示数据。

可视化是 Three.js 的一个应用场景,还有游戏也是一个应用场景,后面我们都会做一些探索。

举报

相关推荐

0 条评论