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

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

[[437041]]

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

图片

代码地址在这里:https://‍github.com/Quark‍GluonPlasma/threejs-exercize

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

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

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

思路分析

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

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

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

  1. context audioCtx = new AudioContext(); 
  2.  
  3. const source = audioCtx.createBufferSource(); 
  4. const analyser = audioCtx.createAnalyser(); 
  5.  
  6. audioCtx.decodeAudioData(音频二进制数据, function(decodedData) { 
  7.     source.buffer = decodedData; 
  8.     source.connect(analyser); 
  9.     analyser.connect(audioCtx.destination); 
  10. }); 

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

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

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

  1. const frequencyData = new Uint8Array(analyser.frequencyBinCount); 
  2. 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 来存储音频的二进制数据。

  1. fetch('./music/一路生花.mp3'
  2. .then(function(response) { 
  3.     if (!response.ok) { 
  4.         throw new Error("HTTP error, status = " + response.status); 
  5.     } 
  6.     return response.arrayBuffer(); 
  7. }) 
  8. .then(function(arrayBuffer) { 
  9. }); 

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

  1. let audioCtx = new AudioContext(); 
  2. let source, analyser; 
  3.  
  4. function getData() { 
  5.     source = audioCtx.createBufferSource(); 
  6.     analyser = audioCtx.createAnalyser(); 
  7.  
  8.     return fetch('./music/一路生花.mp3'
  9.         .then(function(response) { 
  10.             if (!response.ok) { 
  11.                 throw new Error("HTTP error, status = " + response.status); 
  12.             } 
  13.             return response.arrayBuffer(); 
  14.         }) 
  15.         .then(function(arrayBuffer) { 
  16.             audioCtx.decodeAudioData(arrayBuffer, function(decodedData) { 
  17.                 source.buffer = decodedData; 
  18.                 source.connect(analyser); 
  19.                 analyser.connect(audioCtx.destination); 
  20.             }); 
  21.         }); 
  22. }; 

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

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

  1. function triggerHandler() { 
  2.     getData().then(function() { 
  3.         source.start(0); // 从 0 的位置开始播放 
  4.  
  5.         create();  // 创建 Three.js 的各种物体 
  6.         render(); // 渲染 
  7.     }); 
  8.     document.removeEventListener('mousedown', triggerHandler) 
  9. document.addEventListener('mousedown', triggerHandler); 

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

创建立方体:

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

  1. const cubes = new THREE.Group(); 
  2.  
  3. const STEP = 50; 
  4. const CUBE_NUM = Math.ceil(1024 / STEP); 
  5.  
  6. for (let i = 0; i < CUBE_NUM; i ++ ) { 
  7.     const geometry = new THREE.BoxGeometry( 10, 10, 10 ); 
  8.     const material = new THREE.MeshPhongMaterial({color: 'yellowgreen'}); 
  9.     const cube = new THREE.Mesh( geometry, material ); 
  10.  
  11.     cube.translateX((10 + 10) * i); 
  12.  
  13.     cubes.add(cube); 
  14. cubes.translateX(- (10 +10) * CUBE_NUM / 2); 
  15.  
  16. scene.add(cubes); 

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

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

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

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

  1. const FLOWER_NUM = 400; 
  2. /** 
  3.  * 花瓣分组 
  4.  */ 
  5. const petal = new THREE.Group(); 
  6.  
  7. var flowerTexture1 = new THREE.TextureLoader().load("img/flower1.png"); 
  8. var flowerTexture2 = new THREE.TextureLoader().load("img/flower2.png"); 
  9. var flowerTexture3 = new THREE.TextureLoader().load("img/flower3.png"); 
  10. var flowerTexture4 = new THREE.TextureLoader().load("img/flower4.png"); 
  11. var flowerTexture5 = new THREE.TextureLoader().load("img/flower5.png"); 
  12. var imageList = [flowerTexture1, flowerTexture2, flowerTexture3, flowerTexture4, flowerTexture5]; 
  13.  
  14. for (let i = 0; i < FLOWER_NUM; i++) { 
  15.     var spriteMaterial = new THREE.SpriteMaterial({ 
  16.         map: imageList[Math.floor(Math.random() * imageList.length)], 
  17.     }); 
  18.     var sprite = new THREE.Sprite(spriteMaterial); 
  19.     petal.add(sprite); 
  20.  
  21.     sprite.scale.set(40, 50, 1);  
  22.     sprite.position.set(2000 * (Math.random() - 0.5), 500 * Math.random(), 2000 * (Math.random() - 0.5)) 
  23.  
  24. scene.add(petal); 

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

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

  1. const width = window.innerWidth; 
  2. const height = window.innerHeight; 
  3.  
  4. const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000); 
  5. camera.position.set(0,300, 400); 
  6. camera.lookAt(scene.position); 

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

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

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

  1. const renderer = new THREE.WebGLRenderer(); 
  2.  
  3. function render() { 
  4.     renderer.render(scene, camera); 
  5.     requestAnimationFrame(render); 
  6. render(); 

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

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

  1. petal.children.forEach(sprite => { 
  2.    sprite.position.y -= 5; 
  3.    sprite.position.x += 0.5; 
  4.    if (sprite.position.y < - height / 2) { 
  5.        sprite.position.y = height / 2; 
  6.    } 
  7.    if (sprite.position.x > 1000) { 
  8.        sprite.position.x = -1000; 
  9.    } 
  10. ); 

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

  1. // 获取频谱数据 
  2. const frequencyData = new Uint8Array(analyser.frequencyBinCount); 
  3. analyser.getByteFrequencyData(frequencyData); 
  4.  
  5. // 计算每个分组的平均频谱数据 
  6. const averageFrequencyData = []; 
  7. for (let i = 0; i< frequencyData.length; i += STEP) { 
  8.     let sum = 0; 
  9.     for(let j = i; j < i + STEP; j++) { 
  10.         sum += frequencyData[j]; 
  11.     } 
  12.     averageFrequencyData.push(sum / STEP); 
  13. // 设置立方体的 scaleY 
  14. for (let i = 0; i < averageFrequencyData.length; i++) { 
  15.     cubes.children[i].scale.y = Math.floor(averageFrequencyData[i] * 0.4); 

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

  1. scene.rotateX(0.005); 

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

  1. const controls = new THREE.OrbitControls(camera); 

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

[[437042]]

完整代码提交到了 github:

https://github.com/QuarkGluonPlasm‍a/threejs-exercize

也在这里贴一份:

  1. <!DOCTYPE html> 
  2. <html lang="en"
  3. <head> 
  4.     <meta charset="UTF-8"
  5.     <title>音乐频谱可视化</title> 
  6.     <style> 
  7.         body { 
  8.             margin: 0; 
  9.             overflow: hidden; 
  10.         } 
  11.     </style> 
  12.     <script src="./js/three.js"></script> 
  13.     <script src="./js/OrbitControls.js"></script> 
  14. </head> 
  15. <body> 
  16. <script> 
  17.     let audioCtx = new AudioContext(); 
  18.     let source, analyser; 
  19.  
  20.     function getData() { 
  21.         source = audioCtx.createBufferSource(); 
  22.         analyser = audioCtx.createAnalyser(); 
  23.  
  24.         return fetch('./music/一路生花.mp3'
  25.             .then(function(response) { 
  26.                 if (!response.ok) { 
  27.                     throw new Error("HTTP error, status = " + response.status); 
  28.                 } 
  29.                 return response.arrayBuffer(); 
  30.             }) 
  31.             .then(function(arrayBuffer) { 
  32.                 audioCtx.decodeAudioData(arrayBuffer, function(decodedData) { 
  33.                     source.buffer = decodedData; 
  34.                     source.connect(analyser); 
  35.                     analyser.connect(audioCtx.destination); 
  36.                 }); 
  37.             }); 
  38.     }; 
  39.  
  40.     function triggerHandler() { 
  41.         getData().then(function() { 
  42.             source.start(0); 
  43.             create(); 
  44.             render(); 
  45.         }); 
  46.         document.removeEventListener('mousedown', triggerHandler) 
  47.     } 
  48.     document.addEventListener('mousedown', triggerHandler); 
  49.  
  50.     const STEP = 50; 
  51.     const CUBE_NUM = Math.ceil(1024 / STEP); 
  52.     const FLOWER_NUM = 400; 
  53.  
  54.     const width = window.innerWidth; 
  55.     const height = window.innerHeight; 
  56.  
  57.     const scene = new THREE.Scene(); 
  58.  
  59.     const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000); 
  60.  
  61.     const renderer = new THREE.WebGLRenderer(); 
  62.     /** 
  63.      * 花瓣分组 
  64.      */ 
  65.     const petal = new THREE.Group(); 
  66.  
  67.     /** 
  68.      * 频谱立方体 
  69.      */ 
  70.     const cubes = new THREE.Group(); 
  71.  
  72.     function create() { 
  73.         const pointLight = new THREE.PointLight( 0xffffff ); 
  74.         pointLight.position.set(0, 300, 40); 
  75.         scene.add(pointLight); 
  76.  
  77.  
  78.         camera.position.set(0,300, 400); 
  79.         camera.lookAt(scene.position); 
  80.  
  81.         renderer.setSize(width, height); 
  82.         document.body.appendChild(renderer.domElement) 
  83.  
  84.         renderer.render(scene, camera) 
  85.  
  86.         for (let i = 0; i < CUBE_NUM; i ++ ) { 
  87.             const geometry = new THREE.BoxGeometry( 10, 10, 10 ); 
  88.             const material = new THREE.MeshPhongMaterial({color: 'yellowgreen'}); 
  89.             const cube = new THREE.Mesh( geometry, material ); 
  90.             cube.translateX((10 + 10) * i); 
  91.             cube.translateY(1); 
  92.  
  93.             cubes.add(cube); 
  94.         } 
  95.         cubes.translateX(- (10 +10) * CUBE_NUM / 2); 
  96.  
  97.  
  98.         var flowerTexture1 = new THREE.TextureLoader().load("img/flower1.png"); 
  99.         var flowerTexture2 = new THREE.TextureLoader().load("img/flower2.png"); 
  100.         var flowerTexture3 = new THREE.TextureLoader().load("img/flower3.png"); 
  101.         var flowerTexture4 = new THREE.TextureLoader().load("img/flower4.png"); 
  102.         var flowerTexture5 = new THREE.TextureLoader().load("img/flower5.png"); 
  103.         var imageList = [flowerTexture1, flowerTexture2, flowerTexture3, flowerTexture4, flowerTexture5]; 
  104.  
  105.         for (let i = 0; i < FLOWER_NUM; i++) { 
  106.             var spriteMaterial = new THREE.SpriteMaterial({ 
  107.                 map: imageList[Math.floor(Math.random() * imageList.length)], 
  108.             }); 
  109.             var sprite = new THREE.Sprite(spriteMaterial); 
  110.             petal.add(sprite); 
  111.  
  112.             sprite.scale.set(40, 50, 1);  
  113.             sprite.position.set(2000 * (Math.random() - 0.5), 500 * Math.random(), 2000 * (Math.random() - 0.5)) 
  114.         } 
  115.  
  116.         scene.add(cubes); 
  117.         scene.add(petal); 
  118.     } 
  119.  
  120.     function render() { 
  121.         petal.children.forEach(sprite => { 
  122.             sprite.position.y -= 5; 
  123.             sprite.position.x += 0.5; 
  124.             if (sprite.position.y < - height / 2) { 
  125.                 sprite.position.y = height / 2; 
  126.             } 
  127.             if (sprite.position.x > 1000) { 
  128.                 sprite.position.x = -1000; 
  129.             } 
  130.         }); 
  131.  
  132.         const frequencyData = new Uint8Array(analyser.frequencyBinCount); 
  133.         analyser.getByteFrequencyData(frequencyData); 
  134.  
  135.         const averageFrequencyData = []; 
  136.         for (let i = 0; i< frequencyData.length; i += STEP) { 
  137.             let sum = 0; 
  138.             for(let j = i; j < i + STEP; j++) { 
  139.                 sum += frequencyData[j]; 
  140.             } 
  141.             averageFrequencyData.push(sum / STEP); 
  142.         } 
  143.         for (let i = 0; i < averageFrequencyData.length; i++) { 
  144.             cubes.children[i].scale.y = Math.floor(averageFrequencyData[i] * 0.4); 
  145.         } 
  146.  
  147.         scene.rotateX(0.005); 
  148.         renderer.render(scene, camera); 
  149.  
  150.         requestAnimationFrame(render); 
  151.     } 
  152.  
  153.     const controls = new THREE.OrbitControls(camera); 
  154.  
  155. </script> 
  156. </body> 
  157. </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 的一个应用场景,还有游戏也是一个应用场景,后面我们都会做一些探索。

 

责任编辑:姜华 来源: 神光的编程秘籍
相关推荐

2019-11-29 09:30:37

Three.js3D前端

2022-01-16 19:23:25

Three.js粒子动画群星送福

2021-12-14 11:44:37

可视化Three.js 信息

2021-11-23 22:50:14

.js 3D几何体

2012-11-13 10:52:15

大数据3D可视化

2023-09-01 09:30:22

Three.js3D 图形库

2023-07-13 10:48:22

web 3DThree.jsBlender

2021-04-21 09:20:15

three.js3d前端

2016-06-01 09:19:08

开发3D游戏

2021-04-23 16:40:49

Three.js前端代码

2015-03-12 11:08:42

2017-05-08 11:41:37

WebGLThree.js

2023-08-04 09:56:15

2023-08-18 06:59:58

2021-03-08 09:25:48

神经网络数据图形

2022-07-15 13:09:33

Three.js前端

2021-09-16 07:52:18

SwiftUScroll效果

2013-12-11 16:55:23

3DDCIM解决方案

2021-12-03 07:27:30

全景浏览Three.js

2013-04-12 09:32:16

微软3D数据可视化工具插件GeoFlow
点赞
收藏

51CTO技术栈公众号